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
+27 -1
View File
@@ -146,6 +146,7 @@ async def list_contacts(
user_id: uuid.UUID | None = None,
is_system_admin: bool = False,
cursor: str | None = None,
workspace_scope: dict | None = None,
) -> dict:
"""List contacts with pagination, FTS search, type/folder filter, sorting.
@@ -155,11 +156,17 @@ async def list_contacts(
filtered to ``id > cursor`` instead of using OFFSET. This is much faster
for large datasets. When ``cursor`` is not provided, classic page/page_size
offset pagination is used (backward compatible).
Phase N3: ``workspace_scope`` (from X-Workspace-ID) applies folder-subtree
and contact-type restrictions as a pure AND on top of all other filters —
never a grant. An active scope also disables the list cache (the cache key
is workspace-dependent).
"""
from app.core.visibility import apply_visibility_filter
# I.4 Performance: Cache simple list queries (no search, no cursor, first 3 pages)
use_cache = not search and not cursor and page <= 3 and not folder_id
# N3: an active workspace scope is user-dependent — never serve the shared cache
use_cache = not search and not cursor and page <= 3 and not folder_id and not workspace_scope
cache_key = f"contacts:list:{tenant_id}:{page}:{page_size}:{contact_type or 'all'}:{sort_by}:{sort_order}:{user_id or 'admin'}:{is_system_admin}"
if use_cache:
from app.core.cache import cache_get
@@ -185,6 +192,25 @@ async def list_contacts(
if folder_id:
base = base.where(Contact.folder_id == uuid.UUID(folder_id))
# Phase N3: workspace scope (X-Workspace-ID) — pure AND-restriction.
# Empty dimension values were already dropped by resolve_workspace_scope.
if workspace_scope:
from app.models.contact_folder import ContactFolder
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, ContactFolder, scope_folder_ids)
if subtree:
base = base.where(Contact.folder_id.in_(subtree))
else:
# Restrict to a non-existent set: everything is excluded
base = base.where(Contact.folder_id.in_(set()))
scope_types = workspace_scope.get("contact_types")
if isinstance(scope_types, list) and scope_types:
base = base.where(Contact.type.in_(scope_types))
if search:
base = base.where(
Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search))
+164
View File
@@ -17,15 +17,179 @@ Without an active workspace there is no filter (backward compatible).
from __future__ import annotations
import logging
import uuid
from typing import Any
from pydantic import ValidationError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.workspace import Workspace, WorkspaceModule, WorkspaceUser
from app.schemas.workspace import WorkspaceModuleScopes
logger = logging.getLogger(__name__)
async def resolve_workspace_scope(
db: AsyncSession,
current_user: dict[str, Any],
x_workspace_id: str | None,
module_key: str,
) -> dict[str, Any] | None:
"""Resolve the active workspace scope config for a module (Phase N3).
Returns the module's scope values (e.g. ``{"folder_ids": [...]}``) or
``None`` when no restriction applies:
- no X-Workspace-ID header / invalid UUID → no filter (backward compatible)
- system admins → exempt
- holders of ``workspaces:configure_modules`` → exempt (editor deadlock:
the N2 scope editor loads its value options through the same endpoints)
- user not assigned to the workspace → no filter (fail-open to RLS/ABAC;
the /context endpoint reports ``not_assigned`` separately)
- module not configured or config has only empty values → no filter
Security invariant: the returned config is applied as a pure
AND-restriction by the callers — a workspace can never grant visibility.
"""
if not x_workspace_id:
return None
try:
ws_id = uuid.UUID(x_workspace_id)
except (ValueError, AttributeError, TypeError):
return None
if current_user.get("is_system_admin"):
return None
from app.core.permissions import check_permission
if check_permission(current_user, "workspaces:configure_modules"):
return None
try:
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
except (KeyError, ValueError, TypeError):
return None
# Workspace exists, is active and belongs to the tenant
ws = (
await db.execute(
select(Workspace).where(
Workspace.id == ws_id,
Workspace.tenant_id == tenant_id,
Workspace.is_active == True, # noqa: E712
)
)
).scalar_one_or_none()
if ws is None:
return None
# User must be assigned to the workspace
wu = (
await db.execute(
select(WorkspaceUser).where(
WorkspaceUser.workspace_id == ws_id,
WorkspaceUser.user_id == user_id,
WorkspaceUser.tenant_id == tenant_id,
)
)
).scalar_one_or_none()
if wu is None:
return None
# Module config for the requested module
wm = (
await db.execute(
select(WorkspaceModule).where(
WorkspaceModule.workspace_id == ws_id,
WorkspaceModule.tenant_id == tenant_id,
WorkspaceModule.module_key == module_key,
)
)
).scalar_one_or_none()
if wm is None:
return None
config = wm.config or {}
# Empty values (empty list / None / "") mean no restriction per dimension
active = {k: v for k, v in config.items() if v}
return active or None
async def expand_folder_scope(
db: AsyncSession,
model: type,
root_ids: list[str],
) -> set[uuid.UUID] | None:
"""Expand folder scope IDs to the full subtree (self + descendants).
Works for any folder model with ``id``/``parent_id`` (ContactFolder,
DMS Folder). Returns ``None`` when no valid IDs remain — callers treat
that as no restriction. Cycles are tolerated (visited set).
"""
if not root_ids:
return None
try:
frontier = {uuid.UUID(v) for v in root_ids}
except (ValueError, TypeError, AttributeError):
return None
if not frontier:
return None
rows = (
await db.execute(select(model.id, model.parent_id))
).all()
children_of: dict[uuid.UUID | None, set[uuid.UUID]] = {}
for fid, parent in rows:
children_of.setdefault(parent, set()).add(fid)
result = set(frontier)
queue = list(frontier)
while queue:
current = queue.pop()
for child in children_of.get(current, set()):
if child not in result:
result.add(child)
queue.append(child)
return result
def scope_uuid_set(raw: Any) -> set[uuid.UUID] | None:
"""Convert a scope dimension value into a set of UUIDs (Phase N3).
Returns ``None`` when the value is absent or not a list (no restriction).
Returns an EMPTY set when the list contains no valid UUIDs — callers use
it with ``.in_(empty)`` so a configured-but-garbage scope restricts to
nothing (fail-closed AND-restriction). Real editor configs always carry
valid UUIDs; this only guards against hand-corrupted JSONB.
"""
if not isinstance(raw, list) or not raw:
return None
result: set[uuid.UUID] = set()
for value in raw:
try:
result.add(uuid.UUID(value))
except (ValueError, TypeError, AttributeError):
continue
return result
DMS_FILE_TYPE_MATCHERS: dict[str, Any] = {
"application/pdf": lambda mime: mime == "application/pdf",
"image/": lambda mime: mime.startswith("image/"),
"spreadsheet": lambda mime: "spreadsheet" in mime or "excel" in mime,
"word": lambda mime: "word" in mime,
"other": lambda mime: (
mime != "application/pdf"
and not mime.startswith("image/")
and "spreadsheet" not in mime
and "excel" not in mime
and "word" not in mime
),
}
def _parse_contribution(plugin_name: str, contribution: Any) -> WorkspaceModuleScopes | None:
"""Validate a single raw contribution; ``None`` when invalid (fail-closed).