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:
Ilya Kreymer 2021-08-18 20:34:24 -07:00
parent 61a608bfbe
commit eaa87c8b43
5 changed files with 233 additions and 32 deletions

View File

@ -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

View File

@ -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")

View File

@ -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)

View File

@ -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,

View File

@ -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(