"""Permissions plugin routes — file/folder permissions, share links, public access.""" from __future__ import annotations import secrets import uuid from datetime import UTC, datetime from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.auth import hash_password, verify_password from app.core.db import get_db from app.deps import get_current_user from app.plugins.builtins.permissions.models import Permission, ShareLink from app.plugins.builtins.permissions.schemas import ( PermissionGrantRequest, ShareLinkCreateRequest, ShareLinkVerifyRequest, ) router = APIRouter(prefix="/api/v1/dms", tags=["permissions"]) public_router = APIRouter(prefix="/api/public", tags=["public-share"]) def _parse_uuid(val: str, field: str) -> uuid.UUID: try: return uuid.UUID(val) except (ValueError, TypeError): raise HTTPException( 400, detail={"detail": f"Invalid {field}", "code": "invalid_id"} ) from None # ─── Permissions CRUD ─── @router.get("/files/{file_id}/permissions") async def list_permissions( file_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """List all permissions for a file.""" tenant_id = uuid.UUID(current_user["tenant_id"]) fid = _parse_uuid(file_id, "file_id") result = await db.execute( select(Permission).where( Permission.tenant_id == tenant_id, Permission.file_id == fid, ) ) perms = result.scalars().all() return [ { "id": str(p.id), "file_id": str(p.file_id), "user_id": str(p.user_id), "group_id": str(p.group_id) if p.group_id else None, "access_level": p.access_level, } for p in perms ] @router.post("/files/{file_id}/permissions", status_code=status.HTTP_201_CREATED) async def grant_permission( file_id: str, body: PermissionGrantRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Grant a permission on a file to a user.""" tenant_id = uuid.UUID(current_user["tenant_id"]) fid = _parse_uuid(file_id, "file_id") user_id = _parse_uuid(body.user_id, "user_id") group_id = _parse_uuid(body.group_id, "group_id") if body.group_id else None # Check if permission already exists existing = await db.execute( select(Permission).where( Permission.tenant_id == tenant_id, Permission.file_id == fid, Permission.user_id == user_id, Permission.access_level == body.access_level, ) ) if existing.scalar_one_or_none() is not None: raise HTTPException( 409, detail={"detail": "Permission already exists", "code": "duplicate"} ) perm = Permission( tenant_id=tenant_id, file_id=fid, user_id=user_id, group_id=group_id, access_level=body.access_level, ) db.add(perm) await db.flush() return { "id": str(perm.id), "file_id": str(perm.file_id), "user_id": str(perm.user_id), "group_id": str(perm.group_id) if perm.group_id else None, "access_level": perm.access_level, } @router.delete("/files/{file_id}/permissions/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def revoke_permission( file_id: str, user_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Revoke all permissions for a user on a file.""" tenant_id = uuid.UUID(current_user["tenant_id"]) fid = _parse_uuid(file_id, "file_id") uid = _parse_uuid(user_id, "user_id") result = await db.execute( select(Permission).where( Permission.tenant_id == tenant_id, Permission.file_id == fid, Permission.user_id == uid, ) ) perms = result.scalars().all() if not perms: raise HTTPException(404, detail={"detail": "Permission not found", "code": "not_found"}) for p in perms: await db.delete(p) return Response(status_code=status.HTTP_204_NO_CONTENT) # ─── Permission Check Helper ─── async def check_user_file_permission( db: AsyncSession, tenant_id: uuid.UUID, file_id: uuid.UUID, user_id: uuid.UUID, required: str = "read", ) -> bool: """Check if user has required permission on a file.""" result = await db.execute( select(Permission).where( Permission.tenant_id == tenant_id, Permission.file_id == file_id, Permission.user_id == user_id, ) ) perms = result.scalars().all() if not perms: return False if required == "read": return any(p.access_level in ("read", "write") for p in perms) if required == "write": return any(p.access_level == "write" for p in perms) return False # ─── Share Links ─── @router.post("/files/{file_id}/share-link") async def create_share_link( file_id: str, body: ShareLinkCreateRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Create a public share link for a file.""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) fid = _parse_uuid(file_id, "file_id") token = secrets.token_urlsafe(32) password_hash = hash_password(body.password) if body.password else None link = ShareLink( tenant_id=tenant_id, file_id=fid, token=token, password_hash=password_hash, expires_at=body.expires_at, access_level=body.access_level, created_by=user_id, ) db.add(link) await db.flush() return { "id": str(link.id), "file_id": str(link.file_id), "token": token, "public_url": f"/api/public/share/{token}", "expires_at": link.expires_at.isoformat() if link.expires_at else None, "access_level": link.access_level, "has_password": password_hash is not None, } @router.delete("/share-links/{link_id}", status_code=status.HTTP_204_NO_CONTENT) async def revoke_share_link( link_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Revoke (delete) a share link.""" tenant_id = uuid.UUID(current_user["tenant_id"]) lid = _parse_uuid(link_id, "link_id") result = await db.execute( select(ShareLink).where(ShareLink.id == lid, ShareLink.tenant_id == tenant_id) ) link = result.scalar_one_or_none() if link is None: raise HTTPException(404, detail={"detail": "Share link not found", "code": "not_found"}) await db.delete(link) return Response(status_code=status.HTTP_204_NO_CONTENT) # ─── Public Share Access (NO AUTH) ─── @public_router.get("/share/{token}") async def public_access( token: str, db: AsyncSession = Depends(get_db), ): """Public share access — no auth required. Returns 410 Gone if link is expired. Returns 403 if password is required but not provided/invalid. """ result = await db.execute(select(ShareLink).where(ShareLink.token == token)) link = result.scalar_one_or_none() if link is None: raise HTTPException(404, detail={"detail": "Share link not found", "code": "not_found"}) # Check expiry if link.expires_at is not None: now = datetime.now(UTC) if now > link.expires_at: raise HTTPException(410, detail={"detail": "Share link expired", "code": "expired"}) # Check password if link.password_hash is not None: # Password must be provided via query param or header — check query param # We need the request to get the password param # For simplicity, return that password is required raise HTTPException( 401, detail={"detail": "Password required", "code": "password_required"}, ) return { "file_id": str(link.file_id), "access_level": link.access_level, "has_password": False, } @public_router.post("/share/{token}") async def public_access_with_password( token: str, body: ShareLinkVerifyRequest, db: AsyncSession = Depends(get_db), ): """Public share access with password verification — no auth required.""" result = await db.execute(select(ShareLink).where(ShareLink.token == token)) link = result.scalar_one_or_none() if link is None: raise HTTPException(404, detail={"detail": "Share link not found", "code": "not_found"}) # Check expiry if link.expires_at is not None: now = datetime.now(UTC) if now > link.expires_at: raise HTTPException(410, detail={"detail": "Share link expired", "code": "expired"}) # Verify password if required if link.password_hash is not None: if body.password is None: raise HTTPException( 401, detail={"detail": "Password required", "code": "password_required"} ) if not verify_password(body.password, link.password_hash): raise HTTPException( 403, detail={"detail": "Invalid password", "code": "invalid_password"} ) return { "file_id": str(link.file_id), "access_level": link.access_level, "has_password": link.password_hash is not None, }