03dd477899
Check Cross-Plugin Imports / check (push) Has been cancelled
- Scope-Deklarationen: tasks only_mine, kommunikation conversation_ids, wiki category_ids (NEUE contracts.py), report_generator template_ids, automation agent_ids (module_key agents), tags tag_ids, unified_search entity_types dynamisch aus Provider-Registry - Core-Beiträge: navigation default_route (Startseite) + dashboard widget_app_ids (Widget-TYP-Angebot, Layout bleibt Phase M) - Backend-Filter (additive UND): /tasks (only_mine), /comm/conversations, /wiki/articles+/categories (Subtree), /reports/print-templates, /agents, /tags, /search GET+POST (entity_types-Schnitt), /miniapps?host=dashboard - apply_entity_type_scope-Helper (requested ∧ scope) - Frontend: WorkspaceSwitcher default_route-Navigation, Sidebar workspace-menu_order-Sortierung, workspaceStore moduleMenuOrder() - Tests: 18/18 Deklarationen + 11/11 Filter (TDD), Frontend 2/2 + Store 18/18, tsc clean, Build OK - Regression 64 passed (4 Kombi-Failures = Suite-Isolation, solo-bewiesen); Checker 0; Ruff = Vorbestand (Stash-bewiesen)
333 lines
12 KiB
Python
333 lines
12 KiB
Python
"""Workspace-Scope-Registry aggregation (Phase N1, Roadmap Phase N).
|
|
|
|
Plugins declare the scope dimensions their modules support via the contract
|
|
hook ``workspace_scopes()`` — same aggregation pattern as
|
|
``document_placeholders()`` (#359 philosophy): the module owns its domain,
|
|
the generic consumer stays module-agnostic.
|
|
|
|
The N2 workspace editor renders filter UI from these definitions; scope
|
|
VALUES are stored per workspace in ``workspace_modules.config`` (JSONB).
|
|
N3 list filtering applies them as pure AND-restrictions.
|
|
|
|
Security-Invariante (Phase N): a workspace can never GRANT visibility —
|
|
effective visibility is always Workspace-Scope ∧ RLS ∧ ABAC ∧ Permissions.
|
|
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 apply_entity_type_scope(
|
|
requested: list[str] | None,
|
|
scope_entity_types: Any,
|
|
) -> list[str] | None:
|
|
"""Intersect requested search entity types with the workspace scope (N4).
|
|
|
|
Pure AND: the effective set is requested ∧ scope. ``None`` means "no
|
|
restriction" on either side (search all). An empty result list means the
|
|
search legitimately yields nothing (scope excludes every requested type).
|
|
"""
|
|
if not isinstance(scope_entity_types, list) or not scope_entity_types:
|
|
return requested
|
|
if requested is None:
|
|
return list(scope_entity_types)
|
|
return [et for et in requested if et in set(scope_entity_types)]
|
|
|
|
|
|
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).
|
|
|
|
Every declaration must pass the Pydantic schema — invalid dimensions
|
|
(e.g. a multiselect without options AND value_source) are dropped with a
|
|
warning instead of breaking the editor endpoint.
|
|
"""
|
|
if not isinstance(contribution, dict):
|
|
return None
|
|
try:
|
|
return WorkspaceModuleScopes.model_validate(contribution)
|
|
except ValidationError as exc:
|
|
logger.warning(
|
|
"Invalid workspace_scopes contribution from plugin '%s': %s",
|
|
plugin_name,
|
|
exc,
|
|
)
|
|
return None
|
|
|
|
|
|
def get_scope_definitions() -> dict[str, list[dict[str, Any]]]:
|
|
"""Aggregate ``workspace_scopes()`` contributions from all discovered plugins.
|
|
|
|
Returns ``module_key -> [dimension, ...]`` for the N2 editor. Contracts
|
|
of explicitly deactivated plugins stay gone (ARCH-014 unregister marker);
|
|
never-loaded contracts lazy-load on first access (document_placeholders
|
|
precedent). A plugin crash while declaring scopes never fails the
|
|
endpoint — its contribution is simply skipped.
|
|
"""
|
|
from app.plugins.builtins.contracts import get_contract_registry
|
|
from app.plugins.registry import get_registry
|
|
|
|
modules: dict[str, list[dict[str, Any]]] = {}
|
|
for plugin_name in get_registry().list_discovered():
|
|
contract = get_contract_registry().get_contract(plugin_name)
|
|
if contract is None:
|
|
continue
|
|
fn = getattr(contract, "workspace_scopes", None)
|
|
if fn is None:
|
|
continue
|
|
try:
|
|
contributions = fn() or []
|
|
except Exception: # noqa: BLE001
|
|
logger.exception(
|
|
"workspace_scopes() raised for plugin '%s' — skipping", plugin_name
|
|
)
|
|
continue
|
|
for contribution in contributions:
|
|
parsed = _parse_contribution(plugin_name, contribution)
|
|
if parsed is None:
|
|
continue
|
|
modules.setdefault(parsed.module_key, []).extend(
|
|
dimension.model_dump() for dimension in parsed.dimensions
|
|
)
|
|
|
|
# ─── Core contributions (Phase N4) ──────────────────────────
|
|
# Core-owned modules (no plugin owns them) contribute through the same
|
|
# registry so the N2 editor renders them automatically.
|
|
for core_contribution in _core_scope_contributions():
|
|
parsed = _parse_contribution("core", core_contribution)
|
|
if parsed is None:
|
|
continue
|
|
modules.setdefault(parsed.module_key, []).extend(
|
|
dimension.model_dump() for dimension in parsed.dimensions
|
|
)
|
|
return modules
|
|
|
|
|
|
def _core_scope_contributions() -> list[dict[str, Any]]:
|
|
"""Scope contributions for core-owned modules (Phase N4).
|
|
|
|
- navigation: default_route per workspace ("Startseite") — where the
|
|
workspace switcher navigates to.
|
|
- dashboard: widget_app_ids — the workspace limits the OFFERED widget
|
|
types (admin context, workspace_widgets boundary). The personal
|
|
layout stays user-owned (Phase M boundary, user-corrected split).
|
|
"""
|
|
from app.core.permission_registry import CORE_PERMISSIONS
|
|
|
|
route_options = [
|
|
{"value": "/", "label": "Dashboard"},
|
|
{"value": "/contacts", "label": "Kontakte"},
|
|
]
|
|
# Every core permission module with a matching frontend route contributes
|
|
# a navigation option (dynamic, registry-derived — no hardcoded list).
|
|
known_routes = {"/", "/contacts", "/tasks", "/calendar", "/mail", "/dms", "/wiki", "/communication", "/reports", "/tags", "/search", "/agents", "/workflows"}
|
|
for perm in CORE_PERMISSIONS:
|
|
module = perm.get("module", "")
|
|
route = f"/{module}"
|
|
if route in known_routes and all(o["value"] != route for o in route_options):
|
|
route_options.append({"value": route, "label": module.title()})
|
|
|
|
return [
|
|
{
|
|
"module_key": "navigation",
|
|
"dimensions": [
|
|
{
|
|
"key": "default_route",
|
|
"label": "Startseite",
|
|
"control": "select",
|
|
"options": route_options,
|
|
"default": "/",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"module_key": "dashboard",
|
|
"dimensions": [
|
|
{
|
|
"key": "widget_app_ids",
|
|
"label": "Verfügbare Widgets",
|
|
"control": "multiselect",
|
|
"options": [],
|
|
"value_source": {
|
|
"endpoint": "/api/v1/miniapps?host=dashboard",
|
|
"items_path": "items",
|
|
"value_key": "app_id",
|
|
"label_key": "name",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
]
|