sprint9: app visibility — sidebar permission filter + TopBar + ProtectedRoute + route guards
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
This commit is contained in:
@@ -10,9 +10,10 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
|
||||
class Folder(Base, TenantMixin):
|
||||
class Folder(Base, TenantMixin, OwnedMixin):
|
||||
"""Folder entity — hierarchical, tenant-scoped, soft-deletable."""
|
||||
|
||||
__tablename__ = "folders"
|
||||
@@ -41,7 +42,7 @@ class Folder(Base, TenantMixin):
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class File(Base, TenantMixin):
|
||||
class File(Base, TenantMixin, OwnedMixin):
|
||||
"""File entity — stored on disk, tenant-scoped, soft-deletable."""
|
||||
|
||||
__tablename__ = "files"
|
||||
|
||||
@@ -22,6 +22,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.storage import get_storage_backend
|
||||
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
||||
from app.deps import get_current_user, require_permission
|
||||
from app.plugins.builtins.dms.models import File as DmsFile
|
||||
from app.plugins.builtins.dms.models import Folder
|
||||
@@ -106,13 +107,17 @@ async def list_folders(
|
||||
"""AC1: GET /api/v1/dms/folders → 200 + folder tree (recursive)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
|
||||
# Fetch all non-deleted folders for tenant
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.tenant_id == tenant_id,
|
||||
Folder.deleted_at.is_(None),
|
||||
)
|
||||
# Fetch all non-deleted folders for tenant with visibility filter
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
query = select(Folder).where(
|
||||
Folder.tenant_id == tenant_id,
|
||||
Folder.deleted_at.is_(None),
|
||||
)
|
||||
query = await apply_visibility_filter(
|
||||
db, query, "dms_folder", Folder, user_id, tenant_id, is_system_admin
|
||||
)
|
||||
result = await db.execute(query)
|
||||
all_folders = result.scalars().all()
|
||||
|
||||
# Build lookup map
|
||||
@@ -254,6 +259,8 @@ async def update_folder(
|
||||
):
|
||||
"""AC3: PATCH /api/v1/dms/folders/{id} → 200, rename/move."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
fid = _parse_uuid(folder_id, "folder_id")
|
||||
|
||||
result = await db.execute(
|
||||
@@ -267,6 +274,9 @@ async def update_folder(
|
||||
if folder is None:
|
||||
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
|
||||
|
||||
if not await check_single_entity_access(db, "dms_folder", fid, user_id, tenant_id, "write", is_system_admin):
|
||||
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
|
||||
if "name" in data and data["name"] is not None:
|
||||
@@ -366,6 +376,8 @@ async def delete_folder(
|
||||
):
|
||||
"""AC4: DELETE /api/v1/dms/folders/{id} → 204, soft-delete with cascade."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
fid = _parse_uuid(folder_id, "folder_id")
|
||||
|
||||
result = await db.execute(
|
||||
@@ -379,6 +391,9 @@ async def delete_folder(
|
||||
if folder is None:
|
||||
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
|
||||
|
||||
if not await check_single_entity_access(db, "dms_folder", fid, user_id, tenant_id, "delete", is_system_admin):
|
||||
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
now = datetime.now(UTC)
|
||||
@@ -520,6 +535,8 @@ async def get_file(
|
||||
):
|
||||
"""AC6: GET /api/v1/dms/files/{id} → 200 + file metadata."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
fid = _parse_uuid(file_id, "file_id")
|
||||
|
||||
result = await db.execute(
|
||||
@@ -533,6 +550,9 @@ async def get_file(
|
||||
if dms_file is None:
|
||||
raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"})
|
||||
|
||||
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "read", is_system_admin):
|
||||
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||
|
||||
return {
|
||||
"id": str(dms_file.id),
|
||||
"name": dms_file.name,
|
||||
@@ -554,13 +574,17 @@ async def list_all_files(
|
||||
):
|
||||
"""List all non-deleted files for the current tenant."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
|
||||
result = await db.execute(
|
||||
select(DmsFile).where(
|
||||
DmsFile.tenant_id == tenant_id,
|
||||
DmsFile.deleted_at.is_(None),
|
||||
)
|
||||
query = select(DmsFile).where(
|
||||
DmsFile.tenant_id == tenant_id,
|
||||
DmsFile.deleted_at.is_(None),
|
||||
)
|
||||
query = await apply_visibility_filter(
|
||||
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
|
||||
)
|
||||
result = await db.execute(query)
|
||||
files = result.scalars().all()
|
||||
|
||||
return [
|
||||
@@ -587,6 +611,8 @@ async def list_files_in_folder(
|
||||
):
|
||||
"""List all non-deleted files in a specific folder (non-recursive)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
fid = _parse_uuid(folder_id, "folder_id")
|
||||
|
||||
# Validate folder exists
|
||||
@@ -600,13 +626,15 @@ async def list_files_in_folder(
|
||||
if folder_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
|
||||
|
||||
result = await db.execute(
|
||||
select(DmsFile).where(
|
||||
DmsFile.tenant_id == tenant_id,
|
||||
DmsFile.folder_id == fid,
|
||||
DmsFile.deleted_at.is_(None),
|
||||
)
|
||||
query = select(DmsFile).where(
|
||||
DmsFile.tenant_id == tenant_id,
|
||||
DmsFile.folder_id == fid,
|
||||
DmsFile.deleted_at.is_(None),
|
||||
)
|
||||
query = await apply_visibility_filter(
|
||||
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
|
||||
)
|
||||
result = await db.execute(query)
|
||||
files = result.scalars().all()
|
||||
|
||||
return [
|
||||
@@ -634,6 +662,8 @@ async def update_file(
|
||||
):
|
||||
"""AC7: PATCH /api/v1/dms/files/{id} → 200, rename/move."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
fid = _parse_uuid(file_id, "file_id")
|
||||
|
||||
result = await db.execute(
|
||||
@@ -647,6 +677,9 @@ async def update_file(
|
||||
if dms_file is None:
|
||||
raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"})
|
||||
|
||||
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "write", is_system_admin):
|
||||
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
|
||||
if "name" in data and data["name"] is not None:
|
||||
@@ -691,6 +724,8 @@ async def delete_file(
|
||||
):
|
||||
"""AC8: DELETE /api/v1/dms/files/{id} → 204, soft-delete."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
fid = _parse_uuid(file_id, "file_id")
|
||||
|
||||
result = await db.execute(
|
||||
@@ -704,6 +739,9 @@ async def delete_file(
|
||||
if dms_file is None:
|
||||
raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"})
|
||||
|
||||
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "delete", is_system_admin):
|
||||
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
dms_file.deleted_at = datetime.now(UTC)
|
||||
@@ -719,6 +757,8 @@ async def restore_file(
|
||||
):
|
||||
"""AC9: POST /api/v1/dms/files/{id}/restore → 200, restored from trash."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
fid = _parse_uuid(file_id, "file_id")
|
||||
|
||||
result = await db.execute(
|
||||
@@ -732,6 +772,9 @@ async def restore_file(
|
||||
if dms_file is None:
|
||||
raise HTTPException(404, detail={"detail": "Deleted file not found", "code": "not_found"})
|
||||
|
||||
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "write", is_system_admin):
|
||||
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||
|
||||
dms_file.deleted_at = None
|
||||
await db.flush()
|
||||
await db.refresh(dms_file)
|
||||
@@ -761,6 +804,8 @@ async def preview_file(
|
||||
):
|
||||
"""AC10: GET /api/v1/dms/files/{id}/preview → 200 + PDF stream."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
fid = _parse_uuid(file_id, "file_id")
|
||||
|
||||
result = await db.execute(
|
||||
@@ -774,6 +819,9 @@ async def preview_file(
|
||||
if dms_file is None:
|
||||
raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"})
|
||||
|
||||
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "read", is_system_admin):
|
||||
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||
|
||||
if dms_file.mime_type != "application/pdf":
|
||||
raise HTTPException(
|
||||
400, detail={"detail": "Only PDF files can be previewed", "code": "not_pdf"}
|
||||
@@ -807,6 +855,7 @@ async def create_edit_session(
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = current_user["user_id"]
|
||||
user_name = current_user.get("name", "Unknown")
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
fid = _parse_uuid(file_id, "file_id")
|
||||
|
||||
result = await db.execute(
|
||||
@@ -820,6 +869,9 @@ async def create_edit_session(
|
||||
if dms_file is None:
|
||||
raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"})
|
||||
|
||||
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "write", is_system_admin):
|
||||
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||
|
||||
ext = _get_file_extension(dms_file.name)
|
||||
if ext not in OFFICE_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
@@ -866,6 +918,8 @@ async def share_file(
|
||||
):
|
||||
"""AC12: POST /api/v1/dms/files/{id}/share → 200, internal share created."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
fid = _parse_uuid(file_id, "file_id")
|
||||
|
||||
# Verify file exists
|
||||
@@ -879,6 +933,9 @@ async def share_file(
|
||||
if file_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(404, detail={"detail": "File not found", "code": "not_found"})
|
||||
|
||||
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "share", is_system_admin):
|
||||
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||
|
||||
created_perms: list[dict] = []
|
||||
|
||||
for uid_str in body.user_ids:
|
||||
@@ -958,8 +1015,13 @@ async def remove_share(
|
||||
):
|
||||
"""AC13: DELETE /api/v1/dms/files/{id}/share → 204, share removed."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
fid = _parse_uuid(file_id, "file_id")
|
||||
|
||||
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "share", is_system_admin):
|
||||
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
|
||||
|
||||
if body.user_id:
|
||||
uid = _parse_uuid(body.user_id, "user_id")
|
||||
result = await db.execute(
|
||||
@@ -1001,14 +1063,18 @@ async def search_files(
|
||||
):
|
||||
"""AC16: GET /api/v1/dms/search?q=text → 200 + matching files (ILIKE)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
|
||||
result = await db.execute(
|
||||
select(DmsFile).where(
|
||||
DmsFile.tenant_id == tenant_id,
|
||||
DmsFile.deleted_at.is_(None),
|
||||
DmsFile.name.ilike(f"%{q}%"),
|
||||
)
|
||||
query = select(DmsFile).where(
|
||||
DmsFile.tenant_id == tenant_id,
|
||||
DmsFile.deleted_at.is_(None),
|
||||
DmsFile.name.ilike(f"%{q}%"),
|
||||
)
|
||||
query = await apply_visibility_filter(
|
||||
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
|
||||
)
|
||||
result = await db.execute(query)
|
||||
files = result.scalars().all()
|
||||
|
||||
return [
|
||||
@@ -1034,6 +1100,7 @@ async def shared_with_me(
|
||||
"""AC17: GET /api/v1/dms/shared-with-me → 200 + shared files list."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
|
||||
# Query permissions for this user and join with files
|
||||
perm_result = await db.execute(
|
||||
@@ -1048,14 +1115,16 @@ async def shared_with_me(
|
||||
if not file_ids:
|
||||
return {"items": [], "total": 0}
|
||||
|
||||
file_result = await db.execute(
|
||||
select(DmsFile).where(
|
||||
DmsFile.tenant_id == tenant_id,
|
||||
DmsFile.id.in_(file_ids),
|
||||
DmsFile.deleted_at.is_(None),
|
||||
)
|
||||
query = select(DmsFile).where(
|
||||
DmsFile.tenant_id == tenant_id,
|
||||
DmsFile.id.in_(file_ids),
|
||||
DmsFile.deleted_at.is_(None),
|
||||
)
|
||||
files = file_result.scalars().all()
|
||||
query = await apply_visibility_filter(
|
||||
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
|
||||
)
|
||||
result = await db.execute(query)
|
||||
files = result.scalars().all()
|
||||
|
||||
# Map permissions for access_level
|
||||
perm_map: dict[uuid.UUID, str] = {}
|
||||
@@ -1086,6 +1155,8 @@ async def bulk_move(
|
||||
):
|
||||
"""AC18: POST /api/v1/dms/files/bulk-move → 200, files moved."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
target_folder_id = (
|
||||
_parse_uuid(body.target_folder_id, "target_folder_id") if body.target_folder_id else None
|
||||
)
|
||||
@@ -1106,13 +1177,15 @@ async def bulk_move(
|
||||
|
||||
file_ids = [_parse_uuid(fid, "file_id") for fid in body.file_ids]
|
||||
|
||||
result = await db.execute(
|
||||
select(DmsFile).where(
|
||||
DmsFile.tenant_id == tenant_id,
|
||||
DmsFile.id.in_(file_ids),
|
||||
DmsFile.deleted_at.is_(None),
|
||||
)
|
||||
query = select(DmsFile).where(
|
||||
DmsFile.tenant_id == tenant_id,
|
||||
DmsFile.id.in_(file_ids),
|
||||
DmsFile.deleted_at.is_(None),
|
||||
)
|
||||
query = await apply_visibility_filter(
|
||||
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
|
||||
)
|
||||
result = await db.execute(query)
|
||||
files = result.scalars().all()
|
||||
|
||||
moved_count = 0
|
||||
@@ -1137,17 +1210,32 @@ async def bulk_delete(
|
||||
):
|
||||
"""AC19: POST /api/v1/dms/files/bulk-delete → 200, files soft-deleted."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
is_system_admin = current_user.get("role") == "admin"
|
||||
file_ids = [_parse_uuid(fid, "file_id") for fid in body.file_ids]
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Apply visibility filter to only delete files user has access to
|
||||
query = select(DmsFile).where(
|
||||
DmsFile.tenant_id == tenant_id,
|
||||
DmsFile.id.in_(file_ids),
|
||||
DmsFile.deleted_at.is_(None),
|
||||
)
|
||||
query = await apply_visibility_filter(
|
||||
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
|
||||
)
|
||||
result = await db.execute(query)
|
||||
accessible_files = result.scalars().all()
|
||||
accessible_ids = [f.id for f in accessible_files]
|
||||
|
||||
result = await db.execute(
|
||||
update(DmsFile)
|
||||
.where(
|
||||
DmsFile.tenant_id == tenant_id,
|
||||
DmsFile.id.in_(file_ids),
|
||||
DmsFile.id.in_(accessible_ids),
|
||||
DmsFile.deleted_at.is_(None),
|
||||
)
|
||||
.values(deleted_at=now)
|
||||
|
||||
Reference in New Issue
Block a user