feat: strengthen module contracts and shared WebUI runtime

This commit is contained in:
2026-07-29 14:16:28 +02:00
parent 53e947935a
commit 68328f3d8e
57 changed files with 4358 additions and 371 deletions

View File

@@ -39,6 +39,22 @@ class DeltaCollectionResponse(BaseModel):
full: bool = False
class ReferenceOptionResponse(BaseModel):
value: str
label: str
description: str | None = None
kind: str | None = None
availability: Literal["available", "inactive", "unavailable"] = "available"
disabled: bool = False
source_module: str | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
class ReferenceOptionListResponse(BaseModel):
options: list[ReferenceOptionResponse] = Field(default_factory=list)
provider_available: bool = True
class LoginRequest(BaseModel):
model_config = ConfigDict(extra="forbid")

View File

@@ -16,7 +16,7 @@ from govoplan_core.core.events import (
PlatformEvent,
current_event_trace,
normalize_trace_id,
publish_platform_event,
emit_platform_event,
)
from govoplan_core.core.runtime import get_registry
from govoplan_core.privacy.retention import sanitize_audit_details_for_policy
@@ -171,9 +171,13 @@ class _NullAuditRecorder:
)
def _publish_audit_platform_event(item: AuditRecordRef) -> None:
def _publish_audit_platform_event(
session: Session,
item: AuditRecordRef,
) -> None:
trace = _compact_trace(item.details.get("_trace") if isinstance(item.details, Mapping) else None)
publish_platform_event(
emit_platform_event(
session,
PlatformEvent(
type=item.action,
module_id=_module_id_for_audit_action(item.action),
@@ -252,7 +256,7 @@ def audit_event(
actor_id=user_id or api_key_id,
payload={"scope": scope, "action": action, "object_type": object_type, "object_id": object_id},
)
_publish_audit_platform_event(item)
_publish_audit_platform_event(session, item)
if commit:
session.commit()
return item

View File

@@ -118,7 +118,7 @@ def _registry_from_request(request: Request) -> PlatformRegistry | None:
def _api_principal_provider_from_request(request: Request) -> ApiPrincipalProvider:
registry = _registry_from_request(request)
if registry is None or not registry.has_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Auth provider is not available")
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Auth provider is not available")
capability = registry.require_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER)
if not isinstance(capability, ApiPrincipalProvider):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid auth provider capability")

View File

@@ -8,6 +8,12 @@ from govoplan_core.core.dataflows import (
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER,
DataflowTriggerDispatcher,
)
from govoplan_core.core.events import (
CAPABILITY_PLATFORM_EVENT_OUTBOX,
PlatformEvent,
PlatformEventOutbox,
publish_platform_event,
)
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
@@ -34,6 +40,7 @@ celery.conf.update(
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
"govoplan.calendar.dispatch_outbox": {"queue": "calendar"},
"govoplan.dataflow.dispatch_triggers": {"queue": "dataflow"},
"govoplan.events.dispatch_outbox": {"queue": "events"},
},
worker_prefetch_multiplier=1,
task_acks_late=True,
@@ -49,6 +56,11 @@ celery.conf.update(
"schedule": 60.0,
"args": (100,),
},
"platform-events-every-ten-seconds": {
"task": "govoplan.events.dispatch_outbox",
"schedule": 10.0,
"args": (100,),
},
},
)
@@ -96,8 +108,10 @@ def _calendar_outbox() -> CalendarOutboxProvider | None:
return capability
def _dataflow_trigger_dispatcher() -> DataflowTriggerDispatcher | None:
registry = _platform_registry()
def _dataflow_trigger_dispatcher(
registry: PlatformRegistry | None = None,
) -> DataflowTriggerDispatcher | None:
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER):
return None
capability = registry.require_capability(
@@ -108,6 +122,18 @@ def _dataflow_trigger_dispatcher() -> DataflowTriggerDispatcher | None:
return capability
def _platform_event_outbox(
registry: PlatformRegistry | None = None,
) -> PlatformEventOutbox | None:
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX):
return None
capability = registry.require_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
if not isinstance(capability, PlatformEventOutbox):
raise RuntimeError("Platform event outbox capability is invalid")
return capability
@celery.task(name="govoplan.campaigns.send_email", bind=True, max_retries=0)
def send_email(self, job_id: str):
"""Send one explicitly queued campaign job.
@@ -197,3 +223,39 @@ def dispatch_dataflow_triggers(self, limit: int = 100):
result = dict(provider.dispatch_due(session, limit=limit))
session.commit()
return result
@celery.task(
name="govoplan.events.dispatch_outbox",
bind=True,
max_retries=0,
)
def dispatch_platform_events(self, limit: int = 100):
"""Deliver committed platform events once across all worker processes."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
registry = _platform_registry()
outbox = _platform_event_outbox(registry)
if outbox is None:
return {"selected": 0, "dispatched": 0, "failed": 0}
dataflow_dispatcher = _dataflow_trigger_dispatcher(registry)
def dispatch(event: PlatformEvent) -> None:
if (
dataflow_dispatcher is not None
and event.classification in {"public", "internal"}
):
dataflow_dispatcher.ingest_event(session, event=event)
publish_platform_event(event)
result = dict(
outbox.dispatch_pending(
session,
dispatcher=dispatch,
limit=limit,
)
)
session.commit()
return result

View File

@@ -68,6 +68,12 @@ AUTH_CAPABILITY_NAMES = frozenset(
}
)
DEFAULT_CAPABILITY_PROVIDERS: Mapping[str, str] = {
CAPABILITY_AUTH_PRINCIPAL_RESOLVER: ACCESS_MODULE_ID,
CAPABILITY_AUTH_PERMISSION_EVALUATOR: ACCESS_MODULE_ID,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER: ACCESS_MODULE_ID,
}
AuthMethod = Literal["session", "api_key", "service_account"]
AccessSubjectKind = Literal[
"identity",

View File

@@ -6,7 +6,7 @@ from typing import Any, Iterable, Sequence
from sqlalchemy import BigInteger, DateTime, Index, Integer, JSON, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, Session, mapped_column
from govoplan_core.core.events import EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, publish_platform_event
from govoplan_core.core.events import EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, emit_platform_event
from govoplan_core.db.base import Base, utcnow
WATERMARK_PREFIX = "seq:"
@@ -96,13 +96,14 @@ def record_change(
payload=payload or {},
)
session.add(entry)
_publish_change_event(entry)
_publish_change_event(session, entry)
return entry
def _publish_change_event(entry: ChangeSequenceEntry) -> None:
def _publish_change_event(session: Session, entry: ChangeSequenceEntry) -> None:
event_type = _change_event_type(entry.module_id, entry.resource_type, entry.operation)
publish_platform_event(
emit_platform_event(
session,
PlatformEvent(
type=event_type,
module_id=entry.module_id,

View File

@@ -111,96 +111,189 @@ def validate_definition_graph(
nodes: Sequence[DefinitionNode],
edges: Sequence[DefinitionEdge],
) -> tuple[DefinitionDiagnostic, ...]:
diagnostics: list[DefinitionDiagnostic] = []
constraints = library.constraints
if not nodes:
return (_error("graph.empty", "Add nodes before saving the definition."),)
if len(nodes) > constraints.max_nodes:
constraints = library.constraints
diagnostics = _graph_size_diagnostics(
node_count=len(nodes),
edge_count=len(edges),
constraints=constraints,
)
node_by_id = {node.id: node for node in nodes}
definitions: dict[str, DefinitionNodeType] = {}
for item in library.node_types:
definitions.setdefault(item.type, item)
diagnostics.extend(_identifier_diagnostics(nodes=nodes, edges=edges))
incoming, undirected, topology_edges, edge_diagnostics = (
_validate_definition_edges(
edges=edges,
node_by_id=node_by_id,
definitions=definitions,
)
)
diagnostics.extend(edge_diagnostics)
diagnostics.extend(
_node_port_diagnostics(
nodes=nodes,
definitions=definitions,
incoming=incoming,
library_id=library.id,
)
)
diagnostics.extend(
_node_count_diagnostics(
nodes=nodes,
definitions=definitions,
constraints=constraints,
)
)
diagnostics.extend(
_topology_diagnostics(
nodes=nodes,
node_by_id=node_by_id,
topology_edges=topology_edges,
undirected=undirected,
constraints=constraints,
)
)
return tuple(diagnostics)
def _graph_size_diagnostics(
*,
node_count: int,
edge_count: int,
constraints: DefinitionGraphConstraints,
) -> list[DefinitionDiagnostic]:
diagnostics: list[DefinitionDiagnostic] = []
if node_count > constraints.max_nodes:
diagnostics.append(
_error(
"graph.node_limit",
f"Definitions are limited to {constraints.max_nodes:,} nodes.",
)
)
if len(edges) > constraints.max_edges:
if edge_count > constraints.max_edges:
diagnostics.append(
_error(
"graph.edge_limit",
f"Definitions are limited to {constraints.max_edges:,} edges.",
)
)
return diagnostics
node_by_id = {node.id: node for node in nodes}
if len(node_by_id) != len(nodes):
def _identifier_diagnostics(
*,
nodes: Sequence[DefinitionNode],
edges: Sequence[DefinitionEdge],
) -> list[DefinitionDiagnostic]:
diagnostics: list[DefinitionDiagnostic] = []
if len({node.id for node in nodes}) != len(nodes):
diagnostics.append(_error("graph.duplicate_node", "Node identifiers must be unique."))
if len({edge.id for edge in edges}) != len(edges):
diagnostics.append(_error("graph.duplicate_edge", "Edge identifiers must be unique."))
return diagnostics
def _validate_definition_edges(
*,
edges: Sequence[DefinitionEdge],
node_by_id: Mapping[str, DefinitionNode],
definitions: Mapping[str, DefinitionNodeType],
) -> tuple[
dict[str, list[DefinitionEdge]],
dict[str, set[str]],
list[DefinitionEdge],
list[DefinitionDiagnostic],
]:
incoming: dict[str, list[DefinitionEdge]] = {node_id: [] for node_id in node_by_id}
undirected: dict[str, set[str]] = {node_id: set() for node_id in node_by_id}
topology_edges: list[DefinitionEdge] = []
diagnostics: list[DefinitionDiagnostic] = []
for edge in edges:
if edge.source not in node_by_id:
diagnostics.append(
_error(
"edge.unknown_source",
f"Edge {edge.id!r} references an unknown source node.",
)
)
continue
if edge.target not in node_by_id:
diagnostics.append(
_error(
"edge.unknown_target",
f"Edge {edge.id!r} references an unknown target node.",
)
)
continue
if edge.source == edge.target:
diagnostics.append(
_error(
"edge.self_reference",
"A node cannot connect to itself.",
node_id=edge.source,
)
)
structural_error = _edge_structure_diagnostic(edge, node_by_id)
if structural_error is not None:
diagnostics.append(structural_error)
continue
topology_edges.append(edge)
source_definition = library.node_type(node_by_id[edge.source].type)
target_definition = library.node_type(node_by_id[edge.target].type)
if source_definition is not None and edge.source_port not in {
port.id for port in source_definition.output_ports
}:
diagnostics.append(
_error(
"edge.unknown_source_port",
f"Node {edge.source!r} has no output port {edge.source_port!r}.",
node_id=edge.source,
)
)
continue
if target_definition is not None and edge.target_port not in {
port.id for port in target_definition.input_ports
}:
diagnostics.append(
_error(
"edge.unknown_target_port",
f"Node {edge.target!r} has no input port {edge.target_port!r}.",
node_id=edge.target,
)
)
port_error = _edge_port_diagnostic(
edge,
source_definition=definitions.get(node_by_id[edge.source].type),
target_definition=definitions.get(node_by_id[edge.target].type),
)
if port_error is not None:
diagnostics.append(port_error)
continue
incoming[edge.target].append(edge)
undirected[edge.source].add(edge.target)
undirected[edge.target].add(edge.source)
return incoming, undirected, topology_edges, diagnostics
def _edge_structure_diagnostic(
edge: DefinitionEdge,
node_by_id: Mapping[str, DefinitionNode],
) -> DefinitionDiagnostic | None:
if edge.source not in node_by_id:
return _error(
"edge.unknown_source",
f"Edge {edge.id!r} references an unknown source node.",
)
if edge.target not in node_by_id:
return _error(
"edge.unknown_target",
f"Edge {edge.id!r} references an unknown target node.",
)
if edge.source == edge.target:
return _error(
"edge.self_reference",
"A node cannot connect to itself.",
node_id=edge.source,
)
return None
def _edge_port_diagnostic(
edge: DefinitionEdge,
*,
source_definition: DefinitionNodeType | None,
target_definition: DefinitionNodeType | None,
) -> DefinitionDiagnostic | None:
if source_definition is not None and edge.source_port not in {
port.id for port in source_definition.output_ports
}:
return _error(
"edge.unknown_source_port",
f"Node {edge.source!r} has no output port {edge.source_port!r}.",
node_id=edge.source,
)
if target_definition is not None and edge.target_port not in {
port.id for port in target_definition.input_ports
}:
return _error(
"edge.unknown_target_port",
f"Node {edge.target!r} has no input port {edge.target_port!r}.",
node_id=edge.target,
)
return None
def _node_port_diagnostics(
*,
nodes: Sequence[DefinitionNode],
definitions: Mapping[str, DefinitionNodeType],
incoming: Mapping[str, Sequence[DefinitionEdge]],
library_id: str,
) -> list[DefinitionDiagnostic]:
diagnostics: list[DefinitionDiagnostic] = []
for node in nodes:
definition = library.node_type(node.type)
definition = definitions.get(node.type)
if definition is None:
diagnostics.append(
_error(
"node.unsupported_type",
f"Node type {node.type!r} is not in the {library.id!r} library.",
f"Node type {node.type!r} is not in the {library_id!r} library.",
node_id=node.id,
field="type",
)
@@ -231,10 +324,19 @@ def validate_definition_graph(
node_id=node.id,
)
)
return diagnostics
def _node_count_diagnostics(
*,
nodes: Sequence[DefinitionNode],
definitions: Mapping[str, DefinitionNodeType],
constraints: DefinitionGraphConstraints,
) -> list[DefinitionDiagnostic]:
diagnostics: list[DefinitionDiagnostic] = []
for count_constraint in constraints.node_counts:
count = sum(
count_constraint.matches(node, library.node_type(node.type))
count_constraint.matches(node, definitions.get(node.type))
for node in nodes
)
if count < count_constraint.minimum:
@@ -259,7 +361,18 @@ def validate_definition_graph(
),
)
)
return diagnostics
def _topology_diagnostics(
*,
nodes: Sequence[DefinitionNode],
node_by_id: Mapping[str, DefinitionNode],
topology_edges: Sequence[DefinitionEdge],
undirected: Mapping[str, set[str]],
constraints: DefinitionGraphConstraints,
) -> list[DefinitionDiagnostic]:
diagnostics: list[DefinitionDiagnostic] = []
_, cyclic = definition_topological_order(nodes, topology_edges)
if cyclic and not constraints.allow_cycles:
diagnostics.append(_error("graph.cycle", "Definition edges must form an acyclic graph."))
@@ -269,7 +382,7 @@ def validate_definition_graph(
diagnostics.append(
_error("graph.disconnected", "Every node must belong to one connected definition.")
)
return tuple(diagnostics)
return diagnostics
def definition_topological_order(

View File

@@ -7,11 +7,16 @@ from contextvars import ContextVar
from dataclasses import dataclass, field
from datetime import datetime, timezone
import re
from typing import Any, Literal
from typing import Any, Literal, Protocol, runtime_checkable
import uuid
from sqlalchemy import event as sqlalchemy_event
from sqlalchemy.orm import Session
_TRACE_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
_PENDING_EVENTS_KEY = "govoplan.pending_platform_events"
CAPABILITY_PLATFORM_EVENT_OUTBOX = "platform.eventOutbox"
def new_event_id() -> str:
@@ -102,6 +107,21 @@ class PlatformEvent:
EventHandler = Callable[[PlatformEvent], None]
@runtime_checkable
class PlatformEventOutbox(Protocol):
def enqueue(self, session: object, event: PlatformEvent) -> object:
...
def dispatch_pending(
self,
session: object,
*,
dispatcher: EventHandler,
limit: int = 100,
) -> Mapping[str, int]:
...
def current_event_trace() -> EventTrace | None:
return _current_trace.get()
@@ -189,5 +209,85 @@ def publish_platform_event(event: PlatformEvent) -> None:
platform_event_bus().publish(event)
def platform_event_outbox(
registry: object | None = None,
) -> PlatformEventOutbox | None:
if registry is None:
from govoplan_core.core.runtime import get_registry
registry = get_registry()
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
):
return None
capability = registry.capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
return capability if isinstance(capability, PlatformEventOutbox) else None
def emit_platform_event(
session: Session,
event: PlatformEvent,
*,
registry: object | None = None,
) -> None:
"""Persist an event with its transaction or publish it after commit.
The durable outbox is optional so reduced module combinations remain
usable. Without it, the event is kept on the SQLAlchemy session and only
delivered to the process-local bus after the outer transaction commits.
"""
traced = ensure_event_trace(event)
outbox = platform_event_outbox(registry)
if outbox is not None:
outbox.enqueue(session, traced)
return
transaction = (
session.get_nested_transaction()
or session.get_transaction()
or session.begin()
)
pending_by_transaction = session.info.setdefault(
_PENDING_EVENTS_KEY,
{},
)
pending_by_transaction.setdefault(transaction, []).append(
(platform_event_bus(), traced)
)
@sqlalchemy_event.listens_for(Session, "after_commit")
def _publish_committed_events(session: Session) -> None:
transaction = session.get_nested_transaction() or session.get_transaction()
if transaction is None:
return
pending_by_transaction = session.info.get(_PENDING_EVENTS_KEY)
if not isinstance(pending_by_transaction, dict):
return
pending = pending_by_transaction.pop(transaction, ())
parent = transaction.parent
if parent is not None:
pending_by_transaction.setdefault(parent, []).extend(pending)
else:
for bus, event in pending:
bus.publish(event)
if not pending_by_transaction:
session.info.pop(_PENDING_EVENTS_KEY, None)
@sqlalchemy_event.listens_for(Session, "after_rollback")
def _discard_rolled_back_events(session: Session) -> None:
transaction = session.get_nested_transaction() or session.get_transaction()
pending_by_transaction = session.info.get(_PENDING_EVENTS_KEY)
if transaction is None or not isinstance(pending_by_transaction, dict):
return
pending_by_transaction.pop(transaction, None)
if transaction.parent is None or not pending_by_transaction:
session.info.pop(_PENDING_EVENTS_KEY, None)
def _compact_dict(value: Mapping[str, Any]) -> dict[str, Any]:
return {key: item for key, item in value.items() if item is not None}

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
@@ -8,6 +8,7 @@ from typing import Literal, Protocol, runtime_checkable
IDM_MODULE_ID = "idm"
CAPABILITY_IDM_DIRECTORY = "idm.directory"
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS = "idm.function_assignments"
IdmStatus = Literal["active", "inactive", "suspended"]
OrganizationFunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
@@ -30,6 +31,18 @@ class OrganizationFunctionAssignmentRef:
status: IdmStatus = "active"
@dataclass(frozen=True, slots=True)
class OrganizationFunctionIncumbencyRef:
tenant_id: str
function_id: str
assignments: tuple[OrganizationFunctionAssignmentRef, ...] = ()
function_active: bool = True
@property
def vacant(self) -> bool:
return not self.assignments
@runtime_checkable
class IdmDirectory(Protocol):
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
@@ -40,6 +53,7 @@ class IdmDirectory(Protocol):
identity_id: str,
*,
tenant_id: str | None = None,
effective_at: datetime | None = None,
) -> Sequence[OrganizationFunctionAssignmentRef]:
...
@@ -48,5 +62,47 @@ class IdmDirectory(Protocol):
account_id: str,
*,
tenant_id: str | None = None,
effective_at: datetime | None = None,
) -> Sequence[OrganizationFunctionAssignmentRef]:
...
def organization_function_assignments_for_identities(
self,
identity_ids: Sequence[str],
*,
tenant_id: str | None = None,
effective_at: datetime | None = None,
) -> Mapping[str, Sequence[OrganizationFunctionAssignmentRef]]:
...
def organization_function_assignments_for_accounts(
self,
account_ids: Sequence[str],
*,
tenant_id: str | None = None,
effective_at: datetime | None = None,
) -> Mapping[str, Sequence[OrganizationFunctionAssignmentRef]]:
...
@runtime_checkable
class IdmFunctionAssignmentDirectory(Protocol):
"""Reverse lookup for effective incumbency and vacancy decisions."""
def organization_function_assignments_for_function(
self,
function_id: str,
*,
tenant_id: str | None = None,
effective_at: datetime | None = None,
) -> Sequence[OrganizationFunctionAssignmentRef]:
...
def organization_function_incumbencies(
self,
function_ids: Sequence[str],
*,
tenant_id: str,
effective_at: datetime | None = None,
) -> Mapping[str, OrganizationFunctionIncumbencyRef]:
...

View File

@@ -67,6 +67,17 @@ class ModuleLifecycleManager:
def mounted_module_ids(self) -> tuple[str, ...]:
return tuple(sorted(self._mounted_modules))
def live_apply_enabled(self) -> bool:
configured = getattr(
self.settings,
"module_live_apply_enabled",
None,
)
if configured is not None:
return bool(configured)
app_env = str(getattr(self.settings, "app_env", "dev")).casefold()
return app_env in {"dev", "development", "local", "test", "testing"}
def apply_enabled_modules(
self,
requested_enabled: Sequence[str],

View File

@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from govoplan_core.admin.models import SystemSettings
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID, get_system_settings
from govoplan_core.core.access import DEFAULT_CAPABILITY_PROVIDERS
from govoplan_core.core.discovery import iter_module_entry_points
from govoplan_core.core.modules import ModuleManifest
from govoplan_core.db.session import get_database
@@ -17,8 +18,8 @@ from govoplan_core.server.registry import parse_enabled_modules
MODULE_SETTINGS_KEY = "module_management"
INSTALL_PLAN_KEY = "install_plan"
REQUIRED_PLATFORM_MODULES = ("access",)
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin")
REQUIRED_PLATFORM_MODULES: tuple[str, ...] = ()
PROTECTED_MODULES = ("admin",)
INSTALL_PLAN_ACTIONS = ("install", "update", "uninstall")
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
INSTALL_PLAN_SOURCES = ("manual", "catalog")
@@ -109,6 +110,7 @@ def startup_candidate_module_ids(
if desired is not None:
candidates.extend(str(item).strip() for item in desired if str(item).strip())
candidates.extend(REQUIRED_PLATFORM_MODULES)
candidates.extend(DEFAULT_CAPABILITY_PROVIDERS.values())
if "admin" in fallback:
candidates.append("admin")
return tuple(dict.fromkeys(candidates))
@@ -404,46 +406,148 @@ def plan_desired_enabled_modules(
*,
protected_modules: Iterable[str] = PROTECTED_MODULES,
) -> ModuleStatePlan:
requested = {str(item).strip() for item in requested_enabled if str(item).strip()}
protected = {str(item).strip() for item in protected_modules if str(item).strip()}
requested.update(protected)
requested = _normalized_module_ids(requested_enabled)
requested.update(_normalized_module_ids(protected_modules))
missing = sorted(module_id for module_id in requested if module_id not in available)
if missing:
raise ModuleManagementError("Unknown or uninstalled modules: " + ", ".join(missing))
added_dependencies: set[str] = set()
visiting: set[str] = set()
visited: set[str] = set()
ordered: list[str] = []
def visit(module_id: str) -> None:
if module_id in visited:
return
if module_id in visiting:
raise ModuleManagementError(f"Module dependency cycle includes {module_id!r}.")
manifest = available.get(module_id)
if manifest is None:
raise ModuleManagementError(f"Module {module_id!r} is required but not installed.")
visiting.add(module_id)
for dependency_id in manifest.dependencies:
if dependency_id not in available:
raise ModuleManagementError(f"Module {module_id!r} depends on uninstalled module {dependency_id!r}.")
if dependency_id not in requested:
added_dependencies.add(dependency_id)
requested.add(dependency_id)
visit(dependency_id)
visiting.remove(module_id)
visited.add(module_id)
ordered.append(module_id)
for module_id in sorted(requested):
visit(module_id)
added_dependencies = _expand_enabled_module_closure(requested, available)
ordered = _order_enabled_modules(requested, available, added_dependencies)
return ModuleStatePlan(
enabled_modules=tuple(dict.fromkeys(ordered)),
added_dependencies=tuple(sorted(added_dependencies)),
)
def _normalized_module_ids(values: Iterable[str]) -> set[str]:
return {str(item).strip() for item in values if str(item).strip()}
def _expand_enabled_module_closure(
requested: set[str],
available: Mapping[str, ModuleManifest],
) -> set[str]:
added: set[str] = set()
while True:
dependencies = {
dependency
for module_id in requested
for dependency in available[module_id].dependencies
}
_require_installed_modules(
dependencies,
available,
message="Required modules are not installed: ",
)
dependency_additions = dependencies - requested
added.update(dependency_additions)
requested.update(dependency_additions)
providers = _missing_capability_providers(requested, available)
_require_installed_modules(
providers,
available,
message="Required capability providers are not installed: ",
)
additions = providers - requested
if not additions:
return added
added.update(additions)
requested.update(additions)
def _missing_capability_providers(
requested: set[str],
available: Mapping[str, ModuleManifest],
) -> set[str]:
provided = {
capability
for module_id in requested
for capability in available[module_id].capability_factories
}
return {
provider_id
for module_id in requested
for capability in available[module_id].required_capabilities
if capability not in provided
for provider_id in (DEFAULT_CAPABILITY_PROVIDERS.get(capability),)
if provider_id is not None
}
def _require_installed_modules(
module_ids: set[str],
available: Mapping[str, ModuleManifest],
*,
message: str,
) -> None:
missing = sorted(module_id for module_id in module_ids if module_id not in available)
if missing:
raise ModuleManagementError(message + ", ".join(missing))
def _order_enabled_modules(
requested: set[str],
available: Mapping[str, ModuleManifest],
added_dependencies: set[str],
) -> list[str]:
visiting: set[str] = set()
visited: set[str] = set()
ordered: list[str] = []
for module_id in sorted(requested):
_visit_enabled_module(
module_id,
requested=requested,
available=available,
added_dependencies=added_dependencies,
visiting=visiting,
visited=visited,
ordered=ordered,
)
return ordered
def _visit_enabled_module(
module_id: str,
*,
requested: set[str],
available: Mapping[str, ModuleManifest],
added_dependencies: set[str],
visiting: set[str],
visited: set[str],
ordered: list[str],
) -> None:
if module_id in visited:
return
if module_id in visiting:
raise ModuleManagementError(f"Module dependency cycle includes {module_id!r}.")
manifest = available.get(module_id)
if manifest is None:
raise ModuleManagementError(f"Module {module_id!r} is required but not installed.")
visiting.add(module_id)
for dependency_id in manifest.dependencies:
if dependency_id not in available:
raise ModuleManagementError(
f"Module {module_id!r} depends on uninstalled module {dependency_id!r}."
)
if dependency_id not in requested:
added_dependencies.add(dependency_id)
requested.add(dependency_id)
_visit_enabled_module(
dependency_id,
requested=requested,
available=available,
added_dependencies=added_dependencies,
visiting=visiting,
visited=visited,
ordered=ordered,
)
visiting.remove(module_id)
visited.add(module_id)
ordered.append(module_id)
def module_dependents(available: Mapping[str, ModuleManifest]) -> dict[str, tuple[str, ...]]:
dependents: dict[str, list[str]] = {module_id: [] for module_id in available}
for manifest in available.values():

View File

@@ -0,0 +1,386 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
POSTBOX_MODULE_ID = "postbox"
CAPABILITY_POSTBOX_DIRECTORY = "postbox.directory"
CAPABILITY_POSTBOX_ACCESS = "postbox.access"
CAPABILITY_POSTBOX_MESSAGES = "postbox.messages"
CAPABILITY_POSTBOX_DELIVERY = "postbox.delivery"
CAPABILITY_POSTBOX_EVIDENCE = "postbox.evidence"
PostboxAction = Literal["discover", "read", "send", "acknowledge", "administer"]
PostboxMessageListState = Literal["all", "unread", "read", "acknowledged"]
@dataclass(frozen=True, slots=True)
class PostboxActorRef:
account_id: str
identity_id: str | None = None
selected_assignment_id: str | None = None
acting_for_account_id: str | None = None
authorized_actions: frozenset[PostboxAction] = frozenset()
@dataclass(frozen=True, slots=True)
class PostboxTargetRef:
postbox_id: str | None = None
address_key: str | None = None
template_id: str | None = None
organization_unit_id: str | None = None
function_id: str | None = None
context_key: str | None = None
@dataclass(frozen=True, slots=True)
class PostboxDeliveryTemplateRef:
id: str
slug: str
name: str
description: str | None
published_revision_id: str
function_type_id: str | None
scope_kind: str
scope_id: str | None
classification: str
allow_vacant_delivery: bool
@dataclass(frozen=True, slots=True)
class PostboxOrganizationFunctionTargetRef:
id: str
slug: str
name: str
function_type_id: str | None = None
@dataclass(frozen=True, slots=True)
class PostboxOrganizationUnitTargetRef:
id: str
slug: str
name: str
unit_type_id: str | None = None
parent_id: str | None = None
functions: tuple[PostboxOrganizationFunctionTargetRef, ...] = ()
@dataclass(frozen=True, slots=True)
class PostboxDeliveryCatalogRef:
postboxes: tuple["PostboxDirectoryEntryRef", ...] = ()
templates: tuple[PostboxDeliveryTemplateRef, ...] = ()
organization_units: tuple[PostboxOrganizationUnitTargetRef, ...] = ()
@dataclass(frozen=True, slots=True)
class PostboxAccessDecisionRef:
allowed: bool
action: PostboxAction
postbox_id: str
reason_code: str
explanation: str
organization_unit_id: str | None = None
function_id: str | None = None
assignment_ids: tuple[str, ...] = ()
assignment_sources: tuple[str, ...] = ()
selected_assignment_id: str | None = None
holder_count: int = 0
vacant: bool = True
@dataclass(frozen=True, slots=True)
class PostboxDirectoryEntryRef:
id: str
tenant_id: str
address: str
address_key: str
name: str
status: str
classification: str
organization_unit_id: str | None = None
organization_unit_name: str | None = None
function_id: str | None = None
function_name: str | None = None
context_key: str | None = None
template_revision_id: str | None = None
holder_count: int = 0
vacant: bool = True
access: PostboxAccessDecisionRef | None = None
@dataclass(frozen=True, slots=True)
class PostboxParticipantRef:
kind: str
reference_type: str
reference_id: str | None = None
label: str | None = None
address: str | None = None
@dataclass(frozen=True, slots=True)
class PostboxAttachmentRef:
reference_type: str
reference_id: str
name: str | None = None
media_type: str | None = None
size_bytes: int | None = None
digest: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxMessageRef:
id: str
tenant_id: str
postbox_id: str
subject: str
body_text: str | None
status: str
classification: str
sender_label: str | None
delivered_at: datetime
read_at: datetime | None = None
acknowledged_at: datetime | None = None
expires_at: datetime | None = None
withdrawn_at: datetime | None = None
producer_module: str | None = None
producer_resource_type: str | None = None
producer_resource_id: str | None = None
encryption_profile: str = "plaintext_v1"
key_epoch: int = 1
ciphertext_ref: str | None = None
signed_manifest_ref: str | None = None
participants: tuple[PostboxParticipantRef, ...] = ()
attachments: tuple[PostboxAttachmentRef, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxDeliveryRequest:
tenant_id: str
target: PostboxTargetRef
producer_module: str
producer_resource_type: str
producer_resource_id: str | None
idempotency_key: str
subject: str
body_text: str | None = None
sender_label: str | None = None
classification: str = "internal"
participants: tuple[PostboxParticipantRef, ...] = ()
attachments: tuple[PostboxAttachmentRef, ...] = ()
expires_at: datetime | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxDeliveryResult:
delivery_id: str
postbox_id: str
message_id: str
address: str
status: str
vacant: bool
holder_count: int
duplicate: bool = False
evidence: Mapping[str, object] = field(default_factory=dict)
class PostboxDeliveryRejected(RuntimeError):
"""A delivery was rejected before the provider accepted any effect."""
def __init__(
self,
code: str,
message: str,
*,
temporary: bool = False,
) -> None:
super().__init__(message)
self.code = code
self.temporary = temporary
class PostboxDeliveryOutcomeUnknown(RuntimeError):
"""The provider may have accepted an effect and must not be bypassed."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
@runtime_checkable
class PostboxDirectoryProvider(Protocol):
def list_visible_postboxes(
self,
session: object,
*,
tenant_id: str,
actor: PostboxActorRef,
) -> Sequence[PostboxDirectoryEntryRef]:
...
def resolve_postbox(
self,
session: object,
*,
tenant_id: str,
target: PostboxTargetRef,
materialize: bool = False,
) -> PostboxDirectoryEntryRef | None:
...
def delivery_catalog(
self,
session: object,
*,
tenant_id: str,
) -> PostboxDeliveryCatalogRef:
...
@runtime_checkable
class PostboxAccessProvider(Protocol):
def explain_access(
self,
session: object,
*,
tenant_id: str,
postbox_id: str,
actor: PostboxActorRef,
action: PostboxAction,
) -> PostboxAccessDecisionRef:
...
@runtime_checkable
class PostboxMessagesProvider(Protocol):
def list_messages(
self,
session: object,
*,
tenant_id: str,
postbox_ids: Sequence[str],
actor: PostboxActorRef,
limit: int = 100,
offset: int = 0,
query: str | None = None,
state: PostboxMessageListState = "all",
) -> Sequence[PostboxMessageRef]:
...
def get_message(
self,
session: object,
*,
tenant_id: str,
message_id: str,
actor: PostboxActorRef,
) -> PostboxMessageRef | None:
...
def mark_message(
self,
session: object,
*,
tenant_id: str,
message_id: str,
actor: PostboxActorRef,
state: Literal["read", "acknowledged"],
) -> PostboxMessageRef:
...
@runtime_checkable
class PostboxDeliveryProvider(Protocol):
def deliver(
self,
session: object,
request: PostboxDeliveryRequest,
) -> PostboxDeliveryResult:
...
@runtime_checkable
class PostboxEvidenceProvider(Protocol):
def link_evidence(
self,
session: object,
*,
tenant_id: str,
message_id: str,
attachment: PostboxAttachmentRef,
) -> PostboxMessageRef:
...
def _postbox_provider(
registry: object | None,
*,
capability_name: str,
provider_type: type,
) -> object | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(capability_name):
return None
capability = registry.capability(capability_name)
return capability if isinstance(capability, provider_type) else None
def postbox_directory_provider(
registry: object | None,
) -> PostboxDirectoryProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_DIRECTORY,
provider_type=PostboxDirectoryProvider,
)
return provider if isinstance(provider, PostboxDirectoryProvider) else None
def postbox_access_provider(
registry: object | None,
) -> PostboxAccessProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_ACCESS,
provider_type=PostboxAccessProvider,
)
return provider if isinstance(provider, PostboxAccessProvider) else None
def postbox_messages_provider(
registry: object | None,
) -> PostboxMessagesProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_MESSAGES,
provider_type=PostboxMessagesProvider,
)
return provider if isinstance(provider, PostboxMessagesProvider) else None
def postbox_delivery_provider(
registry: object | None,
) -> PostboxDeliveryProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_DELIVERY,
provider_type=PostboxDeliveryProvider,
)
return provider if isinstance(provider, PostboxDeliveryProvider) else None
def postbox_evidence_provider(
registry: object | None,
) -> PostboxEvidenceProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_EVIDENCE,
provider_type=PostboxEvidenceProvider,
)
return provider if isinstance(provider, PostboxEvidenceProvider) else None

View File

@@ -0,0 +1,369 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.access import (
CAPABILITY_ACCESS_DIRECTORY,
AccessDirectory,
GroupRef,
UserRef,
)
ReferenceAvailability = Literal["available", "inactive", "unavailable"]
@dataclass(frozen=True, slots=True)
class ReferenceOption:
"""A stable identifier paired with presentation-only directory metadata."""
value: str
label: str
description: str | None = None
kind: str | None = None
availability: ReferenceAvailability = "available"
disabled: bool = False
source_module: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
def to_dict(self) -> dict[str, object]:
return {
"value": self.value,
"label": self.label,
"description": self.description,
"kind": self.kind,
"availability": self.availability,
"disabled": self.disabled,
"source_module": self.source_module,
"provenance": dict(self.provenance),
}
@dataclass(frozen=True, slots=True)
class ReferenceSearchRequest:
kind: str
tenant_id: str | None
query: str = ""
selected_values: tuple[str, ...] = ()
limit: int = 50
context: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class ReferenceOptionProvider(Protocol):
"""Optional module-neutral provider for typed reference selectors."""
def search_reference_options(
self,
session: object,
principal: object,
*,
request: ReferenceSearchRequest,
) -> Sequence[ReferenceOption]:
...
def reference_option_provider(
registry: object | None,
capability_name: str,
) -> ReferenceOptionProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(capability_name)
):
return None
provider = registry.capability(capability_name)
return provider if isinstance(provider, ReferenceOptionProvider) else None
def access_scope_reference_options(
registry: object | None,
principal: object,
*,
scope_type: str,
query: str = "",
selected_values: Sequence[str] = (),
limit: int = 50,
administrative: bool = False,
) -> tuple[ReferenceOption, ...]:
"""Return canonical user-account or group references for definition scopes."""
clean_type = str(scope_type or "").strip().casefold()
if clean_type not in {"user", "group"}:
raise ValueError("Scope reference type must be user or group.")
normalized_limit = max(1, min(int(limit), 200))
selected = tuple(
dict.fromkeys(
str(value).strip() for value in selected_values if str(value).strip()
)
)
directory = _access_directory(registry)
if directory is None:
return _fallback_scope_options(
principal,
scope_type=clean_type,
query=query,
selected_values=selected,
limit=normalized_limit,
)
tenant_id = str(getattr(principal, "tenant_id", "") or "")
if not tenant_id:
return tuple(
_unavailable_option(value, kind=clean_type) for value in selected
)
if clean_type == "user":
candidates = _user_options(
directory.users_for_tenant(tenant_id),
principal=principal,
administrative=administrative,
)
else:
candidates = _group_options(
directory.groups_for_tenant(tenant_id),
principal=principal,
administrative=administrative,
)
return _filter_and_retain_options(
candidates,
query=query,
selected_values=selected,
limit=normalized_limit,
kind=clean_type,
)
def access_scope_reference_provider_available(registry: object | None) -> bool:
return _access_directory(registry) is not None
def validate_access_scope_reference(
registry: object | None,
*,
tenant_id: str | None,
scope_type: str,
scope_id: str | None,
preserve_existing: str | None = None,
) -> str | None:
"""Validate new directory-backed scope references while preserving history."""
clean_type = str(scope_type or "").strip().casefold()
clean_id = str(scope_id or "").strip() or None
if clean_type not in {"user", "group"}:
return clean_id
if not clean_id:
raise ValueError(f"{clean_type.capitalize()} definitions require a scope ID.")
if clean_id == preserve_existing:
return clean_id
directory = _access_directory(registry)
if directory is None:
return clean_id
if not tenant_id:
raise ValueError("Directory-backed scopes require an active tenant.")
if clean_type == "group":
group = directory.get_group(clean_id)
if group is None or group.tenant_id != tenant_id:
raise ValueError("The selected group is not available in the active tenant.")
if group.status != "active":
raise ValueError("Inactive groups cannot be selected for new definitions.")
return group.id
matching_user = next(
(
user
for user in directory.users_for_tenant(tenant_id)
if user.account_id == clean_id
),
None,
)
if matching_user is None:
raise ValueError("The selected user account is not available in the active tenant.")
if matching_user.status != "active":
raise ValueError("Inactive users cannot be selected for new definitions.")
account = directory.get_account(matching_user.account_id)
if account is not None and account.status != "active":
raise ValueError("Inactive user accounts cannot be selected for new definitions.")
return matching_user.account_id
def _access_directory(registry: object | None) -> AccessDirectory | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY)
):
return None
provider = registry.capability(CAPABILITY_ACCESS_DIRECTORY)
return provider if isinstance(provider, AccessDirectory) else None
def _user_options(
users: Sequence[UserRef],
*,
principal: object,
administrative: bool,
) -> tuple[ReferenceOption, ...]:
principal_account_id = str(getattr(principal, "account_id", "") or "")
options: dict[str, ReferenceOption] = {}
for user in users:
if not administrative and user.account_id != principal_account_id:
continue
inactive = user.status != "active"
label = user.display_name or user.email or user.account_id
detail_parts = [part for part in (user.email, "Inactive" if inactive else None) if part]
options[user.account_id] = ReferenceOption(
value=user.account_id,
label=label,
description=" · ".join(detail_parts) or None,
kind="user",
availability="inactive" if inactive else "available",
disabled=inactive,
source_module="access",
provenance={
"membership_id": user.id,
"tenant_id": user.tenant_id,
"account_id": user.account_id,
},
)
return tuple(options.values())
def _group_options(
groups: Sequence[GroupRef],
*,
principal: object,
administrative: bool,
) -> tuple[ReferenceOption, ...]:
permitted = {
str(value)
for value in getattr(principal, "group_ids", ())
if str(value)
}
return tuple(
ReferenceOption(
value=group.id,
label=group.name or group.id,
description="Inactive" if group.status != "active" else None,
kind="group",
availability=(
"inactive" if group.status != "active" else "available"
),
disabled=group.status != "active",
source_module="access",
provenance={"tenant_id": group.tenant_id, "group_id": group.id},
)
for group in groups
if administrative or group.id in permitted
)
def _filter_and_retain_options(
options: Sequence[ReferenceOption],
*,
query: str,
selected_values: Sequence[str],
limit: int,
kind: str,
) -> tuple[ReferenceOption, ...]:
by_value = {option.value: option for option in options}
needle = str(query or "").strip().casefold()
matches = [
option
for option in options
if not needle
or needle
in " ".join(
(
option.value,
option.label,
option.description or "",
)
).casefold()
][:limit]
returned = {option.value for option in matches}
for value in selected_values:
if value in returned:
continue
matches.append(by_value.get(value) or _unavailable_option(value, kind=kind))
returned.add(value)
return tuple(matches)
def _fallback_scope_options(
principal: object,
*,
scope_type: str,
query: str,
selected_values: Sequence[str],
limit: int,
) -> tuple[ReferenceOption, ...]:
candidates: list[ReferenceOption] = []
if scope_type == "user":
account_id = str(getattr(principal, "account_id", "") or "")
if account_id:
candidates.append(
ReferenceOption(
value=account_id,
label=(
str(getattr(principal, "display_name", "") or "")
or str(getattr(principal, "email", "") or "")
or account_id
),
description="Current user · Access directory unavailable",
kind="user",
source_module="core",
provenance={"fallback": True},
)
)
else:
candidates.extend(
ReferenceOption(
value=str(group_id),
label=str(group_id),
description="Permitted group · Access directory unavailable",
kind="group",
source_module="core",
provenance={"fallback": True},
)
for group_id in getattr(principal, "group_ids", ())
if str(group_id)
)
return _filter_and_retain_options(
candidates,
query=query,
selected_values=selected_values,
limit=limit,
kind=scope_type,
)
def _unavailable_option(value: str, *, kind: str) -> ReferenceOption:
return ReferenceOption(
value=value,
label=f"Unavailable {kind}",
description=value,
kind=kind,
availability="unavailable",
disabled=True,
source_module=None,
provenance={"retained_reference": True},
)
__all__ = [
"ReferenceAvailability",
"ReferenceOption",
"ReferenceOptionProvider",
"ReferenceSearchRequest",
"access_scope_reference_options",
"access_scope_reference_provider_available",
"reference_option_provider",
"validate_access_scope_reference",
]

View File

@@ -514,59 +514,86 @@ def _validate_view_surfaces(manifest: ModuleManifest) -> None:
if frontend is None:
return
root_id = module_view_surface_id(manifest.id)
_validate_view_surface_id(manifest.id, root_id)
known_ids = {root_id}
_validate_view_surface_id(manifest.id, root_id)
for item in frontend.nav_items:
surface_id = item.surface_id or navigation_view_surface_id(manifest.id, item.path)
_validate_view_surface_id(manifest.id, surface_id)
if surface_id in known_ids:
raise RegistryError(
f"Duplicate view surface id {surface_id!r} in module {manifest.id!r}"
)
known_ids.add(surface_id)
_register_view_surface_id(manifest.id, surface_id, known_ids)
for route in (*frontend.routes, *frontend.settings_routes):
surface_id = route.surface_id or route_view_surface_id(manifest.id, route.path)
_validate_view_surface_id(manifest.id, surface_id)
if surface_id in known_ids:
raise RegistryError(
f"Duplicate view surface id {surface_id!r} in module {manifest.id!r}"
)
known_ids.add(surface_id)
_register_view_surface_id(manifest.id, surface_id, known_ids)
for surface in frontend.view_surfaces:
if surface.module_id != manifest.id:
raise RegistryError(
f"View surface {surface.id!r} belongs to {surface.module_id!r}, "
f"not module {manifest.id!r}"
)
_validate_view_surface_id(manifest.id, surface.id)
if surface.id in known_ids:
raise RegistryError(
f"Duplicate view surface id {surface.id!r} in module {manifest.id!r}"
)
known_ids.add(surface.id)
_validate_declared_view_surface(manifest.id, surface)
_register_view_surface_id(manifest.id, surface.id, known_ids)
for surface in frontend.view_surfaces:
parent_id = surface.parent_id or root_id
if parent_id not in known_ids:
raise RegistryError(
f"View surface {surface.id!r} in module {manifest.id!r} "
f"references unknown parent {parent_id!r}"
)
if surface.required and not surface.default_visible:
raise RegistryError(
f"Required view surface {surface.id!r} in module {manifest.id!r} "
"must be visible by default"
)
_validate_view_surface_definition(
manifest.id,
surface,
root_id=root_id,
known_ids=known_ids,
)
parent_by_id = {
surface.id: surface.parent_id or root_id
for surface in frontend.view_surfaces
}
_validate_view_surface_hierarchy(manifest.id, parent_by_id)
def _register_view_surface_id(
module_id: str,
surface_id: str,
known_ids: set[str],
) -> None:
_validate_view_surface_id(module_id, surface_id)
if surface_id in known_ids:
raise RegistryError(
f"Duplicate view surface id {surface_id!r} in module {module_id!r}"
)
known_ids.add(surface_id)
def _validate_declared_view_surface(
module_id: str,
surface: ViewSurface,
) -> None:
if surface.module_id != module_id:
raise RegistryError(
f"View surface {surface.id!r} belongs to {surface.module_id!r}, "
f"not module {module_id!r}"
)
def _validate_view_surface_definition(
module_id: str,
surface: ViewSurface,
*,
root_id: str,
known_ids: set[str],
) -> None:
parent_id = surface.parent_id or root_id
if parent_id not in known_ids:
raise RegistryError(
f"View surface {surface.id!r} in module {module_id!r} "
f"references unknown parent {parent_id!r}"
)
if surface.required and not surface.default_visible:
raise RegistryError(
f"Required view surface {surface.id!r} in module {module_id!r} "
"must be visible by default"
)
def _validate_view_surface_hierarchy(
module_id: str,
parent_by_id: Mapping[str, str],
) -> None:
for surface_id in parent_by_id:
path: set[str] = set()
current_id: str | None = surface_id
while current_id in parent_by_id:
if current_id in path:
raise RegistryError(
f"View surface hierarchy in module {manifest.id!r} "
f"View surface hierarchy in module {module_id!r} "
f"contains a cycle at {current_id!r}"
)
path.add(current_id)

View File

@@ -41,39 +41,73 @@ def parse_tabular_csv(
raise TabularSourceValidationError("CSV delimiter must be one character.")
try:
reader = csv.DictReader(io.StringIO(csv_text), delimiter=delimiter)
if not reader.fieldnames or any(not str(name or "").strip() for name in reader.fieldnames):
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
normalized_headers = [str(name).strip() for name in reader.fieldnames]
normalized_headers[0] = normalized_headers[0].lstrip("\ufeff")
if any(not name for name in normalized_headers):
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
if len(set(normalized_headers)) != len(normalized_headers):
raise TabularSourceValidationError("CSV column names must be unique.")
original_headers, normalized_headers = _csv_headers(reader.fieldnames)
rows: list[dict[str, object]] = []
for row in reader:
extras = row.get(None)
if extras and any(str(value or "").strip() for value in extras):
raise TabularSourceValidationError(
"A CSV row contains more values than the header defines."
)
values = [row.get(original) for original in reader.fieldnames]
if all(value is None or not value.strip() for value in values):
_validate_csv_row_shape(row)
if _csv_row_is_empty(row, original_headers):
continue
if len(rows) >= max_rows:
raise TabularSourceValidationError(
f"CSV snapshots are limited to {max_rows:,} rows."
)
rows.append(
{
normalized_headers[position]: _csv_scalar(row.get(original))
for position, original in enumerate(reader.fieldnames)
}
)
rows.append(_csv_row(row, original_headers, normalized_headers))
return tuple(rows)
except csv.Error as exc:
raise TabularSourceValidationError(f"CSV input could not be parsed: {exc}") from exc
def _csv_headers(
fieldnames: Sequence[str | None] | None,
) -> tuple[tuple[str, ...], tuple[str, ...]]:
if not fieldnames:
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
original = tuple(str(name or "") for name in fieldnames)
normalized = tuple(
name.strip().lstrip("\ufeff") if position == 0 else name.strip()
for position, name in enumerate(original)
)
if any(not name for name in normalized):
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
if len(set(normalized)) != len(normalized):
raise TabularSourceValidationError("CSV column names must be unique.")
return original, normalized
def _validate_csv_row_shape(row: Mapping[str | None, object]) -> None:
extras = row.get(None)
if isinstance(extras, list) and any(str(value or "").strip() for value in extras):
raise TabularSourceValidationError(
"A CSV row contains more values than the header defines."
)
def _csv_row_is_empty(
row: Mapping[str | None, str | list[str] | None],
headers: Sequence[str],
) -> bool:
return all(
value is None or isinstance(value, list) or not value.strip()
for value in (row.get(header) for header in headers)
)
def _csv_row(
row: Mapping[str | None, str | list[str] | None],
original_headers: Sequence[str],
normalized_headers: Sequence[str],
) -> dict[str, object]:
return {
normalized: _csv_scalar(value if isinstance(value, str) else None)
for original, normalized in zip(
original_headers,
normalized_headers,
strict=True,
)
for value in (row.get(original),)
}
def _csv_scalar(value: str | None) -> object:
if value is None:
return None

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
from collections.abc import Generator, Mapping
import os
from typing import Any
from sqlalchemy import create_engine
@@ -10,6 +11,14 @@ from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.db.query_metrics import instrument_engine
_POOL_SETTING_BOUNDS: dict[str, tuple[int, int]] = {
"GOVOPLAN_DB_POOL_SIZE": (1, 100),
"GOVOPLAN_DB_MAX_OVERFLOW": (0, 200),
"GOVOPLAN_DB_POOL_TIMEOUT_SECONDS": (1, 300),
"GOVOPLAN_DB_POOL_RECYCLE_SECONDS": (0, 86_400),
}
def default_connect_args(database_url: str) -> dict[str, Any]:
return {"check_same_thread": False} if database_url.startswith("sqlite") else {}
@@ -24,9 +33,42 @@ def create_database_engine(
merged_connect_args = dict(default_connect_args(database_url))
if connect_args:
merged_connect_args.update(connect_args)
if not database_url.startswith("sqlite"):
kwargs.setdefault(
"pool_size",
_pool_setting("GOVOPLAN_DB_POOL_SIZE", 5),
)
kwargs.setdefault(
"max_overflow",
_pool_setting("GOVOPLAN_DB_MAX_OVERFLOW", 10),
)
kwargs.setdefault(
"pool_timeout",
_pool_setting("GOVOPLAN_DB_POOL_TIMEOUT_SECONDS", 30),
)
kwargs.setdefault(
"pool_recycle",
_pool_setting("GOVOPLAN_DB_POOL_RECYCLE_SECONDS", 1800),
)
return instrument_engine(create_engine(database_url, pool_pre_ping=pool_pre_ping, connect_args=merged_connect_args, **kwargs))
def _pool_setting(name: str, default: int) -> int:
raw = os.environ.get(name)
if raw is None:
return default
try:
value = int(raw)
except ValueError as exc:
raise ValueError(f"{name} must be an integer") from exc
minimum, maximum = _POOL_SETTING_BOUNDS[name]
if not minimum <= value <= maximum:
raise ValueError(
f"{name} must be between {minimum} and {maximum}",
)
return value
class DatabaseHandle:
def __init__(self, database_url: str, *, engine: Engine | None = None) -> None:
self.database_url = database_url

View File

@@ -72,8 +72,19 @@ def _validate_production_startup() -> None:
"off",
}
if throttle_enabled and not os.getenv("REDIS_URL", "").strip():
allow_process_local = os.getenv(
"GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE",
"false",
).strip().lower() in {"1", "true", "yes", "on"}
if not allow_process_local:
raise RuntimeError(
"Production login throttling requires REDIS_URL. "
"Set GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE=true only "
"for an explicitly single-process deployment."
)
logger.warning(
"Redis is not configured; login throttling is process-local and will not coordinate horizontally"
"Redis is not configured; login throttling is explicitly limited "
"to this process"
)

View File

@@ -1,10 +1,11 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.exc import SQLAlchemyError
from govoplan_core.admin.models import SystemSettings
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.maintenance import saved_maintenance_mode
from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleManifest, NavItem, PublicFrontendRoute
from govoplan_core.core.registry import PlatformRegistry, manifest_view_surfaces
@@ -153,7 +154,10 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
}
@router.get("/modules")
def modules(request: Request):
def modules(
request: Request,
_principal: ApiPrincipal = Depends(get_api_principal),
):
registry = _registry(request)
return {
"modules": [
@@ -190,7 +194,10 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
}
@router.get("/permissions")
def permissions(request: Request):
def permissions(
request: Request,
_principal: ApiPrincipal = Depends(get_api_principal),
):
registry = _registry(request)
return {
"permissions": [
@@ -210,7 +217,10 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
}
@router.get("/navigation")
def navigation(request: Request):
def navigation(
request: Request,
_principal: ApiPrincipal = Depends(get_api_principal),
):
registry = _registry(request)
return {
"items": [_nav_item_payload(item) for item in registry.nav_items()]

View File

@@ -3,6 +3,9 @@ from __future__ import annotations
from collections.abc import Callable, Iterable, Sequence
import importlib
from govoplan_core.core.access import (
DEFAULT_CAPABILITY_PROVIDERS,
)
from govoplan_core.core.discovery import discover_module_manifests
from govoplan_core.core.modules import ModuleManifest
from govoplan_core.core.registry import PlatformRegistry, RegistryError
@@ -15,7 +18,6 @@ _BUILTIN_MANIFESTS = {
"tenancy": "govoplan_tenancy.backend.manifest:get_manifest",
}
def parse_enabled_modules(value: str | Iterable[str]) -> list[str]:
if isinstance(value, str):
items = [item.strip() for item in value.split(",")]
@@ -57,22 +59,68 @@ def available_module_manifests(
return manifests
def build_platform_registry(enabled_modules: str | Iterable[str], *, manifest_factories: Sequence[ManifestFactory] = ()) -> PlatformRegistry:
def build_platform_registry(
enabled_modules: str | Iterable[str],
*,
manifest_factories: Sequence[ManifestFactory] = (),
) -> PlatformRegistry:
requested = parse_enabled_modules(enabled_modules)
if "access" not in requested:
requested.insert(0, "access")
available = available_module_manifests(manifest_factories, enabled_modules=requested)
available = _resolve_required_manifests(
requested,
manifest_factories=manifest_factories,
)
registry = PlatformRegistry()
for module_id in requested:
manifest = available.get(module_id)
if manifest is None:
raise RegistryError(f"Enabled module is not available: {module_id}")
for module_id, manifest in available.items():
registry.register(manifest)
registry.validate()
return registry
def _resolve_required_manifests(
requested: Sequence[str],
*,
manifest_factories: Sequence[ManifestFactory],
) -> dict[str, ModuleManifest]:
manifests: dict[str, ModuleManifest] = {}
pending = list(dict.fromkeys(requested))
while pending:
module_ids = [module_id for module_id in pending if module_id not in manifests]
pending = []
if not module_ids:
break
available = available_module_manifests(
manifest_factories,
enabled_modules=module_ids,
)
for module_id in module_ids:
manifest = available.get(module_id)
if manifest is None:
raise RegistryError(f"Enabled module is not available: {module_id}")
manifests[module_id] = manifest
provided_capabilities = {
capability
for manifest in manifests.values()
for capability in manifest.capability_factories
}
for manifest in tuple(manifests.values()):
pending.extend(
dependency_id
for dependency_id in manifest.dependencies
if dependency_id not in manifests
)
for capability in manifest.required_capabilities:
if capability in provided_capabilities:
continue
provider_id = DEFAULT_CAPABILITY_PROVIDERS.get(capability)
if provider_id is not None and provider_id not in manifests:
pending.append(provider_id)
return manifests
def _load_builtin_manifest(module_id: str) -> ModuleManifest:
target = _BUILTIN_MANIFESTS[module_id]
module_name, function_name = validate_object_path(target)

View File

@@ -6,18 +6,46 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=None, extra="ignore")
app_env: str = Field(default="dev", alias="APP_ENV")
module_live_apply_enabled: bool | None = Field(
default=None,
alias="GOVOPLAN_MODULE_LIVE_APPLY_ENABLED",
)
database_url: str = Field(
default="postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev",
alias="DATABASE_URL",
)
database_pool_size: int = Field(
default=5,
alias="GOVOPLAN_DB_POOL_SIZE",
ge=1,
le=100,
)
database_max_overflow: int = Field(
default=10,
alias="GOVOPLAN_DB_MAX_OVERFLOW",
ge=0,
le=200,
)
database_pool_timeout_seconds: int = Field(
default=30,
alias="GOVOPLAN_DB_POOL_TIMEOUT_SECONDS",
ge=1,
le=300,
)
database_pool_recycle_seconds: int = Field(
default=1800,
alias="GOVOPLAN_DB_POOL_RECYCLE_SECONDS",
ge=0,
le=86_400,
)
access_database_url: str | None = Field(default=None, alias="ACCESS_DATABASE_URL")
access_db_schema: str | None = Field(default=None, alias="ACCESS_DB_SCHEMA")
access_table_prefix: str = Field(default="access_", alias="ACCESS_TABLE_PREFIX")
tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE")
tenant_db_url_template: str | None = Field(default=None, alias="TENANT_DB_URL_TEMPLATE")
tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE")
enabled_modules: str = Field(default="tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,connectors,datasources,dataflow,workflow,views,notifications,docs,ops", alias="ENABLED_MODULES")
enabled_modules: str = Field(default="tenancy,organizations,identity,idm,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,connectors,datasources,dataflow,workflow,views,postbox,notifications,docs,ops", alias="ENABLED_MODULES")
migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK")
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
@@ -47,6 +75,11 @@ class Settings(BaseSettings):
auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE")
auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN")
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS")
auth_activity_touch_interval_seconds: int = Field(
default=5 * 60,
ge=0,
alias="AUTH_ACTIVITY_TOUCH_INTERVAL_SECONDS",
)
auth_login_throttle_enabled: bool = Field(default=True, alias="AUTH_LOGIN_THROTTLE_ENABLED")
auth_login_throttle_identity_limit: int = Field(
default=10,

View File

@@ -46,12 +46,24 @@ def assert_tenant_governance_override_allowed(session: Session, *, field: str, v
raise AdminValidationError("Tenant governance cannot explicitly allow a capability denied by system settings.")
def _tenant_module_counts(session: Session, tenant_id: str) -> dict[str, int]:
def _tenant_module_counts(
session: Session,
tenant_id: str,
*,
module_ids: tuple[str, ...] | None = None,
) -> dict[str, int]:
registry = get_registry()
if registry is None or not hasattr(registry, "tenant_summary_providers"):
return {}
counts: dict[str, int] = {}
for provider in registry.tenant_summary_providers().values():
providers = registry.tenant_summary_providers()
if module_ids is not None:
providers = {
module_id: provider
for module_id, provider in providers.items()
if module_id in module_ids
}
for provider in providers.values():
provided = provider(session, tenant_id)
counts.update({str(key): int(value) for key, value in provided.items()})
return counts
@@ -67,17 +79,27 @@ def _access_administration() -> AccessAdministration | None:
return capability
def tenant_counts(session: Session, tenant_id: str) -> dict[str, int]:
module_counts = _tenant_module_counts(session, tenant_id)
def tenant_counts(
session: Session,
tenant_id: str,
*,
module_ids: tuple[str, ...] | None = None,
) -> dict[str, int]:
module_counts = _tenant_module_counts(
session,
tenant_id,
module_ids=module_ids,
)
access_administration = _access_administration()
access_counts = access_administration.tenant_counts(session, tenant_id) if access_administration is not None else {}
return {
**module_counts,
"users": int(access_counts.get("users", 0)),
"active_users": int(access_counts.get("active_users", 0)),
"groups": int(access_counts.get("groups", 0)),
"campaigns": module_counts.get("campaigns", 0),
"files": module_counts.get("files", 0),
"campaigns": int(module_counts.get("campaigns", 0)),
"files": int(module_counts.get("files", 0)),
"api_keys": int(access_counts.get("api_keys", 0)),
"active_api_keys": int(access_counts.get("active_api_keys", access_counts.get("api_keys", 0))),
}