From eaa87c8b43bca1b325ee3eb4eb88280a638a531f Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Wed, 18 Aug 2021 20:34:24 -0700 Subject: [PATCH] support for user roles (owner, crawler, viewer), owner users can issue invites to other existing users by email to join existing archives --- backend/archives.py | 189 +++++++++++++++++++++++++++++++++++++------ backend/crawls.py | 26 +++++- backend/dockerman.py | 11 +-- backend/main.py | 4 +- backend/users.py | 35 ++++++++ 5 files changed, 233 insertions(+), 32 deletions(-) diff --git a/backend/archives.py b/backend/archives.py index 2fccaa0d..ecf8f0bf 100644 --- a/backend/archives.py +++ b/backend/archives.py @@ -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 diff --git a/backend/crawls.py b/backend/crawls.py index 690f5031..a95e11ee 100644 --- a/backend/crawls.py +++ b/backend/crawls.py @@ -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") diff --git a/backend/dockerman.py b/backend/dockerman.py index 033280d4..2bc22626 100644 --- a/backend/dockerman.py +++ b/backend/dockerman.py @@ -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) diff --git a/backend/main.py b/backend/main.py index 1fa9582f..2f9d39f5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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, diff --git a/backend/users.py b/backend/users.py index 0e6bb885..1baaabbc 100644 --- a/backend/users.py +++ b/backend/users.py @@ -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(