Files
leocrm/app/services/workspace_scope_service.py
T

333 lines
12 KiB
Python
Raw Normal View History

"""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",
},
},
],
},
]