diff --git a/app/plugins/builtins/calendar/models.py b/app/plugins/builtins/calendar/models.py index c45cff2..cec29ff 100644 --- a/app/plugins/builtins/calendar/models.py +++ b/app/plugins/builtins/calendar/models.py @@ -18,6 +18,7 @@ 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 Calendar(Base, TenantMixin): @@ -37,7 +38,7 @@ class Calendar(Base, TenantMixin): deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) -class CalendarEntry(Base, TenantMixin): +class CalendarEntry(Base, TenantMixin, OwnedMixin): """Calendar entry — appointment or task, tenant-scoped, soft-deletable.""" __tablename__ = "calendar_entries" @@ -131,7 +132,7 @@ class UserCalendarVisibility(Base): visible: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) -class Subtask(Base, TenantMixin): +class Subtask(Base, TenantMixin, OwnedMixin): """Subtask belonging to a calendar entry.""" __tablename__ = "subtasks" diff --git a/app/plugins/builtins/calendar/routes.py b/app/plugins/builtins/calendar/routes.py index 5e978ca..c40be2f 100644 --- a/app/plugins/builtins/calendar/routes.py +++ b/app/plugins/builtins/calendar/routes.py @@ -22,6 +22,7 @@ from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db +from app.core.visibility import apply_visibility_filter, check_single_entity_access from app.deps import get_current_user, require_admin, require_permission from app.plugins.builtins.calendar.ics_utils import ( export_entries_to_ics, diff --git a/app/plugins/builtins/dms/models.py b/app/plugins/builtins/dms/models.py index 9b1b4b1..0480317 100644 --- a/app/plugins/builtins/dms/models.py +++ b/app/plugins/builtins/dms/models.py @@ -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" diff --git a/app/plugins/builtins/dms/routes.py b/app/plugins/builtins/dms/routes.py index ea2327e..207bcca 100644 --- a/app/plugins/builtins/dms/routes.py +++ b/app/plugins/builtins/dms/routes.py @@ -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) diff --git a/app/plugins/builtins/tasks/models.py b/app/plugins/builtins/tasks/models.py index 2e32a3f..67af958 100644 --- a/app/plugins/builtins/tasks/models.py +++ b/app/plugins/builtins/tasks/models.py @@ -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 Task(Base, TenantMixin): +class Task(Base, TenantMixin, OwnedMixin): """Task entity — free activities (calls, notes, visits) linked to contacts.""" __tablename__ = "tasks" @@ -47,3 +48,4 @@ class Task(Base, TenantMixin): created_by: Mapped[uuid.UUID | None] = mapped_column( PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True ) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/frontend/src/components/common/ProtectedRoute.tsx b/frontend/src/components/common/ProtectedRoute.tsx new file mode 100644 index 0000000..0016ed9 --- /dev/null +++ b/frontend/src/components/common/ProtectedRoute.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { usePermission } from '@/hooks/usePermission'; + +interface ProtectedRouteProps { + permission: string; + children: React.ReactNode; +} + +export function ProtectedRoute({ permission, children }: ProtectedRouteProps) { + const { hasPermission } = usePermission(); + + if (!hasPermission(permission)) { + return ; + } + + return <>{children}; +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 0482393..30d6ea1 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -7,6 +7,7 @@ import { ChevronRight, FileText, Home, Settings, Users } from 'lucide-react'; import { usePluginStore } from '@/store/pluginStore'; import * as LucideIcons from 'lucide-react'; import { useMenuOrder } from '@/api/users'; +import { usePermission } from '@/hooks/usePermission'; interface NavSingleItem { to: string; @@ -40,6 +41,7 @@ export function Sidebar() { const location = useLocation(); const manifests = usePluginStore(s => s.manifests); const { data: menuOrderData } = useMenuOrder(); + const { hasPermission } = usePermission(); const allMenuItems = useMemo(() => { const staticItems = singleItems.map(item => ({ @@ -50,10 +52,12 @@ export function Sidebar() { order: item.order, group: undefined as string | undefined, isStatic: true as const, + permission: item.to === '/dashboard' ? 'dashboard:read' : item.to === '/contacts' ? 'contacts:read' : undefined, })); const pluginItems = manifests .flatMap((m) => m.menu_items) + .filter(item => !item.permission || hasPermission(item.permission)) .map(item => ({ path: item.path, labelKey: item.label_key, @@ -62,6 +66,7 @@ export function Sidebar() { order: item.order, group: item.group, isStatic: false as const, + permission: item.permission, })); const allItems = [...staticItems, ...pluginItems]; @@ -152,6 +157,8 @@ export function Sidebar() { } const elements: React.ReactNode[] = []; for (const item of singles) { + // Skip if user lacks permission + if (item.permission && !hasPermission(item.permission)) continue; elements.push(
  • !item.permission || hasPermission(item.permission)); + if (visibleItems.length === 0) continue; // Hide empty groups const groupKey = `plugin-group-${group}`; const expanded = expandedItems.has(groupKey); elements.push( @@ -196,13 +206,13 @@ export function Sidebar() { } }} > - {getIcon(items[0].icon as string)} + {getIcon(visibleItems[0].icon as string)} {group} {chevronIcon(expanded)} {expanded && (