support for user roles (owner, crawler, viewer), owner users can issue invites to other existing users by email to join existing archives
This commit is contained in:
parent
61a608bfbe
commit
eaa87c8b43
@ -3,15 +3,30 @@ Archive API handling
|
||||
"""
|
||||
import os
|
||||
import uuid
|
||||
from typing import Optional, List
|
||||
import datetime
|
||||
|
||||
from typing import Optional, Dict
|
||||
|
||||
|
||||
from pydantic import BaseModel, UUID4
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from db import BaseMongoModel
|
||||
|
||||
from users import User
|
||||
from users import User, InvitePending, UserRole
|
||||
|
||||
|
||||
# ============================================================================
|
||||
class InviteRequest(BaseModel):
|
||||
"""Request to invite another user to an archive"""
|
||||
|
||||
email: str
|
||||
role: UserRole
|
||||
|
||||
|
||||
# ============================================================================
|
||||
class UpdateRole(InviteRequest):
|
||||
"""Update existing role for user"""
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@ -19,7 +34,6 @@ class S3Storage(BaseModel):
|
||||
"""S3 Storage Model"""
|
||||
|
||||
type: str = "S3Storage"
|
||||
name: str
|
||||
endpoint_url: str
|
||||
access_key: str
|
||||
secret_key: str
|
||||
@ -31,10 +45,44 @@ class Archive(BaseMongoModel):
|
||||
"""Archive Base Model"""
|
||||
|
||||
name: str
|
||||
users: List[UUID4]
|
||||
admin_user: UUID4
|
||||
|
||||
users: Dict[str, UserRole]
|
||||
|
||||
storage: S3Storage
|
||||
|
||||
def is_owner(self, user):
|
||||
"""Check if user is owner"""
|
||||
return self._is_auth(user, UserRole.OWNER)
|
||||
|
||||
def is_crawler(self, user):
|
||||
"""Check if user can crawl (write)"""
|
||||
return self._is_auth(user, UserRole.CRAWLER)
|
||||
|
||||
def is_viewer(self, user):
|
||||
"""Check if user can view (read)"""
|
||||
return self._is_auth(user, UserRole.VIEWER)
|
||||
|
||||
def _is_auth(self, user, value):
|
||||
"""Check if user has at least specified permission level"""
|
||||
res = self.users.get(str(user.id))
|
||||
if not res:
|
||||
return False
|
||||
|
||||
return res >= value
|
||||
|
||||
def serialize_for_user(self, user: User):
|
||||
"""Serialize based on current user access"""
|
||||
exclude = {}
|
||||
if not self.is_owner(user):
|
||||
exclude = {"users", "storage"}
|
||||
|
||||
return self.dict(
|
||||
exclude_unset=True,
|
||||
exclude_defaults=True,
|
||||
exclude_none=True,
|
||||
exclude=exclude,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
class ArchiveOps:
|
||||
@ -79,28 +127,40 @@ class ArchiveOps:
|
||||
archive = Archive(
|
||||
id=id_,
|
||||
name=archive_name,
|
||||
admin_user=user.id,
|
||||
users=[user.id],
|
||||
users={str(user.id): UserRole.OWNER},
|
||||
storage=storage,
|
||||
)
|
||||
|
||||
print(f"Created New Archive with storage at {endpoint_url}")
|
||||
await self.add_archive(archive)
|
||||
|
||||
async def get_archives_for_user(self, user: User):
|
||||
async def get_archives_for_user(self, user: User, role: UserRole = UserRole.VIEWER):
|
||||
"""Get all archives a user is a member of"""
|
||||
cursor = self.archives.find({"users": user.id})
|
||||
query = {f"users.{user.id}": {"$gte": role.value}}
|
||||
cursor = self.archives.find(query)
|
||||
results = await cursor.to_list(length=1000)
|
||||
return [Archive.from_dict(res) for res in results]
|
||||
|
||||
async def get_archive_for_user_by_id(self, uid: str, user: User):
|
||||
async def get_archive_for_user_by_id(
|
||||
self, uid: str, user: User, role: UserRole = UserRole.VIEWER
|
||||
):
|
||||
"""Get an archive for user by unique id"""
|
||||
res = await self.archives.find_one({"_id": uid, "users": user.id})
|
||||
query = {f"users.{user.id}": {"$gte": role.value}, "_id": uid}
|
||||
res = await self.archives.find_one(query)
|
||||
return Archive.from_dict(res)
|
||||
|
||||
async def get_archive_by_id(self, uid: str):
|
||||
"""Get an archive by id"""
|
||||
res = await self.archives.find_one({"_id": uid})
|
||||
return Archive.from_dict(res)
|
||||
|
||||
async def update(self, archive: Archive):
|
||||
"""Update existing archive"""
|
||||
self.archives.replace_one({"_id": archive.id}, archive.to_dict())
|
||||
|
||||
|
||||
# ============================================================================
|
||||
def init_archives_api(app, mdb, user_dep: User):
|
||||
def init_archives_api(app, mdb, users, user_dep: User):
|
||||
"""Init archives api router for /archives"""
|
||||
ops = ArchiveOps(mdb)
|
||||
|
||||
@ -113,7 +173,6 @@ def init_archives_api(app, mdb, user_dep: User):
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/archives/{aid}",
|
||||
tags=["archives"],
|
||||
dependencies=[Depends(archive_dep)],
|
||||
responses={404: {"description": "Not found"}},
|
||||
)
|
||||
@ -124,16 +183,100 @@ def init_archives_api(app, mdb, user_dep: User):
|
||||
@app.get("/archives", tags=["archives"])
|
||||
async def get_archives(user: User = Depends(user_dep)):
|
||||
results = await ops.get_archives_for_user(user)
|
||||
return {"archives": [res.serialize() for res in results]}
|
||||
return {"archives": [res.serialize_for_user(user) for res in results]}
|
||||
|
||||
@router.get("")
|
||||
async def get_archive(archive: Archive = Depends(archive_dep)):
|
||||
return archive.serialize()
|
||||
@router.get("", tags=["archives"])
|
||||
async def get_archive(
|
||||
archive: Archive = Depends(archive_dep), user: User = Depends(user_dep)
|
||||
):
|
||||
return archive.serialize_for_user(user)
|
||||
|
||||
# @router.post("/{id}/storage")
|
||||
# async def add_storage(storage: S3Storage, user: User = Depends(user_dep)):
|
||||
# storage.user = user.id
|
||||
# res = await ops.add_storage(storage)
|
||||
# return {"added": str(res.inserted_id)}
|
||||
@router.post("/invite", tags=["invites"])
|
||||
async def invite_user(
|
||||
invite: InviteRequest,
|
||||
archive: Archive = Depends(archive_dep),
|
||||
user: User = Depends(user_dep),
|
||||
):
|
||||
|
||||
if not archive.is_owner(user):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="User does not have permission to invite other users",
|
||||
)
|
||||
|
||||
other_user = await users.db.get_by_email(invite.email)
|
||||
if not other_user:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="No user found for specified e-mail"
|
||||
)
|
||||
|
||||
if other_user.email == user.email:
|
||||
raise HTTPException(status_code=400, detail="Can't invite ourselves!")
|
||||
|
||||
if archive.users.get(str(other_user.id)):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="User already a member of this archive."
|
||||
)
|
||||
|
||||
# try:
|
||||
# role = UserRole[invite.role].name
|
||||
# except KeyError:
|
||||
# # pylint: disable=raise-missing-from
|
||||
# raise HTTPException(status_code=400, detail="Invalid User Role")
|
||||
|
||||
invite_code = uuid.uuid4().hex
|
||||
other_user.invites[invite_code] = InvitePending(
|
||||
aid=str(archive.id), created=datetime.datetime.utcnow(), role=invite.role
|
||||
)
|
||||
await users.db.update(other_user)
|
||||
return {
|
||||
"invite_code": invite_code,
|
||||
"email": invite.email,
|
||||
"role": invite.role.value,
|
||||
}
|
||||
|
||||
@router.patch("/user-role", tags=["invites"])
|
||||
async def set_role(
|
||||
update: UpdateRole,
|
||||
archive: Archive = Depends(archive_dep),
|
||||
user: User = Depends(user_dep),
|
||||
):
|
||||
|
||||
if not archive.is_owner(user):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="User does not have permission to invite other users",
|
||||
)
|
||||
|
||||
other_user = await users.db.get_by_email(update.email)
|
||||
if not other_user:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="No user found for specified e-mail"
|
||||
)
|
||||
|
||||
if other_user.email == user.email:
|
||||
raise HTTPException(status_code=400, detail="Can't change own role!")
|
||||
|
||||
archive.users[str(other_user.id)] = update.role
|
||||
await ops.update(archive)
|
||||
|
||||
return {"updated": True}
|
||||
|
||||
@app.get("/invite/accept/{token}", tags=["invites"])
|
||||
async def accept_invite(token: str, user: User = Depends(user_dep)):
|
||||
invite = user.invites.pop(token, "")
|
||||
if not invite:
|
||||
raise HTTPException(status_code=400, detail="Invalid Invite Code")
|
||||
|
||||
archive = await ops.get_archive_by_id(invite.aid)
|
||||
if not archive:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid Invite Code, No Such Archive"
|
||||
)
|
||||
|
||||
archive.users[str(user.id)] = invite.role
|
||||
await ops.update(archive)
|
||||
await users.db.update(user)
|
||||
return {"added": True}
|
||||
|
||||
return ops
|
||||
|
@ -128,7 +128,7 @@ class CrawlOps:
|
||||
crawlconfig = CrawlConfig.from_dict(data)
|
||||
|
||||
await self.crawl_manager.add_crawl_config(
|
||||
uid=str(user.id),
|
||||
userid=str(user.id),
|
||||
aid=str(archive.id),
|
||||
storage=archive.storage,
|
||||
crawlconfig=crawlconfig,
|
||||
@ -194,16 +194,36 @@ def init_crawl_config_api(mdb, user_dep, archive_ops, crawl_manager):
|
||||
archive: Archive = Depends(archive_dep),
|
||||
user: User = Depends(user_dep),
|
||||
):
|
||||
|
||||
if not archive.is_crawler(user):
|
||||
raise HTTPException(
|
||||
status_code=403, detail="User does not have permission to modify crawls"
|
||||
)
|
||||
|
||||
res = await ops.add_crawl_config(config, archive, user)
|
||||
return {"added": str(res.inserted_id)}
|
||||
|
||||
@router.delete("")
|
||||
async def delete_crawl_configs(archive: Archive = Depends(archive_dep)):
|
||||
async def delete_crawl_configs(
|
||||
archive: Archive = Depends(archive_dep), user: User = Depends(user_dep)
|
||||
):
|
||||
if not archive.is_crawler(user):
|
||||
raise HTTPException(
|
||||
status_code=403, detail="User does not have permission to modify crawls"
|
||||
)
|
||||
|
||||
result = await ops.delete_crawl_configs(archive)
|
||||
return {"deleted": result.deleted_count}
|
||||
|
||||
@router.delete("/{id}")
|
||||
async def delete_crawl_config(id: str, archive: Archive = Depends(archive_dep)):
|
||||
async def delete_crawl_config(
|
||||
id: str, archive: Archive = Depends(archive_dep), user: User = Depends(user_dep)
|
||||
):
|
||||
if not archive.is_crawler(user):
|
||||
raise HTTPException(
|
||||
status_code=403, detail="User does not have permission to modify crawls"
|
||||
)
|
||||
|
||||
result = await ops.delete_crawl_config(id, archive)
|
||||
if not result or not result.deleted_count:
|
||||
raise HTTPException(status_code=404, detail="Crawl Config Not Found")
|
||||
|
@ -1,20 +1,21 @@
|
||||
from archives import Archive
|
||||
from crawls import CrawlConfig
|
||||
from baseman import BaseMan
|
||||
|
||||
|
||||
class DockerManager(BaseMan):
|
||||
class DockerManager:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
async def add_crawl_config(
|
||||
self,
|
||||
userid: str,
|
||||
archive: Archive,
|
||||
crawlconfig: CrawlConfig,
|
||||
aid: str,
|
||||
storage,
|
||||
crawlconfig,
|
||||
extra_crawl_params: list = None,
|
||||
):
|
||||
print("add_crawl_config")
|
||||
print(storage)
|
||||
print(crawlconfig)
|
||||
print(archive)
|
||||
print(aid)
|
||||
print(extra_crawl_params)
|
||||
|
@ -57,7 +57,9 @@ class BrowsertrixAPI:
|
||||
|
||||
current_active_user = self.fastapi_users.current_user(active=True)
|
||||
|
||||
self.archive_ops = init_archives_api(self.app, self.mdb, current_active_user)
|
||||
self.archive_ops = init_archives_api(
|
||||
self.app, self.mdb, self.fastapi_users, current_active_user
|
||||
)
|
||||
|
||||
self.crawl_config_ops = init_crawl_config_api(
|
||||
self.mdb,
|
||||
|
@ -4,6 +4,15 @@ FastAPI user handling (via fastapi-users)
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from typing import Dict
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fastapi_users import FastAPIUsers, models
|
||||
from fastapi_users.authentication import JWTAuthentication
|
||||
from fastapi_users.db import MongoDBUserDatabase
|
||||
@ -11,12 +20,32 @@ from fastapi_users.db import MongoDBUserDatabase
|
||||
PASSWORD_SECRET = os.environ.get("PASSWORD_SECRET", uuid.uuid4().hex)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
class UserRole(IntEnum):
|
||||
"""User role"""
|
||||
|
||||
VIEWER = 10
|
||||
CRAWLER = 20
|
||||
OWNER = 40
|
||||
|
||||
|
||||
# ============================================================================
|
||||
class InvitePending(BaseModel):
|
||||
"""Pending Request to join an archive"""
|
||||
|
||||
aid: str
|
||||
created: datetime
|
||||
role: UserRole = UserRole.VIEWER
|
||||
|
||||
|
||||
# ============================================================================
|
||||
class User(models.BaseUser):
|
||||
"""
|
||||
Base User Model
|
||||
"""
|
||||
|
||||
invites: Dict[str, InvitePending] = {}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
class UserCreate(models.BaseUserCreate):
|
||||
@ -24,6 +53,8 @@ class UserCreate(models.BaseUserCreate):
|
||||
User Creation Model
|
||||
"""
|
||||
|
||||
invites: Dict[str, InvitePending] = {}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
class UserUpdate(User, models.BaseUserUpdate):
|
||||
@ -31,6 +62,8 @@ class UserUpdate(User, models.BaseUserUpdate):
|
||||
User Update Model
|
||||
"""
|
||||
|
||||
invites: Dict[str, InvitePending] = {}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
class UserDB(User, models.BaseUserDB):
|
||||
@ -38,6 +71,8 @@ class UserDB(User, models.BaseUserDB):
|
||||
User in DB Model
|
||||
"""
|
||||
|
||||
invites: Dict[str, InvitePending] = {}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
def init_users_api(
|
||||
|
Loading…
Reference in New Issue
Block a user