feat(N3): Backend respektiert X-Workspace-ID bei Listen — contacts/dms/mail/calendar (#367)
Check Cross-Plugin Imports / check (push) Has been cancelled
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:
+20
-1
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
**Offene Roadmap-Phasen (user-abgestimmt, startklar):**
|
**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 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)
|
- **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):**
|
**Vorbestands-Findings (nicht blockierend, dokumentiert):**
|
||||||
@@ -104,6 +104,25 @@
|
|||||||
|
|
||||||
**Offen in Phase M:** — (Phase M abgeschlossen).
|
**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) ✅
|
## 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)
|
**Spec:** [#366](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/366) | **Roadmap:** Phase N, N2 | **Milestone:** Phase N — Workspace-Scopes (#14)
|
||||||
|
|||||||
+25
-1
@@ -7,7 +7,7 @@ import uuid
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import redis.asyncio as aioredis
|
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 import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -382,6 +382,30 @@ async def get_current_user_id(
|
|||||||
return uuid.UUID(current_user["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):
|
def require_active_plugin(plugin_name: str):
|
||||||
"""FastAPI dependency factory: require that a plugin is active.
|
"""FastAPI dependency factory: require that a plugin is active.
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.core.audit import log_audit
|
from app.core.audit import log_audit
|
||||||
from app.core.db import get_db
|
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 (
|
from app.plugins.builtins.calendar.ics_utils import (
|
||||||
export_entries_to_ics,
|
export_entries_to_ics,
|
||||||
ics_events_to_entry_data,
|
ics_events_to_entry_data,
|
||||||
@@ -168,8 +168,13 @@ async def _check_write_permission(
|
|||||||
async def list_calendars(
|
async def list_calendars(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
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"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Calendar).where(
|
select(Calendar).where(
|
||||||
@@ -178,6 +183,13 @@ async def list_calendars(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
cals = result.scalars().all()
|
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]
|
return [_calendar_to_dict(c) for c in cals]
|
||||||
|
|
||||||
|
|
||||||
@@ -359,8 +371,13 @@ async def list_entries(
|
|||||||
end: str | None = None,
|
end: str | None = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
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"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
role = current_user.get("role", "viewer")
|
role = current_user.get("role", "viewer")
|
||||||
@@ -370,6 +387,14 @@ async def list_entries(
|
|||||||
CalendarEntry.deleted_at.is_(None),
|
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
|
# Filter private entries: only owner + admin can see
|
||||||
if role != "admin":
|
if role != "admin":
|
||||||
query = query.where(
|
query = query.where(
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from app.commands.contact_commands import (
|
|||||||
)
|
)
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.visibility import check_single_entity_access
|
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.contact import Contact
|
||||||
from app.models.custom_field_definition import CustomFieldDefinition
|
from app.models.custom_field_definition import CustomFieldDefinition
|
||||||
from app.plugins.registry import get_registry
|
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)"),
|
cursor: str | None = Query(None, description="Keyset pagination cursor (contact UUID)"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("contacts:read")),
|
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.
|
"""List contacts with pagination, FTS search, type/folder filter, sorting.
|
||||||
|
|
||||||
Supports keyset pagination via ``cursor`` parameter for large datasets.
|
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"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
@@ -87,6 +90,7 @@ async def list_contacts(
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
is_system_admin=is_admin,
|
is_system_admin=is_admin,
|
||||||
cursor=cursor,
|
cursor=cursor,
|
||||||
|
workspace_scope=workspace_scope,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
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 (
|
from app.plugins.builtins.dms.common import (
|
||||||
_parse_uuid,
|
_parse_uuid,
|
||||||
)
|
)
|
||||||
@@ -35,8 +35,13 @@ async def list_folders(
|
|||||||
parent_id: str | None = None,
|
parent_id: str | None = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
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"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
|
||||||
# Fetch all non-deleted folders for tenant with visibility filter
|
# Fetch all non-deleted folders for tenant with visibility filter
|
||||||
@@ -52,6 +57,16 @@ async def list_folders(
|
|||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
all_folders = result.scalars().all()
|
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
|
# Build lookup map
|
||||||
folder_map: dict[uuid.UUID, dict] = {}
|
folder_map: dict[uuid.UUID, dict] = {}
|
||||||
for f in all_folders:
|
for f in all_folders:
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.storage import LocalStorage, get_storage_backend
|
from app.core.storage import LocalStorage, get_storage_backend
|
||||||
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
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;
|
# BUG-018 God-Object-Split: Helper/Konstanten leben jetzt in common.py;
|
||||||
# Re-Exports sichern Import- und Patch-Kompatibilitaet
|
# Re-Exports sichern Import- und Patch-Kompatibilitaet
|
||||||
@@ -245,8 +245,13 @@ async def get_file(
|
|||||||
async def list_all_files(
|
async def list_all_files(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user),
|
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"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
is_system_admin = current_user.get("role") == "admin"
|
is_system_admin = current_user.get("role") == "admin"
|
||||||
@@ -258,9 +263,32 @@ async def list_all_files(
|
|||||||
query = await apply_visibility_filter(
|
query = await apply_visibility_filter(
|
||||||
db, query, "dms_file", DmsFile, user_id, tenant_id, is_system_admin
|
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)
|
result = await db.execute(query)
|
||||||
files = result.scalars().all()
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
"id": str(f.id),
|
"id": str(f.id),
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import app.plugins.builtins.mail.services as mail_services
|
|||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.storage import get_storage_backend
|
from app.core.storage import get_storage_backend
|
||||||
from app.core.visibility import apply_visibility_filter, check_single_entity_access
|
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 (
|
from app.plugins.builtins.mail.models import (
|
||||||
ContactPgpKey,
|
ContactPgpKey,
|
||||||
Mail,
|
Mail,
|
||||||
@@ -214,7 +214,8 @@ async def _check_delegate_access(
|
|||||||
|
|
||||||
@router.get("/accounts")
|
@router.get("/accounts")
|
||||||
async def list_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"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
@@ -223,6 +224,13 @@ async def list_accounts(
|
|||||||
query = await apply_visibility_filter(
|
query = await apply_visibility_filter(
|
||||||
db, query, "mail_account", MailAccount, user_id, tenant_id, is_system_admin
|
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()
|
accounts = (await db.execute(query)).scalars().all()
|
||||||
return [account_to_response(a) for a in accounts]
|
return [account_to_response(a) for a in accounts]
|
||||||
|
|
||||||
@@ -878,12 +886,20 @@ async def list_threads(
|
|||||||
account_id: str | None = None,
|
account_id: str | None = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("mail:read")),
|
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"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
stmt = select(Mail).where(Mail.tenant_id == tenant_id)
|
stmt = select(Mail).where(Mail.tenant_id == tenant_id)
|
||||||
if account_id:
|
if account_id:
|
||||||
a_id = _parse_uuid(account_id, "account_id")
|
a_id = _parse_uuid(account_id, "account_id")
|
||||||
stmt = stmt.where(Mail.account_id == a_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()
|
mails = (await db.execute(stmt.order_by(desc(Mail.received_at)))).scalars().all()
|
||||||
threads: dict[str, dict] = {}
|
threads: dict[str, dict] = {}
|
||||||
for mail in mails:
|
for mail in mails:
|
||||||
@@ -1887,6 +1903,7 @@ async def list_mails(
|
|||||||
sort_order: str = Query("desc", pattern="^(asc|desc)$"),
|
sort_order: str = Query("desc", pattern="^(asc|desc)$"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("mail:read")),
|
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"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
stmt = select(Mail).where(Mail.tenant_id == tenant_id)
|
stmt = select(Mail).where(Mail.tenant_id == tenant_id)
|
||||||
@@ -1896,6 +1913,13 @@ async def list_mails(
|
|||||||
if account_id:
|
if account_id:
|
||||||
a_id = _parse_uuid(account_id, "account_id")
|
a_id = _parse_uuid(account_id, "account_id")
|
||||||
stmt = stmt.where(Mail.account_id == a_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()
|
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar()
|
||||||
# Dynamic sorting
|
# Dynamic sorting
|
||||||
sort_columns = {
|
sort_columns = {
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ async def list_contacts(
|
|||||||
user_id: uuid.UUID | None = None,
|
user_id: uuid.UUID | None = None,
|
||||||
is_system_admin: bool = False,
|
is_system_admin: bool = False,
|
||||||
cursor: str | None = None,
|
cursor: str | None = None,
|
||||||
|
workspace_scope: dict | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""List contacts with pagination, FTS search, type/folder filter, sorting.
|
"""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
|
filtered to ``id > cursor`` instead of using OFFSET. This is much faster
|
||||||
for large datasets. When ``cursor`` is not provided, classic page/page_size
|
for large datasets. When ``cursor`` is not provided, classic page/page_size
|
||||||
offset pagination is used (backward compatible).
|
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
|
from app.core.visibility import apply_visibility_filter
|
||||||
|
|
||||||
# I.4 Performance: Cache simple list queries (no search, no cursor, first 3 pages)
|
# 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}"
|
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:
|
if use_cache:
|
||||||
from app.core.cache import cache_get
|
from app.core.cache import cache_get
|
||||||
@@ -185,6 +192,25 @@ async def list_contacts(
|
|||||||
if folder_id:
|
if folder_id:
|
||||||
base = base.where(Contact.folder_id == uuid.UUID(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:
|
if search:
|
||||||
base = base.where(
|
base = base.where(
|
||||||
Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search))
|
Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search))
|
||||||
|
|||||||
@@ -17,15 +17,179 @@ Without an active workspace there is no filter (backward compatible).
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import ValidationError
|
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
|
from app.schemas.workspace import WorkspaceModuleScopes
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
def _parse_contribution(plugin_name: str, contribution: Any) -> WorkspaceModuleScopes | None:
|
||||||
"""Validate a single raw contribution; ``None`` when invalid (fail-closed).
|
"""Validate a single raw contribution; ``None`` when invalid (fail-closed).
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { useWindowStore } from '@/store/windowStore';
|
|||||||
import { IcsControls } from '@/components/calendar/IcsControls';
|
import { IcsControls } from '@/components/calendar/IcsControls';
|
||||||
import { SharingSettings } from '@/components/calendar/SharingSettings';
|
import { SharingSettings } from '@/components/calendar/SharingSettings';
|
||||||
import { useCalendarStore, type CalendarViewMode } from '@/store/calendarStore';
|
import { useCalendarStore, type CalendarViewMode } from '@/store/calendarStore';
|
||||||
|
import { useWorkspaceStore } from '@/store/workspaceStore';
|
||||||
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
|
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
|
||||||
import { ChevronLeft, ChevronRight, ExternalLink, Info, Plus } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, ExternalLink, Info, Plus } from 'lucide-react';
|
||||||
import { PrintButton } from '@/components/common/PrintButton';
|
import { PrintButton } from '@/components/common/PrintButton';
|
||||||
@@ -112,6 +113,18 @@ export function CalendarPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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
|
// Compute date range based on viewMode
|
||||||
const range = useMemo(() => {
|
const range = useMemo(() => {
|
||||||
if (viewMode === 'month') {
|
if (viewMode === 'month') {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { useWorkspaceStore } from '@/store/workspaceStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ResizablePanel } from '@/components/ui/ResizablePanel';
|
import { ResizablePanel } from '@/components/ui/ResizablePanel';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
@@ -331,6 +332,17 @@ export function ContactsListPage() {
|
|||||||
if (activeViewId === id) setActiveViewId(null);
|
if (activeViewId === id) setActiveViewId(null);
|
||||||
}, [activeViewId, deleteViewMut]);
|
}, [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
|
// Save current filter configuration — via API
|
||||||
const handleSaveFilter = useCallback((name: string, filterState: FilterState) => {
|
const handleSaveFilter = useCallback((name: string, filterState: FilterState) => {
|
||||||
createFilterMut.mutate({ name, entity_type: 'contacts', filter_criteria: filterState as any });
|
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().context).toBeNull();
|
||||||
expect(useWorkspaceStore.getState().myWorkspaces).toEqual([]);
|
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({});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ interface WorkspaceStoreState {
|
|||||||
// Helpers
|
// Helpers
|
||||||
isModuleVisible: (moduleKey: string, isSystemAdmin?: boolean) => boolean;
|
isModuleVisible: (moduleKey: string, isSystemAdmin?: boolean) => boolean;
|
||||||
visibleModuleKeys: () => Set<string>;
|
visibleModuleKeys: () => Set<string>;
|
||||||
|
getModuleConfig: (moduleKey: string) => Record<string, any>;
|
||||||
hasWorkspaces: () => boolean;
|
hasWorkspaces: () => boolean;
|
||||||
// Reset
|
// Reset
|
||||||
reset: () => void;
|
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,
|
hasWorkspaces: () => get().myWorkspaces.length > 0,
|
||||||
|
|
||||||
reset: () => {
|
reset: () => {
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user