feat(N3): Backend respektiert X-Workspace-ID bei Listen — contacts/dms/mail/calendar (#367)
Check Cross-Plugin Imports / check (push) Has been cancelled

- Core-Resolver resolve_workspace_scope(): Zuweisungs-Check, leere Werte fallen weg; Exemptions System-Admin + workspaces:configure_modules (Editor-Deadlock)
- require_workspace_scope(module_key) FastAPI-Dependency (deps.py)
- expand_folder_scope(): Ordner-Subtree (zyklensicher) für ContactFolder + DMS Folder; scope_uuid_set() fail-closed
- contacts: folder_ids-Subtree + contact_types auf GET /contacts, List-Cache bei aktivem Scope deaktiviert (Cache-Leak-Gefahr)
- dms: folder_ids-Subtree + file_types (semantische Matcher) auf /files, Baum-Reduktion auf /folders
- mail: account_ids auf /mails, /threads, /accounts
- calendar: calendar_ids auf /calendar/entries, /calendars
- Frontend-Defaults: getModuleConfig() im workspaceStore, ContactsList default_saved_view_id, Calendar default_view
- Tests: 21/21 neu (TDD rot→grün), Regression 81 passed, Checker 0, tsc clean, Vitest grün, Build OK
This commit is contained in:
Agent Zero
2026-09-01 10:27:23 +02:00
parent b40adfdd3a
commit 26506a5027
14 changed files with 1156 additions and 13 deletions
+28 -3
View File
@@ -23,7 +23,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.core.db import get_db
from app.deps import get_current_user, require_admin, require_permission
from app.deps import get_current_user, require_admin, require_permission, require_workspace_scope
from app.plugins.builtins.calendar.ics_utils import (
export_entries_to_ics,
ics_events_to_entry_data,
@@ -168,8 +168,13 @@ async def _check_write_permission(
async def list_calendars(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
workspace_scope: dict | None = Depends(require_workspace_scope("calendar")),
):
"""AC1: GET /api/v1/calendars → 200 + calendar list."""
"""AC1: GET /api/v1/calendars → 200 + calendar list.
Phase N3: applies the active workspace scope (X-Workspace-ID) as a pure
AND-restriction (calendar subsets) — never a grant.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
result = await db.execute(
select(Calendar).where(
@@ -178,6 +183,13 @@ async def list_calendars(
)
)
cals = result.scalars().all()
# Phase N3: workspace scope — calendar picker restriction
if workspace_scope:
from app.services.workspace_scope_service import scope_uuid_set
calendar_scope = scope_uuid_set(workspace_scope.get("calendar_ids"))
if calendar_scope is not None:
cals = [c for c in cals if c.id in calendar_scope]
return [_calendar_to_dict(c) for c in cals]
@@ -359,8 +371,13 @@ async def list_entries(
end: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
workspace_scope: dict | None = Depends(require_workspace_scope("calendar")),
):
"""AC7: GET /api/v1/calendar/entries?start=...&end=... → 200 + entries in range."""
"""AC7: GET /api/v1/calendar/entries?start=...&end=... → 200 + entries in range.
Phase N3: applies the active workspace scope (X-Workspace-ID) as a pure
AND-restriction (calendar subsets) — never a grant.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
role = current_user.get("role", "viewer")
@@ -370,6 +387,14 @@ async def list_entries(
CalendarEntry.deleted_at.is_(None),
)
# Phase N3: workspace scope — calendar subsets, pure AND
if workspace_scope:
from app.services.workspace_scope_service import scope_uuid_set
calendar_scope = scope_uuid_set(workspace_scope.get("calendar_ids"))
if calendar_scope is not None:
query = query.where(CalendarEntry.calendar_id.in_(calendar_scope))
# Filter private entries: only owner + admin can see
if role != "admin":
query = query.where(
+5 -1
View File
@@ -24,7 +24,7 @@ from app.commands.contact_commands import (
)
from app.core.db import get_db
from app.core.visibility import check_single_entity_access
from app.deps import get_current_user, get_redis_dep, require_permission
from app.deps import get_current_user, get_redis_dep, require_permission, require_workspace_scope
from app.models.contact import Contact
from app.models.custom_field_definition import CustomFieldDefinition
from app.plugins.registry import get_registry
@@ -70,10 +70,13 @@ async def list_contacts(
cursor: str | None = Query(None, description="Keyset pagination cursor (contact UUID)"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:read")),
workspace_scope: dict | None = Depends(require_workspace_scope("contacts")),
):
"""List contacts with pagination, FTS search, type/folder filter, sorting.
Supports keyset pagination via ``cursor`` parameter for large datasets.
Phase N3: applies the active workspace scope (X-Workspace-ID) as a pure
AND-restriction (folder subtree + contact types) — never a grant.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
@@ -87,6 +90,7 @@ async def list_contacts(
user_id=user_id,
is_system_admin=is_admin,
cursor=cursor,
workspace_scope=workspace_scope,
)
+17 -2
View File
@@ -16,7 +16,7 @@ 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_permission
from app.deps import get_current_user, require_permission, require_workspace_scope
from app.plugins.builtins.dms.common import (
_parse_uuid,
)
@@ -35,8 +35,13 @@ async def list_folders(
parent_id: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
workspace_scope: dict | None = Depends(require_workspace_scope("dms")),
):
"""AC1: GET /api/v1/dms/folders → 200 + folder tree (recursive)."""
"""AC1: GET /api/v1/dms/folders → 200 + folder tree (recursive).
Phase N3: an active workspace scope (X-Workspace-ID) reduces the tree to
the folder subtree — pure AND-restriction, never a grant.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
# Fetch all non-deleted folders for tenant with visibility filter
@@ -52,6 +57,16 @@ async def list_folders(
result = await db.execute(query)
all_folders = result.scalars().all()
# Phase N3: reduce to the scope subtree (folder_ids dimension)
if workspace_scope:
from app.services.workspace_scope_service import expand_folder_scope
scope_folder_ids = workspace_scope.get("folder_ids")
if isinstance(scope_folder_ids, list) and scope_folder_ids:
subtree = await expand_folder_scope(db, Folder, scope_folder_ids)
allowed = subtree or set()
all_folders = [f for f in all_folders if f.id in allowed]
# Build lookup map
folder_map: dict[uuid.UUID, dict] = {}
for f in all_folders:
+30 -2
View File
@@ -21,7 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.core.storage import LocalStorage, 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.deps import get_current_user, require_permission, require_workspace_scope
# BUG-018 God-Object-Split: Helper/Konstanten leben jetzt in common.py;
# Re-Exports sichern Import- und Patch-Kompatibilitaet
@@ -245,8 +245,13 @@ async def get_file(
async def list_all_files(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
workspace_scope: dict | None = Depends(require_workspace_scope("dms")),
):
"""List all non-deleted files for the current tenant."""
"""List all non-deleted files for the current tenant.
Phase N3: applies the active workspace scope (X-Workspace-ID) as a pure
AND-restriction — folder subtree + file types. Never a grant.
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
is_system_admin = current_user.get("role") == "admin"
@@ -258,9 +263,32 @@ async def list_all_files(
query = await apply_visibility_filter(
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
)
# Phase N3: workspace scope filters (folder subtree + file types)
if workspace_scope:
from app.services.workspace_scope_service import (
DMS_FILE_TYPE_MATCHERS,
expand_folder_scope,
)
scope_folder_ids = workspace_scope.get("folder_ids")
if isinstance(scope_folder_ids, list) and scope_folder_ids:
subtree = await expand_folder_scope(db, Folder, scope_folder_ids)
query = query.where(DmsFile.folder_id.in_(subtree or set()))
result = await db.execute(query)
files = result.scalars().all()
# file_types needs Python-side matching (semantic matchers, not SQL-LIKE)
if workspace_scope:
from app.services.workspace_scope_service import DMS_FILE_TYPE_MATCHERS
scope_file_types = workspace_scope.get("file_types")
if isinstance(scope_file_types, list) and scope_file_types:
matchers = [DMS_FILE_TYPE_MATCHERS[t] for t in scope_file_types if t in DMS_FILE_TYPE_MATCHERS]
if matchers:
files = [f for f in files if any(m(f.mime_type) for m in matchers)]
return [
{
"id": str(f.id),
+26 -2
View File
@@ -21,7 +21,7 @@ import app.plugins.builtins.mail.services as mail_services
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 require_permission
from app.deps import require_permission, require_workspace_scope
from app.plugins.builtins.mail.models import (
ContactPgpKey,
Mail,
@@ -214,7 +214,8 @@ async def _check_delegate_access(
@router.get("/accounts")
async def list_accounts(
db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:read"))
db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("mail:read")),
workspace_scope: dict | None = Depends(require_workspace_scope("mail")),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
@@ -223,6 +224,13 @@ async def list_accounts(
query = await apply_visibility_filter(
db, query, "mail_account", MailAccount, user_id, tenant_id, is_system_admin
)
# Phase N3: workspace scope (X-Workspace-ID) — account picker restriction.
if workspace_scope:
from app.services.workspace_scope_service import scope_uuid_set
account_scope = scope_uuid_set(workspace_scope.get("account_ids"))
if account_scope is not None:
query = query.where(MailAccount.id.in_(account_scope))
accounts = (await db.execute(query)).scalars().all()
return [account_to_response(a) for a in accounts]
@@ -878,12 +886,20 @@ async def list_threads(
account_id: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("mail:read")),
workspace_scope: dict | None = Depends(require_workspace_scope("mail")),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
stmt = select(Mail).where(Mail.tenant_id == tenant_id)
if account_id:
a_id = _parse_uuid(account_id, "account_id")
stmt = stmt.where(Mail.account_id == a_id)
# Phase N3: workspace scope (X-Workspace-ID) — account subsets, pure AND.
if workspace_scope:
from app.services.workspace_scope_service import scope_uuid_set
account_scope = scope_uuid_set(workspace_scope.get("account_ids"))
if account_scope is not None:
stmt = stmt.where(Mail.account_id.in_(account_scope))
mails = (await db.execute(stmt.order_by(desc(Mail.received_at)))).scalars().all()
threads: dict[str, dict] = {}
for mail in mails:
@@ -1887,6 +1903,7 @@ async def list_mails(
sort_order: str = Query("desc", pattern="^(asc|desc)$"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("mail:read")),
workspace_scope: dict | None = Depends(require_workspace_scope("mail")),
):
tenant_id = uuid.UUID(current_user["tenant_id"])
stmt = select(Mail).where(Mail.tenant_id == tenant_id)
@@ -1896,6 +1913,13 @@ async def list_mails(
if account_id:
a_id = _parse_uuid(account_id, "account_id")
stmt = stmt.where(Mail.account_id == a_id)
# Phase N3: workspace scope (X-Workspace-ID) — account subsets, pure AND.
if workspace_scope:
from app.services.workspace_scope_service import scope_uuid_set
account_scope = scope_uuid_set(workspace_scope.get("account_ids"))
if account_scope is not None:
stmt = stmt.where(Mail.account_id.in_(account_scope))
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar()
# Dynamic sorting
sort_columns = {