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