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