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
+20 -1
View File
@@ -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)
+25 -1
View File
@@ -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.
+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 = {
+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).
+13
View File
@@ -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') {
+12
View File
@@ -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 });
@@ -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({});
});
});
+9
View File
@@ -58,6 +58,7 @@ interface WorkspaceStoreState {
// Helpers
isModuleVisible: (moduleKey: string, isSystemAdmin?: boolean) => boolean;
visibleModuleKeys: () => Set<string>;
getModuleConfig: (moduleKey: string) => Record<string, any>;
hasWorkspaces: () => boolean;
// Reset
reset: () => void;
@@ -111,6 +112,14 @@ export const useWorkspaceStore = create<WorkspaceStoreState>()(
);
},
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: () => {
+747
View File
@@ -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="<m1@example.com>",
subject="Angebot",
from_address="kunde@example.com",
)
m2 = Mail(
tenant_id=tenant,
account_id=a2.id,
folder_id=folder.id,
message_id="<m2@example.com>",
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