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:
+25
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user