From 26506a5027b0785bf0879570a31c42fd2967cd1b Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Tue, 1 Sep 2026 10:27:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(N3):=20Backend=20respektiert=20X-Workspace?= =?UTF-8?q?-ID=20bei=20Listen=20=E2=80=94=20contacts/dms/mail/calendar=20(?= =?UTF-8?q?#367)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- PROGRESS.md | 21 +- app/deps.py | 26 +- app/plugins/builtins/calendar/routes.py | 31 +- app/plugins/builtins/contacts/routes.py | 6 +- app/plugins/builtins/dms/folders_routes.py | 19 +- app/plugins/builtins/dms/routes.py | 32 +- app/plugins/builtins/mail/routes.py | 28 +- app/services/contact_service.py | 28 +- app/services/workspace_scope_service.py | 164 ++++ frontend/src/pages/Calendar.tsx | 13 + frontend/src/pages/ContactsList.tsx | 12 + .../store/__tests__/workspaceStore.test.ts | 33 + frontend/src/store/workspaceStore.ts | 9 + tests/test_n3_workspace_filtering.py | 747 ++++++++++++++++++ 14 files changed, 1156 insertions(+), 13 deletions(-) create mode 100644 tests/test_n3_workspace_filtering.py diff --git a/PROGRESS.md b/PROGRESS.md index 130686d..866e7c5 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -8,7 +8,7 @@ **Offene Roadmap-Phasen (user-abgestimmt, startklar):** - **Phase M** — MiniApp-Plattform & Dashboard-Builder (M1-M6). **M1 ✓** (Universal-Registry, `/api/v1/miniapps`), **M2 ✓** (persönliche Dashboards: Tabelle, CRUD, Seed, RLS), **M3 ✓** (Dashboard-Builder: Edit-Modus, Drag&Drop, Palette, Tabs), **M4 ✓** (System-Rückbau, Core = reiner Host), **M5 ✓** (Plugin-MiniApps), **M6 ✓ erledigt — PHASE M KOMPLETT** (Windows-Host + AI-Agenten-Tool send_miniapp — siehe Phase-M6-Section). -- **Phase N** — Workspace-Scopes (N1-N4). 0 Umbau — Fundament (config JSONB, X-Workspace-ID, /context, Sidebar-Consumer) existiert bereits. **N1 ✓** (Scope-Registry via Contract: workspace_scopes() für contacts/dms/mail/calendar, Pydantic fail-closed, `/api/v1/workspaces/scope-definitions` — siehe Phase-N1-Section). **N2 ✓** (Dynamischer Scope-Editor: WorkspaceScopeEditor ersetzt JSON-Textarea, Filter-UI aus Registry, Speicherung workspace_modules.config — siehe Phase-N2-Section). **Nächster Schritt:** N3 Erste vier Module integrieren (Backend respektiert X-Workspace-ID bei Listen, additive UND-Einschränkung). +- **Phase N** — Workspace-Scopes (N1-N4). 0 Umbau — Fundament (config JSONB, X-Workspace-ID, /context, Sidebar-Consumer) existiert bereits. **N1 ✓** (Scope-Registry via Contract), **N2 ✓** (Dynamischer Scope-Editor), **N3 ✓** (Backend-Filterung: contacts Ordner-Subtree/Typen + Cache-Bypass, dms Ordner/Datei-Typen, mail Postfächer, calendar Kalender; Frontend-Defaults Saved-View/Kalender-Ansicht — siehe Phase-N3-Section). **Nächster Schritt:** N4 Restliche Module (Tasks, Kommunikation, Wiki, Reports/Dokumente, Automation, Tags, Suche, Navigation; Dashboard-Schnittstelle workspace_widgets). - **Phase O** — UI-Overhaul (umbenannt von Doppel-L, Bug-Verifikation steht im Roadmap-Eintrag: 5/7 Bugs bereits erledigt, offen: 1.2 Kontakte-Drag-Drop in Ordner, 1.3 MoveDialog) **Vorbestands-Findings (nicht blockierend, dokumentiert):** @@ -104,6 +104,25 @@ **Offen in Phase M:** — (Phase M abgeschlossen). +## Phase N3 — Erste vier Module integrieren (2026-09-01) ✅ + +**Spec:** [#367](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/367) | **Roadmap:** Phase N, N3 | **Milestone:** Phase N — Workspace-Scopes (#14) + +**Umgesetzt:** +- **Core-Resolver** `resolve_workspace_scope()` (workspace_scope_service.py): X-Workspace-ID → Workspace aktiv/Tenant → User-Zuweisung → Modul-config; leere Dimensionswerte fallen weg. Exemptions: System-Admins + `workspaces:configure_modules`-Inhaber — löst den Editor-Deadlock (N2-Scope-Editor lädt Wertoptionen über dieselben Endpoints). +- **FastAPI-Dependency** `require_workspace_scope(module_key)` (deps.py) — Header-Parsing gekapselt, einzeilige Nutzung pro Route. +- **Ordner-Subtree** `expand_folder_scope()`: self + descendants (zyklensicher) für ContactFolder + DMS Folder — Ordner-Scopes gelten inkl. Unterordnern. `scope_uuid_set()`: fail-closed (garbage UUIDs → leere Menge). +- **Listen-Filter (additive UND-Einschränkung, kein Umbau):** contacts (folder_ids-Subtree + contact_types auf GET /contacts; List-Cache bei aktivem Scope deaktiviert — Cross-Workspace-Leak-Gefahr beseitigt), dms (folder_ids-Subtree + file_types auf GET /files, Baum-Reduktion auf GET /folders; semantische Typ-Matcher pdf/image/spreadsheet/word/other), mail (account_ids auf GET /mails, /threads, /accounts-Picker), calendar (calendar_ids auf GET /calendar/entries + /calendars-Picker). +- **Frontend-Defaults:** `getModuleConfig(moduleKey)` im workspaceStore; ContactsList wendet `default_saved_view_id` beim Mount an (admin-definierte Standard-Ansicht), Calendar setzt `default_view` (day/week/month/range) bei Workspace-Wechsel. + +**Verifiziert (2026-09-01):** +- TDD: Rot (7 ImportError + 14 Fixture-Errors) → ✅ Grün **21/21** (Resolver 7, Contacts 5 mit Cache-Bypass-Beweis + scharfem AND-Beweis (Beta=Person im Ordner-Scope fällt raus), DMS 3, Mail 3, Calendar 3 inkl. Admin-Bypass) +- ✅ Regression: N1 + N2 + Workspaces + test_mail **81 passed** +- ✅ Cross-Plugin-Checker: 0 Verstöße; Ruff: nur per Stash bewiesener Vorbestand (N806/UP017) +- ✅ Frontend: tsc clean, Vitest (workspaceStore 16/16, CalendarPage, ContactsList) grün, Production-Build OK + +**Offen in Phase N:** N4 restliche Module (Tasks „nur meine", Kommunikation-Räume, Wiki-Kategorien, Reports-Vorlagen, Automation-Agenten, Tags, Search-Provider, Navigation-Defaults) + Dashboard-Schnittstelle (workspace_widgets begrenzt Widget-TYP-Angebot). + ## Phase N2 — Dynamischer Scope-Editor (2026-09-01) ✅ **Spec:** [#366](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/366) | **Roadmap:** Phase N, N2 | **Milestone:** Phase N — Workspace-Scopes (#14) diff --git a/app/deps.py b/app/deps.py index 4b87aaf..f0eeeba 100644 --- a/app/deps.py +++ b/app/deps.py @@ -7,7 +7,7 @@ import uuid from typing import Any import redis.asyncio as aioredis -from fastapi import Depends, HTTPException, Request, status +from fastapi import Depends, Header, HTTPException, Request, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -382,6 +382,30 @@ async def get_current_user_id( return uuid.UUID(current_user["user_id"]) +def require_workspace_scope(module_key: str): + """FastAPI dependency factory (Phase N3): resolve the active workspace + scope config for a module from the X-Workspace-ID header. + + Returns the scope dict (e.g. ``{"folder_ids": [...]}``) or ``None`` + when no restriction applies (no header, admin, unassigned, empty config). + Callers apply it as a pure AND-restriction — never a grant. + + Usage: + scope: dict | None = Depends(require_workspace_scope("contacts")) + """ + + async def _resolve( + db: AsyncSession = Depends(get_db), + current_user: dict[str, Any] = Depends(get_current_user), + x_workspace_id: str | None = Header(None, alias="X-Workspace-ID"), + ) -> dict[str, Any] | None: + from app.services.workspace_scope_service import resolve_workspace_scope + + return await resolve_workspace_scope(db, current_user, x_workspace_id, module_key) + + return _resolve + + def require_active_plugin(plugin_name: str): """FastAPI dependency factory: require that a plugin is active. diff --git a/app/plugins/builtins/calendar/routes.py b/app/plugins/builtins/calendar/routes.py index c7e699a..f932fae 100644 --- a/app/plugins/builtins/calendar/routes.py +++ b/app/plugins/builtins/calendar/routes.py @@ -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( diff --git a/app/plugins/builtins/contacts/routes.py b/app/plugins/builtins/contacts/routes.py index e12f5f6..0af7edd 100644 --- a/app/plugins/builtins/contacts/routes.py +++ b/app/plugins/builtins/contacts/routes.py @@ -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, ) diff --git a/app/plugins/builtins/dms/folders_routes.py b/app/plugins/builtins/dms/folders_routes.py index 1ad4543..fdcba1b 100644 --- a/app/plugins/builtins/dms/folders_routes.py +++ b/app/plugins/builtins/dms/folders_routes.py @@ -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: diff --git a/app/plugins/builtins/dms/routes.py b/app/plugins/builtins/dms/routes.py index b275f25..c4d6275 100644 --- a/app/plugins/builtins/dms/routes.py +++ b/app/plugins/builtins/dms/routes.py @@ -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), diff --git a/app/plugins/builtins/mail/routes.py b/app/plugins/builtins/mail/routes.py index a07066d..89c9c6b 100644 --- a/app/plugins/builtins/mail/routes.py +++ b/app/plugins/builtins/mail/routes.py @@ -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 = { diff --git a/app/services/contact_service.py b/app/services/contact_service.py index e28c883..34cd68d 100644 --- a/app/services/contact_service.py +++ b/app/services/contact_service.py @@ -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)) diff --git a/app/services/workspace_scope_service.py b/app/services/workspace_scope_service.py index 662908d..42b4f1d 100644 --- a/app/services/workspace_scope_service.py +++ b/app/services/workspace_scope_service.py @@ -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). diff --git a/frontend/src/pages/Calendar.tsx b/frontend/src/pages/Calendar.tsx index 072d9f9..754c464 100644 --- a/frontend/src/pages/Calendar.tsx +++ b/frontend/src/pages/Calendar.tsx @@ -25,6 +25,7 @@ import { useWindowStore } from '@/store/windowStore'; import { IcsControls } from '@/components/calendar/IcsControls'; import { SharingSettings } from '@/components/calendar/SharingSettings'; import { useCalendarStore, type CalendarViewMode } from '@/store/calendarStore'; +import { useWorkspaceStore } from '@/store/workspaceStore'; import { usePluginToolbarStore } from '@/store/pluginToolbarStore'; import { ChevronLeft, ChevronRight, ExternalLink, Info, Plus } from 'lucide-react'; import { PrintButton } from '@/components/common/PrintButton'; @@ -112,6 +113,18 @@ export function CalendarPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // N3: workspace default calendar view (admin-defined per workspace). + // Applied once when the workspace context provides a default_view; + // user view switches stay sticky (only re-applied on workspace change). + const workspaceDefaultView = useWorkspaceStore(s => s.getModuleConfig('calendar')?.default_view) as CalendarViewMode | undefined; + const activeWorkspaceId = useWorkspaceStore(s => s.activeWorkspaceId); + useEffect(() => { + if (workspaceDefaultView && ['day', 'week', 'month', 'range'].includes(workspaceDefaultView)) { + setViewMode(workspaceDefaultView); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspaceDefaultView, activeWorkspaceId]); + // Compute date range based on viewMode const range = useMemo(() => { if (viewMode === 'month') { diff --git a/frontend/src/pages/ContactsList.tsx b/frontend/src/pages/ContactsList.tsx index 1174494..7ac670c 100644 --- a/frontend/src/pages/ContactsList.tsx +++ b/frontend/src/pages/ContactsList.tsx @@ -5,6 +5,7 @@ */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { useWorkspaceStore } from '@/store/workspaceStore'; import { useTranslation } from 'react-i18next'; import { ResizablePanel } from '@/components/ui/ResizablePanel'; import { Input } from '@/components/ui/Input'; @@ -331,6 +332,17 @@ export function ContactsListPage() { if (activeViewId === id) setActiveViewId(null); }, [activeViewId, deleteViewMut]); + // N3: workspace default saved view — applied once when views are loaded + // and the user has not chosen a view yet (admin-defined per workspace). + const workspaceDefaultViewId = useWorkspaceStore(s => s.getModuleConfig('contacts')?.default_saved_view_id); + const activeWorkspaceId = useWorkspaceStore(s => s.activeWorkspaceId); + useEffect(() => { + if (activeViewId || !workspaceDefaultViewId || savedViews.length === 0) return; + const view = savedViews.find(v => v.id === workspaceDefaultViewId); + if (view) applySavedView(view); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspaceDefaultViewId, activeWorkspaceId, savedViews]); + // Save current filter configuration — via API const handleSaveFilter = useCallback((name: string, filterState: FilterState) => { createFilterMut.mutate({ name, entity_type: 'contacts', filter_criteria: filterState as any }); diff --git a/frontend/src/store/__tests__/workspaceStore.test.ts b/frontend/src/store/__tests__/workspaceStore.test.ts index ec7d0d6..80b9b16 100644 --- a/frontend/src/store/__tests__/workspaceStore.test.ts +++ b/frontend/src/store/__tests__/workspaceStore.test.ts @@ -136,4 +136,37 @@ describe('workspaceStore', () => { expect(useWorkspaceStore.getState().context).toBeNull(); expect(useWorkspaceStore.getState().myWorkspaces).toEqual([]); }); + + // ─── N3: getModuleConfig (scope defaults for pages) ───────── + + it('getModuleConfig returns the module config from the active context', () => { + useWorkspaceStore.getState().setContext({ + workspace_id: 'ws-1', + modules: [ + { module_key: 'contacts', menu_order: 0, config: { default_saved_view_id: 'view-42' } }, + { module_key: 'calendar', menu_order: 1, config: { default_view: 'week' } }, + ], + widgets: [], + }); + expect(useWorkspaceStore.getState().getModuleConfig('contacts')).toEqual({ + default_saved_view_id: 'view-42', + }); + expect(useWorkspaceStore.getState().getModuleConfig('calendar')).toEqual({ + default_view: 'week', + }); + }); + + it('getModuleConfig returns empty object without workspace context (backward compatible)', () => { + expect(useWorkspaceStore.getState().getModuleConfig('contacts')).toEqual({}); + }); + + it('getModuleConfig returns empty object for unconfigured modules', () => { + useWorkspaceStore.getState().setContext({ + workspace_id: 'ws-1', + modules: [{ module_key: 'contacts', menu_order: 0, config: {} }], + widgets: [], + }); + expect(useWorkspaceStore.getState().getModuleConfig('mail')).toEqual({}); + expect(useWorkspaceStore.getState().getModuleConfig('contacts')).toEqual({}); + }); }); diff --git a/frontend/src/store/workspaceStore.ts b/frontend/src/store/workspaceStore.ts index 696be2a..fb3679c 100644 --- a/frontend/src/store/workspaceStore.ts +++ b/frontend/src/store/workspaceStore.ts @@ -58,6 +58,7 @@ interface WorkspaceStoreState { // Helpers isModuleVisible: (moduleKey: string, isSystemAdmin?: boolean) => boolean; visibleModuleKeys: () => Set; + getModuleConfig: (moduleKey: string) => Record; hasWorkspaces: () => boolean; // Reset reset: () => void; @@ -111,6 +112,14 @@ export const useWorkspaceStore = create()( ); }, + getModuleConfig: (moduleKey: string) => { + // N3: scope defaults (e.g. default_saved_view_id, default_view) come + // from the active workspace context; no context = no defaults. + const ctx = get().context; + const mod = (ctx?.modules ?? []).find(m => m.module_key === moduleKey); + return mod?.config ?? {}; + }, + hasWorkspaces: () => get().myWorkspaces.length > 0, reset: () => { diff --git a/tests/test_n3_workspace_filtering.py b/tests/test_n3_workspace_filtering.py new file mode 100644 index 0000000..df0f6af --- /dev/null +++ b/tests/test_n3_workspace_filtering.py @@ -0,0 +1,747 @@ +"""N3 — Workspace-Scopes in Backend-Listen (Phase N). + +Backend respects X-Workspace-ID on list endpoints: contacts (folder subtree ++ types), dms (folder subtree + file types), mail (account subsets + picker), +calendar (calendar subsets + picker). Pure AND-restriction — never grants. + +Exemption rule (editor deadlock): system admins and holders of +``workspaces:configure_modules`` bypass scope filtering — the N2 scope +editor loads its value options through the same endpoints and needs the +unrestricted lists. Admins keep the full view everywhere. +""" + +from __future__ import annotations + +import uuid +from collections.abc import Callable +from datetime import UTC, datetime + +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession + +from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users + +# ─── Helpers ────────────────────────────────────────────────── + + +async def _make_member( + db: AsyncSession, + seed: dict, + email: str, + extra_perms: dict[str, dict[str, bool]] | None = None, +): + """Create a non-admin user with module read permissions.""" + from app.core.auth import hash_password + from app.models.role import Role + from app.models.user import User, UserTenant + + perms: dict[str, dict[str, bool]] = { + "contacts": {"read": True}, + "dms": {"read": True}, + "mail": {"read": True}, + "calendar": {"read": True}, + "workspaces": {"read": True}, + } + if extra_perms: + for module, actions in extra_perms.items(): + perms.setdefault(module, {}).update(actions) + + user = User( + email=email, + name=email.split("@")[0].title(), + password_hash=hash_password("TestPass123!"), + is_active=True, + preferences={}, + ) + db.add(user) + await db.flush() + role = Role( + tenant_id=seed["tenant_a"].id, + name=f"ws-member-{uuid.uuid4().hex[:8]}", + permissions=perms, + denied_permissions=[], + field_permissions={}, + ) + db.add(role) + await db.flush() + ut = UserTenant( + user_id=user.id, + tenant_id=seed["tenant_a"].id, + is_default=True, + role="viewer", + role_id=role.id, + ) + db.add(ut) + await db.flush() + return user + + +async def _seed_contacts_data(db: AsyncSession, seed: dict, member_id) -> dict: + """Folders F1 > F2 (subtree), F3 (other); contacts across folders. + + Beta is a PERSON inside the folder scope — proves that folder and type + scopes combine as AND (Beta passes folder, fails type). + """ + from app.models.contact import Contact + from app.models.contact_folder import ContactFolder + + tenant = seed["tenant_a"].id + f1 = ContactFolder(tenant_id=tenant, name="Vertrieb", user_id=seed["admin_a"].id) + db.add(f1) + await db.flush() + f2 = ContactFolder(tenant_id=tenant, name="Angebote", user_id=seed["admin_a"].id, parent_id=f1.id) + db.add(f2) + await db.flush() + f3 = ContactFolder(tenant_id=tenant, name="Support", user_id=seed["admin_a"].id) + db.add(f3) + await db.flush() + + def _contact(folder, name, ctype, displayname): + return Contact( + tenant_id=tenant, + folder_id=folder.id if folder else None, + type=ctype, + name=name, + displayname=displayname, + owner_id=None, + ) + + c1 = _contact(f1, "Acme GmbH", "company", "Acme GmbH") + c2 = _contact(f2, "Beta Person", "person", "Beta") + c3 = _contact(f3, "Gamma Person", "person", "Gamma") + c4 = _contact(None, "Delta GmbH", "company", "Delta GmbH") + db.add_all([c1, c2, c3, c4]) + await db.flush() + return {"f1": f1, "f2": f2, "f3": f3} + + +async def _seed_dms_data(db: AsyncSession, seed: dict, member_id) -> dict: + from app.plugins.builtins.dms.models import File as DmsFile + from app.plugins.builtins.dms.models import Folder + + tenant = seed["tenant_a"].id + d1 = Folder(tenant_id=tenant, name="Angebote", created_by=seed["admin_a"].id) + db.add(d1) + await db.flush() + d2 = Folder(tenant_id=tenant, name="2026", created_by=seed["admin_a"].id, parent_id=d1.id) + db.add(d2) + await db.flush() + d3 = Folder(tenant_id=tenant, name="Verträge", created_by=seed["admin_a"].id) + db.add(d3) + await db.flush() + + def _file(folder, name, mime): + return DmsFile( + tenant_id=tenant, + folder_id=folder.id if folder else None, + name=name, + mime_type=mime, + size_bytes=10, + storage_path=f"/tmp/{name}", + uploaded_by=seed["admin_a"].id, + owner_id=None, + ) + + files = [ + _file(d1, "angebot.pdf", "application/pdf"), + _file(d2, "logo.png", "image/png"), + _file(d3, "vertrag.pdf", "application/pdf"), + _file(d1, "brief.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + _file(None, "lose.pdf", "application/pdf"), + ] + db.add_all(files) + await db.flush() + return {"d1": d1, "d2": d2, "d3": d3} + + +async def _seed_mail_data(db: AsyncSession, seed: dict, member_id) -> dict: + from app.plugins.builtins.mail.models import Mail, MailAccount, MailFolder + + tenant = seed["tenant_a"].id + a1 = MailAccount( + tenant_id=tenant, + user_id=member_id, + email_address="vertrieb@example.com", + display_name="Vertrieb", + imap_host="imap.example.com", + smtp_host="smtp.example.com", + username="vertrieb", + encrypted_password="x", + owner_id=member_id, + ) + a2 = MailAccount( + tenant_id=tenant, + user_id=member_id, + email_address="info@example.com", + display_name="Info", + imap_host="imap.example.com", + smtp_host="smtp.example.com", + username="info", + encrypted_password="x", + owner_id=member_id, + ) + db.add_all([a1, a2]) + await db.flush() + folder = MailFolder(tenant_id=tenant, account_id=a1.id, name="INBOX", imap_name="INBOX") + db.add(folder) + await db.flush() + m1 = Mail( + tenant_id=tenant, + account_id=a1.id, + folder_id=folder.id, + message_id="", + subject="Angebot", + from_address="kunde@example.com", + ) + m2 = Mail( + tenant_id=tenant, + account_id=a2.id, + folder_id=folder.id, + message_id="", + subject="Rechnung", + from_address="kunde2@example.com", + ) + db.add_all([m1, m2]) + await db.flush() + return {"a1": a1, "a2": a2} + + +async def _seed_calendar_data(db: AsyncSession, seed: dict, member_id) -> dict: + from app.plugins.builtins.calendar.models import Calendar, CalendarEntry + + tenant = seed["tenant_a"].id + cal1 = Calendar(tenant_id=tenant, name="Vertrieb", owner_id=member_id) + cal2 = Calendar(tenant_id=tenant, name="Intern", owner_id=member_id) + db.add_all([cal1, cal2]) + await db.flush() + now = datetime(2026, 9, 1, 9, 0, tzinfo=UTC) + e1 = CalendarEntry( + tenant_id=tenant, + calendar_id=cal1.id, + entry_type="appointment", + title="Kundentermin", + start_at=now, + created_by=member_id, + ) + e2 = CalendarEntry( + tenant_id=tenant, + calendar_id=cal2.id, + entry_type="appointment", + title="Team", + start_at=now, + created_by=member_id, + ) + db.add_all([e1, e2]) + await db.flush() + return {"cal1": cal1, "cal2": cal2} + + +async def _make_workspace( + db: AsyncSession, + seed: dict, + member, + module_configs: dict[str, dict], +) -> uuid.UUID: + """Workspace + member assignment + module configs (via service).""" + from app.services import workspace_service + + tenant = seed["tenant_a"].id + ws = await workspace_service.create_workspace( + db, tenant, seed["admin_a"].id, f"ScopeWS-{uuid.uuid4().hex[:6]}" + ) + ws_id = uuid.UUID(ws["id"]) + await workspace_service.assign_user(db, tenant, ws_id, member.id, role="member") + modules = [ + {"module_key": key, "is_visible": True, "menu_order": i, "config": cfg} + for i, (key, cfg) in enumerate(module_configs.items()) + ] + if modules: + await workspace_service.set_workspace_modules(db, tenant, ws_id, modules) + await db.commit() + return ws_id + + +async def _build_seed( + db: AsyncSession, + member_email: str, + config_builder: Callable[[dict], dict[str, dict]], + seeders: list, +) -> tuple[dict, uuid.UUID, str]: + """Seed tenant + member + data, THEN build the workspace config with real IDs.""" + seed = await seed_tenant_and_users(db) + member = await _make_member(db, seed, member_email) + data: dict = {} + for seeder in seeders: + data.update(await seeder(db, seed, member.id)) + ws_id = await _make_workspace(db, seed, member, config_builder(data)) + return data, ws_id, member_email + + +# ─── Fixtures (4 plugins active: contacts, dms, mail, calendar) ─ + + +@pytest_asyncio.fixture +async def scope_app(engine: AsyncEngine, redis_client): + """FastAPI app with the four N3 plugins registered and activated.""" + from sqlalchemy.ext.asyncio import async_sessionmaker + + from app.core.db import close_engine, reset_engine_for_testing + from app.core.permission_registry import ( + init_permission_registry, + register_plugin_permissions, + ) + from app.core.service_container import get_container + from app.main import create_app + from app.plugins.builtins.calendar.plugin import CalendarPlugin + from app.plugins.builtins.contacts.plugin import ContactsPlugin + from app.plugins.builtins.dms.plugin import DmsPlugin + from app.plugins.builtins.mail.plugin import MailPlugin + from app.plugins.builtins.permissions.plugin import PermissionsPlugin + from app.plugins.registry import reset_registry_for_testing + from app.services.plugin_service import reset_plugin_service_for_testing + + reset_engine_for_testing(engine) + app = create_app() + registry = reset_registry_for_testing() + registry.initialize(engine, app) + init_permission_registry(active_plugin_names={"contacts", "dms", "mail", "calendar", "permissions"}) + container = get_container() + await container.initialize() + for plugin in ( + PermissionsPlugin(), + ContactsPlugin(), + DmsPlugin(), + MailPlugin(), + CalendarPlugin(), + ): + registry.register_plugin(plugin) + if plugin.manifest.permissions: + register_plugin_permissions(plugin.name, plugin.manifest.permissions) + reset_plugin_service_for_testing(registry) + sf = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession) + async with sf() as session: + # permissions first (dms depends on it) + for name in ("permissions", "contacts", "dms", "mail", "calendar"): + await registry.install(session, name) + await registry.activate(session, name) + await session.commit() + yield app + await close_engine() + + +@pytest_asyncio.fixture +async def scope_client(scope_app) -> AsyncClient: + transport = ASGITransport(app=scope_app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +@pytest_asyncio.fixture +async def scope_seed(scope_app, db_session: AsyncSession) -> tuple: + """Contacts: folder scope F1 (subtree includes F2).""" + return await _build_seed( + db_session, + "scope-member@example.com", + lambda data: {"contacts": {"folder_ids": [str(data["f1"].id)]}}, + [_seed_contacts_data], + ) + + +@pytest_asyncio.fixture +async def scope_seed_types(scope_app, db_session: AsyncSession) -> tuple: + """Contacts: type scope companies only.""" + return await _build_seed( + db_session, + "scope-types@example.com", + lambda data: {"contacts": {"contact_types": ["company"]}}, + [_seed_contacts_data], + ) + + +@pytest_asyncio.fixture +async def scope_seed_combined(scope_app, db_session: AsyncSession) -> tuple: + """Contacts: folder + type scopes combined (AND proof).""" + return await _build_seed( + db_session, + "scope-combined@example.com", + lambda data: { + "contacts": { + "folder_ids": [str(data["f1"].id)], + "contact_types": ["company"], + } + }, + [_seed_contacts_data], + ) + + +@pytest_asyncio.fixture +async def scope_seed_dms(scope_app, db_session: AsyncSession) -> tuple: + return await _build_seed( + db_session, + "scope-dms@example.com", + lambda data: {"dms": {"folder_ids": [str(data["d1"].id)]}}, + [_seed_dms_data], + ) + + +@pytest_asyncio.fixture +async def scope_seed_dms_types(scope_app, db_session: AsyncSession) -> tuple: + return await _build_seed( + db_session, + "scope-dms-types@example.com", + lambda data: {"dms": {"file_types": ["image/"]}}, + [_seed_dms_data], + ) + + +@pytest_asyncio.fixture +async def scope_seed_mail(scope_app, db_session: AsyncSession) -> tuple: + return await _build_seed( + db_session, + "scope-mail@example.com", + lambda data: {"mail": {"account_ids": [str(data["a1"].id)]}}, + [_seed_mail_data], + ) + + +@pytest_asyncio.fixture +async def scope_seed_cal(scope_app, db_session: AsyncSession) -> tuple: + return await _build_seed( + db_session, + "scope-cal@example.com", + lambda data: {"calendar": {"calendar_ids": [str(data["cal1"].id)]}}, + [_seed_calendar_data], + ) + + +# ─── Resolver unit tests ───────────────────────────────────── + + +class TestResolveWorkspaceScope: + async def test_no_header_returns_none(self, db_session: AsyncSession): + from app.services.workspace_scope_service import resolve_workspace_scope + + seed = await seed_tenant_and_users(db_session) + user = {"tenant_id": str(seed["tenant_a"].id), "user_id": str(seed["admin_a"].id)} + assert await resolve_workspace_scope(db_session, user, None, "contacts") is None + + async def test_invalid_header_returns_none(self, db_session: AsyncSession): + """Invalid UUID = no workspace context = no filter (fail-open to RLS/ABAC).""" + from app.services.workspace_scope_service import resolve_workspace_scope + + seed = await seed_tenant_and_users(db_session) + user = { + "tenant_id": str(seed["tenant_a"].id), + "user_id": str(seed["admin_a"].id), + "is_system_admin": False, + "permissions": ["contacts:read"], + } + assert await resolve_workspace_scope(db_session, user, "not-a-uuid", "contacts") is None + + async def test_system_admin_exempt(self, db_session: AsyncSession): + from app.services.workspace_scope_service import resolve_workspace_scope + + seed = await seed_tenant_and_users(db_session) + member = await _make_member(db_session, seed, "exempt-admin@example.com") + ws_id = await _make_workspace( + db_session, seed, member, {"contacts": {"contact_types": ["company"]}} + ) + user = { + "tenant_id": str(seed["tenant_a"].id), + "user_id": str(member.id), + "is_system_admin": True, + "permissions": ["contacts:read"], + } + assert await resolve_workspace_scope(db_session, user, str(ws_id), "contacts") is None + + async def test_configure_modules_holder_exempt(self, db_session: AsyncSession): + """Editor deadlock: scope editors need the unrestricted value lists.""" + from app.services.workspace_scope_service import resolve_workspace_scope + + seed = await seed_tenant_and_users(db_session) + editor = await _make_member( + db_session, + seed, + "editor@example.com", + extra_perms={"workspaces": {"configure_modules": True}}, + ) + ws_id = await _make_workspace( + db_session, seed, editor, {"contacts": {"contact_types": ["company"]}} + ) + user = { + "tenant_id": str(seed["tenant_a"].id), + "user_id": str(editor.id), + "is_system_admin": False, + "permissions": ["contacts:read", "workspaces:configure_modules"], + } + assert await resolve_workspace_scope(db_session, user, str(ws_id), "contacts") is None + + async def test_unassigned_user_returns_none(self, db_session: AsyncSession): + from app.services.workspace_scope_service import resolve_workspace_scope + + seed = await seed_tenant_and_users(db_session) + member = await _make_member(db_session, seed, "member@example.com") + other = await _make_member(db_session, seed, "other@example.com") + ws_id = await _make_workspace( + db_session, seed, member, {"contacts": {"contact_types": ["company"]}} + ) + user = { + "tenant_id": str(seed["tenant_a"].id), + "user_id": str(other.id), + "is_system_admin": False, + "permissions": ["contacts:read"], + } + assert await resolve_workspace_scope(db_session, user, str(ws_id), "contacts") is None + + async def test_assigned_user_gets_config(self, db_session: AsyncSession): + from app.services.workspace_scope_service import resolve_workspace_scope + + seed = await seed_tenant_and_users(db_session) + member = await _make_member(db_session, seed, "member@example.com") + ws_id = await _make_workspace( + db_session, seed, member, {"contacts": {"contact_types": ["company"]}} + ) + user = { + "tenant_id": str(seed["tenant_a"].id), + "user_id": str(member.id), + "is_system_admin": False, + "permissions": ["contacts:read"], + } + scope = await resolve_workspace_scope(db_session, user, str(ws_id), "contacts") + assert scope == {"contact_types": ["company"]} + + async def test_module_without_config_returns_none(self, db_session: AsyncSession): + from app.services.workspace_scope_service import resolve_workspace_scope + + seed = await seed_tenant_and_users(db_session) + member = await _make_member(db_session, seed, "member@example.com") + ws_id = await _make_workspace( + db_session, seed, member, {"contacts": {"contact_types": ["company"]}} + ) + user = { + "tenant_id": str(seed["tenant_a"].id), + "user_id": str(member.id), + "is_system_admin": False, + "permissions": ["dms:read"], + } + assert await resolve_workspace_scope(db_session, user, str(ws_id), "dms") is None + + +# ─── HTTP filtering: contacts ────────────────────────────────── + + +class TestContactsScopeFiltering: + async def test_folder_scope_includes_subtree( + self, scope_client: AsyncClient, scope_seed: tuple + ): + client = scope_client + data, ws_id, member_email = scope_seed + await login_client(client, member_email) + headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)} + + resp = await client.get("/api/v1/contacts", headers=headers) + assert resp.status_code == 200, resp.text + names = {i["displayname"] for i in resp.json()["items"]} + # F1 + subtree F2: Acme (company, F1) + Beta (person, F2) + assert names == {"Acme GmbH", "Beta"}, names + + async def test_type_scope_companies_only( + self, scope_client: AsyncClient, scope_seed_types: tuple + ): + client = scope_client + data, ws_id, member_email = scope_seed_types + await login_client(client, member_email) + headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)} + + resp = await client.get("/api/v1/contacts", headers=headers) + assert resp.status_code == 200, resp.text + names = {i["displayname"] for i in resp.json()["items"]} + # companies only: Acme (F1) + Delta (no folder) + Company Alpha + # (created by the conftest seed_tenant_and_users helper) + assert names == {"Acme GmbH", "Delta GmbH", "Company Alpha"}, names + + async def test_combined_scope_is_and( + self, scope_client: AsyncClient, scope_seed_combined: tuple + ): + """Folder AND type: Beta is inside the folder scope but is a person + and must be excluded — the sharp AND proof.""" + client = scope_client + data, ws_id, member_email = scope_seed_combined + await login_client(client, member_email) + headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)} + + resp = await client.get("/api/v1/contacts", headers=headers) + assert resp.status_code == 200, resp.text + names = {i["displayname"] for i in resp.json()["items"]} + assert names == {"Acme GmbH"}, names + + async def test_no_header_no_filter( + self, scope_client: AsyncClient, scope_seed: tuple + ): + client = scope_client + data, ws_id, member_email = scope_seed + await login_client(client, member_email) + + resp = await client.get("/api/v1/contacts", headers=ORIGIN_HEADER) + assert resp.status_code == 200, resp.text + # 4 own contacts + Company Alpha from the conftest seed + assert resp.json()["total"] == 5 + + async def test_scope_bypasses_list_cache( + self, scope_client: AsyncClient, scope_seed: tuple + ): + """First unscoped GET caches; the scoped GET must NOT serve that cache.""" + client = scope_client + data, ws_id, member_email = scope_seed + await login_client(client, member_email) + + unscoped = await client.get("/api/v1/contacts", headers=ORIGIN_HEADER) + assert unscoped.status_code == 200 + assert unscoped.json()["total"] == 5 # cacheable simple query + + scoped = await client.get( + "/api/v1/contacts", + headers={**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)}, + ) + assert scoped.json()["total"] == 2, "Scoped list must not serve the unscoped cache" + + +# ─── HTTP filtering: dms ────────────────────────────────────── + + +class TestDmsScopeFiltering: + async def test_folder_scope_subtree_files( + self, scope_client: AsyncClient, scope_seed_dms: tuple + ): + client = scope_client + data, ws_id, member_email = scope_seed_dms + await login_client(client, member_email) + headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)} + + resp = await client.get("/api/v1/dms/files", headers=headers) + assert resp.status_code == 200, resp.text + names = {f["name"] for f in resp.json()} + # d1 + subtree d2: angebot.pdf + brief.docx (d1) + logo.png (d2) + assert names == {"angebot.pdf", "brief.docx", "logo.png"}, names + + async def test_file_type_scope_images_only( + self, scope_client: AsyncClient, scope_seed_dms_types: tuple + ): + client = scope_client + data, ws_id, member_email = scope_seed_dms_types + await login_client(client, member_email) + headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)} + + resp = await client.get("/api/v1/dms/files", headers=headers) + assert resp.status_code == 200, resp.text + names = {f["name"] for f in resp.json()} + assert names == {"logo.png"}, names + + async def test_folder_tree_scoped( + self, scope_client: AsyncClient, scope_seed_dms: tuple + ): + client = scope_client + data, ws_id, member_email = scope_seed_dms + await login_client(client, member_email) + headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)} + + resp = await client.get("/api/v1/dms/folders", headers=headers) + assert resp.status_code == 200, resp.text + + def walk(nodes): + for n in nodes: + yield n["name"] + yield from walk(n.get("children", [])) + + names = set(walk(resp.json())) + assert names == {"Angebote", "2026"}, names + + +# ─── HTTP filtering: mail ───────────────────────────────────── + + +class TestMailScopeFiltering: + async def test_mails_scoped_to_account( + self, scope_client: AsyncClient, scope_seed_mail: tuple + ): + client = scope_client + data, ws_id, member_email = scope_seed_mail + await login_client(client, member_email) + headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)} + + resp = await client.get("/api/v1/mail", headers=headers) + assert resp.status_code == 200, resp.text + subjects = {m["subject"] for m in resp.json()["mails"]} + assert subjects == {"Angebot"}, subjects + + async def test_accounts_picker_scoped( + self, scope_client: AsyncClient, scope_seed_mail: tuple + ): + client = scope_client + data, ws_id, member_email = scope_seed_mail + await login_client(client, member_email) + headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)} + + resp = await client.get("/api/v1/mail/accounts", headers=headers) + assert resp.status_code == 200, resp.text + addresses = {a["email"] for a in resp.json()} + assert addresses == {"vertrieb@example.com"}, addresses + + async def test_threads_scoped( + self, scope_client: AsyncClient, scope_seed_mail: tuple + ): + client = scope_client + data, ws_id, member_email = scope_seed_mail + await login_client(client, member_email) + headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)} + + resp = await client.get("/api/v1/mail/threads", headers=headers) + assert resp.status_code == 200, resp.text + subjects = {th["subject"] for th in resp.json()} + assert subjects == {"Angebot"}, subjects + + +# ─── HTTP filtering: calendar ───────────────────────────────── + + +class TestCalendarScopeFiltering: + async def test_entries_scoped_to_calendar( + self, scope_client: AsyncClient, scope_seed_cal: tuple + ): + client = scope_client + data, ws_id, member_email = scope_seed_cal + await login_client(client, member_email) + headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)} + + resp = await client.get("/api/v1/calendar/entries", headers=headers) + assert resp.status_code == 200, resp.text + titles = {e["title"] for e in resp.json()} + assert titles == {"Kundentermin"}, titles + + async def test_calendars_picker_scoped( + self, scope_client: AsyncClient, scope_seed_cal: tuple + ): + client = scope_client + data, ws_id, member_email = scope_seed_cal + await login_client(client, member_email) + headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)} + + resp = await client.get("/api/v1/calendars", headers=headers) + assert resp.status_code == 200, resp.text + names = {c["name"] for c in resp.json()} + assert names == {"Vertrieb"}, names + + async def test_admin_bypass_still_sees_all( + self, scope_client: AsyncClient, scope_seed_cal: tuple + ): + """Tenant admin (workspaces:configure_modules via *:*) keeps the full view.""" + client = scope_client + data, ws_id, _ = scope_seed_cal + await login_client(client, "admin@tenanta.com") + headers = {**ORIGIN_HEADER, "X-Workspace-ID": str(ws_id)} + + resp = await client.get("/api/v1/calendars", headers=headers) + assert resp.status_code == 200, resp.text + names = {c["name"] for c in resp.json()} + assert names == {"Vertrieb", "Intern"}, names