chore: consolidate platform split checks
Some checks failed
Dependency Audit / dependency-audit (push) Has been cancelled
Module Matrix / module-matrix (push) Has been cancelled

This commit is contained in:
2026-07-10 12:51:19 +02:00
parent 150b720f12
commit 635d25c74c
216 changed files with 23336 additions and 4077 deletions

View File

@@ -9,7 +9,7 @@ from govoplan_core.db.base import Base, TimestampMixin
class SystemSettings(Base, TimestampMixin):
__tablename__ = "system_settings"
__tablename__ = "core_system_settings"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default="global")
default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False)

View File

@@ -24,6 +24,21 @@ class AuditLogListResponse(BaseModel):
items: list[AuditLogItemResponse]
class DeltaDeletedItem(BaseModel):
id: str
resource_type: str | None = None
revision: str | None = None
deleted_at: datetime | None = None
class DeltaCollectionResponse(BaseModel):
items: list[dict[str, Any]] = Field(default_factory=list)
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
watermark: str | None = None
has_more: bool = False
full: bool = False
class LoginRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -46,6 +61,7 @@ class TenantInfo(BaseModel):
name: str
is_active: bool = True
default_locale: str = "en"
enabled_language_codes: list[str] = Field(default_factory=list)
class TenantMembershipInfo(TenantInfo):
@@ -53,6 +69,16 @@ class TenantMembershipInfo(TenantInfo):
is_active: bool = True
class UserUiPreferences(BaseModel):
model_config = ConfigDict(extra="ignore")
compact_tables: bool = False
show_inline_help_hints: bool = True
reduce_motion: bool = False
sticky_section_sidebars: bool = True
theme: Literal["system", "light", "dark"] = "system"
class UserInfo(BaseModel):
id: str
account_id: str
@@ -63,6 +89,9 @@ class UserInfo(BaseModel):
tenant_display_name: str | None = None
is_tenant_admin: bool = False
password_reset_required: bool = False
preferred_language: str | None = None
enabled_language_codes: list[str] = Field(default_factory=list)
ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences)
class ProfileUpdateRequest(BaseModel):
@@ -70,6 +99,15 @@ class ProfileUpdateRequest(BaseModel):
display_name: str | None = Field(default=None, max_length=255)
tenant_display_name: str | None = Field(default=None, max_length=255)
preferred_language: str | None = Field(default=None, max_length=20)
enabled_language_codes: list[str] | None = None
ui_preferences: UserUiPreferences | None = None
class LanguageInfo(BaseModel):
code: str
label: str
native_label: str | None = None
class RoleInfo(BaseModel):
@@ -86,6 +124,25 @@ class GroupInfo(BaseModel):
name: str
class PrincipalContextInfo(BaseModel):
account_id: str
membership_id: str | None = None
tenant_id: str | None = None
identity_id: str | None = None
scopes: list[str] = Field(default_factory=list)
group_ids: list[str] = Field(default_factory=list)
role_ids: list[str] = Field(default_factory=list)
function_assignment_ids: list[str] = Field(default_factory=list)
delegation_ids: list[str] = Field(default_factory=list)
auth_method: Literal["session", "api_key", "service_account"] = "session"
api_key_id: str | None = None
session_id: str | None = None
service_account_id: str | None = None
acting_for_account_id: str | None = None
email: str | None = None
display_name: str | None = None
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
@@ -98,6 +155,10 @@ class LoginResponse(BaseModel):
scopes: list[str]
roles: list[RoleInfo] = Field(default_factory=list)
groups: list[GroupInfo] = Field(default_factory=list)
principal: PrincipalContextInfo | None = None
available_languages: list[LanguageInfo] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list)
default_language: str = "en"
class MeResponse(BaseModel):
@@ -109,3 +170,7 @@ class MeResponse(BaseModel):
scopes: list[str]
roles: list[RoleInfo] = Field(default_factory=list)
groups: list[GroupInfo] = Field(default_factory=list)
principal: PrincipalContextInfo | None = None
available_languages: list[LanguageInfo] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list)
default_language: str = "en"

View File

@@ -1,14 +1,21 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from sqlalchemy.orm import Session
from govoplan_access.backend.auth.dependencies import ApiPrincipal
from govoplan_access.auth import ApiPrincipal
from govoplan_core.core.change_sequence import record_change
from govoplan_core.core.events import current_event_trace, normalize_trace_id
from govoplan_core.privacy.retention import sanitize_audit_details_for_policy
from govoplan_audit.backend.db.models import AuditLog
AUDIT_MODULE_ID = "audit"
AUDIT_TENANT_EVENTS_COLLECTION = "audit.admin.tenant_events"
AUDIT_SYSTEM_EVENTS_COLLECTION = "audit.admin.system_events"
SENSITIVE_DETAIL_KEYS = {
"password",
"smtp_password",
@@ -16,9 +23,40 @@ SENSITIVE_DETAIL_KEYS = {
"secret",
"api_key",
"token",
"access_token",
"refresh_token",
"authorization",
"cookie",
"credentials",
"credential",
"private_key",
"claim_token",
"build_token",
}
def _optional_text(value: object | None) -> str | None:
if value is None:
return None
candidate = str(value).strip()
return candidate or None
def _compact_trace(trace: Mapping[str, object] | None) -> dict[str, str]:
if not isinstance(trace, Mapping):
return {}
compact: dict[str, str] = {}
correlation_id = _optional_text(trace.get("correlation_id"))
causation_id = _optional_text(trace.get("causation_id"))
clean_correlation_id = normalize_trace_id(correlation_id)
clean_causation_id = normalize_trace_id(causation_id)
if clean_correlation_id:
compact["correlation_id"] = clean_correlation_id
if clean_causation_id:
compact["causation_id"] = clean_causation_id
return compact
def _sanitize_details(value: Any) -> Any:
if isinstance(value, dict):
sanitized: dict[str, Any] = {}
@@ -33,6 +71,70 @@ def _sanitize_details(value: Any) -> Any:
return value
def audit_operation_context(
*,
module_id: object | None = None,
request_id: object | None = None,
run_id: object | None = None,
outcome: object | None = None,
trace: Mapping[str, object] | None = None,
**details: Any,
) -> dict[str, Any]:
"""Build concise operational audit details for admin and lifecycle actions."""
payload: dict[str, Any] = {}
for key, value in (
("module_id", module_id),
("request_id", request_id),
("run_id", run_id),
("outcome", outcome),
):
text = _optional_text(value)
if text is not None:
payload[key] = text
for key, value in details.items():
if value is not None:
payload[key] = _sanitize_details(value)
compact_trace = _compact_trace(trace)
if compact_trace:
existing = payload.get("_trace")
payload["_trace"] = {**existing, **compact_trace} if isinstance(existing, dict) else compact_trace
return payload
def _trace_details(
details: dict[str, Any],
*,
correlation_id: str | None = None,
causation_id: str | None = None,
) -> tuple[dict[str, Any], dict[str, str]]:
active_trace = current_event_trace()
trace: dict[str, str] = {}
clean_correlation_id = normalize_trace_id(correlation_id)
clean_causation_id = normalize_trace_id(causation_id)
if clean_correlation_id or active_trace:
trace["correlation_id"] = clean_correlation_id or active_trace.correlation_id
if clean_causation_id or (active_trace and active_trace.causation_id):
trace["causation_id"] = clean_causation_id or str(active_trace.causation_id)
if not trace:
return details, {}
merged = dict(details)
existing = merged.get("_trace")
merged["_trace"] = {**existing, **trace} if isinstance(existing, dict) else trace
return merged, trace
def _restore_trace_after_policy(details: dict[str, Any], trace: dict[str, str]) -> dict[str, Any]:
if not trace:
return details
if details.get("_trace") == trace:
return details
restored = dict(details)
existing = restored.get("_trace")
restored["_trace"] = {**existing, **trace} if isinstance(existing, dict) else trace
return restored
def audit_event(
session: Session,
*,
@@ -44,6 +146,8 @@ def audit_event(
object_type: str | None = None,
object_id: str | None = None,
details: dict[str, Any] | None = None,
correlation_id: str | None = None,
causation_id: str | None = None,
commit: bool = False,
) -> AuditLog:
"""Persist one audit event.
@@ -56,6 +160,16 @@ def audit_event(
if scope not in {"tenant", "system"}:
raise ValueError(f"Unsupported audit scope: {scope}")
traced_details, trace = _trace_details(
_sanitize_details(details or {}),
correlation_id=correlation_id,
causation_id=causation_id,
)
stored_details = _restore_trace_after_policy(
sanitize_audit_details_for_policy(session, traced_details),
trace,
)
item = AuditLog(
scope=scope,
tenant_id=tenant_id,
@@ -64,13 +178,24 @@ def audit_event(
action=action,
object_type=object_type,
object_id=object_id,
details=sanitize_audit_details_for_policy(session, _sanitize_details(details or {})),
details=stored_details,
)
session.add(item)
session.flush()
record_change(
session,
module_id=AUDIT_MODULE_ID,
collection=AUDIT_SYSTEM_EVENTS_COLLECTION if scope == "system" else AUDIT_TENANT_EVENTS_COLLECTION,
resource_type="audit_log",
resource_id=item.id,
operation="created",
tenant_id=None if scope == "system" else tenant_id,
actor_type="user" if user_id else ("api_key" if api_key_id else None),
actor_id=user_id or api_key_id,
payload={"scope": scope, "action": action, "object_type": object_type, "object_id": object_id},
)
if commit:
session.commit()
else:
session.flush()
return item
@@ -83,6 +208,8 @@ def audit_from_principal(
object_type: str | None = None,
object_id: str | None = None,
details: dict[str, Any] | None = None,
correlation_id: str | None = None,
causation_id: str | None = None,
commit: bool = False,
) -> AuditLog:
return audit_event(
@@ -95,5 +222,7 @@ def audit_from_principal(
object_type=object_type,
object_id=object_id,
details=details,
correlation_id=correlation_id,
causation_id=causation_id,
commit=commit,
)

View File

@@ -27,6 +27,7 @@ from govoplan_core.core.module_installer import (
update_module_installer_request,
update_module_installer_daemon_status,
)
from govoplan_core.core.module_license import issue_module_license, module_license_diagnostics
from govoplan_core.core.module_package_catalog import sign_module_package_catalog, validate_module_package_catalog
from govoplan_core.core.module_management import (
configured_enabled_modules,
@@ -80,10 +81,69 @@ def main() -> int:
parser.add_argument("--catalog-signing-key-id", help="Key id to record when signing a package catalog.")
parser.add_argument("--catalog-signing-private-key", type=Path, help="PEM Ed25519 private key used by --sign-package-catalog.")
parser.add_argument("--catalog-output", type=Path, help="Output path for --sign-package-catalog. Defaults to overwriting the input file.")
parser.add_argument("--validate-license", nargs="?", const="", metavar="PATH", help="Validate a license JSON file, or the configured license when PATH is omitted.")
parser.add_argument("--require-trusted-license", action="store_true", help="Require license validation to trust an Ed25519 signature.")
parser.add_argument("--license-trusted-key", action="append", default=[], metavar="KEY_ID=BASE64_PUBLIC_KEY", help="Trusted Ed25519 license public key; may be repeated.")
parser.add_argument("--license-required-feature", action="append", default=[], help="License feature to check; may be repeated.")
parser.add_argument("--issue-license", type=Path, metavar="PATH", help="Write a signed offline license JSON file.")
parser.add_argument("--license-id", help="License id for --issue-license.")
parser.add_argument("--license-subject", help="License subject/customer for --issue-license.")
parser.add_argument("--license-feature", action="append", default=[], help="Feature entitlement to include in --issue-license; may be repeated.")
parser.add_argument("--license-valid-from", help="License valid_from timestamp. Defaults to now.")
parser.add_argument("--license-valid-until", help="License valid_until timestamp.")
parser.add_argument("--license-signing-key-id", help="Signing key id for --issue-license.")
parser.add_argument("--license-signing-private-key", type=Path, help="PEM Ed25519 private key for --issue-license.")
parser.add_argument("--license-issuer", help="Optional issuer string for --issue-license.")
parser.add_argument("--license-notes", help="Optional operator note for --issue-license.")
args = parser.parse_args()
runtime_dir = args.runtime_dir or default_installer_runtime_dir(args.database_url)
try:
if args.issue_license:
if not args.license_id or not args.license_subject or not args.license_valid_until:
raise ModuleInstallerError("--issue-license requires --license-id, --license-subject, and --license-valid-until.")
if not args.license_signing_key_id or not args.license_signing_private_key:
raise ModuleInstallerError("--issue-license requires --license-signing-key-id and --license-signing-private-key.")
if not [item for item in args.license_feature if item.strip()]:
raise ModuleInstallerError("--issue-license requires at least one --license-feature.")
try:
path = issue_module_license(
path=args.issue_license,
license_id=args.license_id,
subject=args.license_subject,
features=args.license_feature,
valid_from=args.license_valid_from,
valid_until=args.license_valid_until,
signing_key_id=args.license_signing_key_id,
signing_private_key_path=args.license_signing_private_key,
issuer=args.license_issuer,
notes=args.license_notes,
)
except (OSError, ValueError) as exc:
raise ModuleInstallerError(str(exc)) from exc
_print_result(
{
"issued": True,
"path": str(path),
"license_id": args.license_id,
"subject": args.license_subject,
"features": list(dict.fromkeys(item.strip() for item in args.license_feature if item.strip())),
"valid_until": args.license_valid_until,
"key_id": args.license_signing_key_id,
},
output_format=args.format,
)
return 0
if args.validate_license is not None:
path = Path(args.validate_license).expanduser() if args.validate_license else None
result = module_license_diagnostics(
path,
require_trusted=args.require_trusted_license,
trusted_keys=_trusted_keys_from_args(args.license_trusted_key, flag_name="--license-trusted-key"),
required_features=args.license_required_feature,
)
_print_result(result, output_format=args.format)
return 0 if result.get("valid") and not result.get("missing_features") else 1
if args.sign_package_catalog:
if not args.catalog_signing_key_id or not args.catalog_signing_private_key:
raise ModuleInstallerError("--sign-package-catalog requires --catalog-signing-key-id and --catalog-signing-private-key.")
@@ -101,7 +161,7 @@ def main() -> int:
path,
require_trusted=args.require_signed_catalog,
approved_channels=tuple(args.approved_catalog_channel),
trusted_keys=_trusted_catalog_keys_from_args(args.catalog_trusted_key),
trusted_keys=_trusted_keys_from_args(args.catalog_trusted_key, flag_name="--catalog-trusted-key"),
)
_print_result(result, output_format=args.format)
return 0 if result.get("valid") else 1
@@ -322,6 +382,7 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
health_urls=_list_option(options, "health_urls") or _list_option(options, "health_url") or ([args.health_url] if args.health_url else []),
health_timeout_seconds=_float_option(options, "health_timeout_seconds", args.health_timeout_seconds),
health_interval_seconds=_float_option(options, "health_interval_seconds", args.health_interval_seconds),
request_context=_request_context(request),
)
update_module_installer_request(
runtime_dir=runtime_dir,
@@ -362,6 +423,18 @@ def _request_options_from_args(args: argparse.Namespace) -> dict[str, object]:
}
def _request_context(request: dict[str, object]) -> dict[str, object]:
context: dict[str, object] = {
"request_id": request.get("request_id"),
"requested_by": request.get("requested_by"),
}
if request.get("retry_of"):
context["retry_of"] = request["retry_of"]
if isinstance(request.get("trace"), dict):
context["trace"] = request["trace"]
return {key: value for key, value in context.items() if value is not None}
def _str_option(options: object, key: str) -> str | None:
if not isinstance(options, dict):
return None
@@ -403,14 +476,14 @@ def _list_option(options: object, key: str) -> list[str]:
return []
def _trusted_catalog_keys_from_args(values: list[str]) -> dict[str, str] | None:
def _trusted_keys_from_args(values: list[str], *, flag_name: str) -> dict[str, str] | None:
if not values:
return None
keys: dict[str, str] = {}
for value in values:
key_id, separator, public_key = value.partition("=")
if not separator or not key_id.strip() or not public_key.strip():
raise ModuleInstallerError("--catalog-trusted-key must use KEY_ID=BASE64_PUBLIC_KEY.")
raise ModuleInstallerError(f"{flag_name} must use KEY_ID=BASE64_PUBLIC_KEY.")
keys[key_id.strip()] = public_key.strip()
return keys

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
from typing import Literal, Protocol, cast, runtime_checkable
from govoplan_core.core.modules import AccessDecision
@@ -17,11 +17,12 @@ CAPABILITY_ACCESS_PRINCIPAL_RESOLVER = "access.principalResolver"
CAPABILITY_ACCESS_DIRECTORY = "access.directory"
CAPABILITY_ACCESS_PERMISSION_EVALUATOR = "access.permissionEvaluator"
CAPABILITY_ACCESS_RESOURCE_ACCESS = "access.resourceAccess"
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY = "access.semanticDirectory"
CAPABILITY_ACCESS_EXPLANATION = "access.explanation"
CAPABILITY_ACCESS_TENANT_PROVISIONER = "access.tenantProvisioner"
CAPABILITY_ACCESS_ADMINISTRATION = "access.administration"
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER = "access.governanceMaterializer"
CAPABILITY_TENANCY_TENANT_RESOLVER = "tenancy.tenantResolver"
CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider"
CAPABILITY_AUDIT_SINK = "audit.sink"
ACCESS_CAPABILITY_NAMES = frozenset(
@@ -30,20 +31,47 @@ ACCESS_CAPABILITY_NAMES = frozenset(
CAPABILITY_ACCESS_DIRECTORY,
CAPABILITY_ACCESS_PERMISSION_EVALUATOR,
CAPABILITY_ACCESS_RESOURCE_ACCESS,
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
CAPABILITY_ACCESS_EXPLANATION,
CAPABILITY_ACCESS_TENANT_PROVISIONER,
CAPABILITY_ACCESS_ADMINISTRATION,
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
CAPABILITY_TENANCY_TENANT_RESOLVER,
CAPABILITY_SECURITY_SECRET_PROVIDER,
CAPABILITY_AUDIT_SINK,
}
)
AuthMethod = Literal["session", "api_key", "service_account"]
AccessSubjectKind = Literal["account", "user", "group", "role", "tenant", "api_key", "service_account"]
AccessSubjectKind = Literal[
"identity",
"account",
"user",
"group",
"role",
"tenant",
"organization_unit",
"function",
"api_key",
"service_account",
]
AccessStatus = Literal["active", "inactive", "suspended"]
PermissionLevel = Literal["system", "tenant"]
AuditOutcome = Literal["success", "failure", "denied", "unknown"]
FunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
FunctionDelegationMode = Literal["delegate", "act_in_place"]
AccessProvenanceKind = Literal[
"identity",
"account",
"tenant_membership",
"organization_unit",
"function",
"group",
"role",
"right",
"delegation",
"policy",
"system_actor",
]
@dataclass(frozen=True, slots=True)
@@ -51,15 +79,75 @@ class PrincipalRef:
account_id: str
membership_id: str | None
tenant_id: str | None
identity_id: str | None = None
scopes: frozenset[str] = field(default_factory=frozenset)
group_ids: frozenset[str] = field(default_factory=frozenset)
role_ids: frozenset[str] = field(default_factory=frozenset)
function_assignment_ids: frozenset[str] = field(default_factory=frozenset)
delegation_ids: frozenset[str] = field(default_factory=frozenset)
auth_method: AuthMethod = "session"
api_key_id: str | None = None
session_id: str | None = None
service_account_id: str | None = None
acting_for_account_id: str | None = None
email: str | None = None
display_name: str | None = None
def to_dict(self) -> dict[str, object]:
return {
"account_id": self.account_id,
"membership_id": self.membership_id,
"tenant_id": self.tenant_id,
"identity_id": self.identity_id,
"scopes": sorted(self.scopes),
"group_ids": sorted(self.group_ids),
"role_ids": sorted(self.role_ids),
"function_assignment_ids": sorted(self.function_assignment_ids),
"delegation_ids": sorted(self.delegation_ids),
"auth_method": self.auth_method,
"api_key_id": self.api_key_id,
"session_id": self.session_id,
"service_account_id": self.service_account_id,
"acting_for_account_id": self.acting_for_account_id,
"email": self.email,
"display_name": self.display_name,
}
@classmethod
def from_mapping(cls, value: Mapping[str, object]) -> PrincipalRef:
return cls(
account_id=str(value["account_id"]),
membership_id=_optional_str(value.get("membership_id")),
tenant_id=_optional_str(value.get("tenant_id")),
identity_id=_optional_str(value.get("identity_id")),
scopes=_string_frozenset(value.get("scopes")),
group_ids=_string_frozenset(value.get("group_ids")),
role_ids=_string_frozenset(value.get("role_ids")),
function_assignment_ids=_string_frozenset(value.get("function_assignment_ids")),
delegation_ids=_string_frozenset(value.get("delegation_ids")),
auth_method=cast(AuthMethod, str(value.get("auth_method") or "session")),
api_key_id=_optional_str(value.get("api_key_id")),
session_id=_optional_str(value.get("session_id")),
service_account_id=_optional_str(value.get("service_account_id")),
acting_for_account_id=_optional_str(value.get("acting_for_account_id")),
email=_optional_str(value.get("email")),
display_name=_optional_str(value.get("display_name")),
)
def _optional_str(value: object | None) -> str | None:
return str(value) if value is not None else None
def _string_frozenset(value: object | None) -> frozenset[str]:
if value is None:
return frozenset()
if isinstance(value, str):
return frozenset({value})
if isinstance(value, Iterable):
return frozenset(str(item) for item in value)
raise TypeError("PrincipalRef collection fields must be strings or iterables")
@dataclass(frozen=True, slots=True)
class AccountRef:
@@ -87,6 +175,15 @@ class UserRef:
status: AccessStatus = "active"
@dataclass(frozen=True, slots=True)
class IdentityRef:
id: str
display_name: str | None = None
primary_account_id: str | None = None
account_ids: tuple[str, ...] = ()
status: AccessStatus = "active"
@dataclass(frozen=True, slots=True)
class GroupRef:
id: str
@@ -105,6 +202,79 @@ class RoleRef:
protected: bool = False
@dataclass(frozen=True, slots=True)
class OrganizationUnitRef:
id: str
tenant_id: str
name: str
parent_id: str | None = None
status: AccessStatus = "active"
@dataclass(frozen=True, slots=True)
class FunctionRef:
id: str
tenant_id: str
organization_unit_id: str
slug: str
name: str
role_ids: tuple[str, ...] = ()
delegable: bool = False
act_in_place_allowed: bool = False
status: AccessStatus = "active"
@dataclass(frozen=True, slots=True)
class FunctionAssignmentRef:
id: str
tenant_id: str
account_id: str
function_id: str
organization_unit_id: str
identity_id: str | None = None
applies_to_subunits: bool = False
source: FunctionAssignmentSource = "direct"
delegated_from_assignment_id: str | None = None
acting_for_account_id: str | None = None
valid_from: datetime | None = None
valid_until: datetime | None = None
status: AccessStatus = "active"
@dataclass(frozen=True, slots=True)
class FunctionDelegationRef:
id: str
tenant_id: str
function_assignment_id: str
delegator_account_id: str
delegate_account_id: str
mode: FunctionDelegationMode = "delegate"
reason: str | None = None
valid_from: datetime | None = None
valid_until: datetime | None = None
status: AccessStatus = "active"
@dataclass(frozen=True, slots=True)
class AccessDecisionProvenance:
kind: AccessProvenanceKind
id: str | None = None
label: str | None = None
tenant_id: str | None = None
source: str | None = None
details: Mapping[str, object] = field(default_factory=dict)
def to_dict(self) -> dict[str, object]:
return {
"kind": self.kind,
"id": self.id,
"label": self.label,
"tenant_id": self.tenant_id,
"source": self.source,
"details": dict(self.details),
}
@dataclass(frozen=True, slots=True)
class AccessSubjectRef:
kind: AccessSubjectKind
@@ -143,6 +313,8 @@ class AuditEvent:
resource_type: str | None = None
resource_id: str | None = None
occurred_at: datetime | None = None
correlation_id: str | None = None
causation_id: str | None = None
details: Mapping[str, object] = field(default_factory=dict)
@@ -191,6 +363,43 @@ class AccessDirectory(Protocol):
...
@runtime_checkable
class AccessSemanticDirectory(AccessDirectory, Protocol):
def get_identity(self, identity_id: str) -> IdentityRef | None:
...
def accounts_for_identity(self, identity_id: str) -> Sequence[AccountRef]:
...
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
...
def organization_units_for_tenant(self, tenant_id: str) -> Sequence[OrganizationUnitRef]:
...
def get_function(self, function_id: str) -> FunctionRef | None:
...
def functions_for_organization_unit(
self,
organization_unit_id: str,
*,
include_subunits: bool = False,
) -> Sequence[FunctionRef]:
...
def get_function_assignment(self, assignment_id: str) -> FunctionAssignmentRef | None:
...
def function_assignments_for_account(
self,
account_id: str,
*,
tenant_id: str | None = None,
) -> Sequence[FunctionAssignmentRef]:
...
@runtime_checkable
class PermissionEvaluator(Protocol):
def scopes_grant(self, scopes: Iterable[str], required_scope: str) -> bool:
@@ -209,6 +418,26 @@ class ResourceAccessService(Protocol):
...
@runtime_checkable
class AccessExplanationService(Protocol):
def explain_scope_provenance(
self,
principal: PrincipalRef,
required_scope: str,
) -> Sequence[AccessDecisionProvenance]:
...
def explain_resource_provenance(
self,
principal: PrincipalRef,
*,
resource_type: str,
resource_id: str,
action: str,
) -> Sequence[AccessDecisionProvenance]:
...
@runtime_checkable
class TenantAccessProvisioner(Protocol):
def ensure_default_roles(self, session: object, tenant: object | None = None) -> Mapping[str, object]:
@@ -254,18 +483,6 @@ class AccessGovernanceMaterializer(Protocol):
...
@runtime_checkable
class SecretProvider(Protocol):
def store_secret(self, *, scope: str, name: str, value: str) -> str:
...
def read_secret(self, secret_ref: str) -> str | None:
...
def delete_secret(self, secret_ref: str) -> None:
...
@runtime_checkable
class AuditSink(Protocol):
def record(self, event: AuditEvent) -> None:

View File

@@ -0,0 +1,253 @@
from __future__ import annotations
from datetime import datetime
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.db.base import Base, utcnow
WATERMARK_PREFIX = "seq:"
class ChangeSequenceEntry(Base):
__tablename__ = "core_change_sequence"
__table_args__ = (
Index("ix_core_change_sequence_tenant_module_id", "tenant_id", "module_id", "id"),
Index("ix_core_change_sequence_collection_id", "collection", "id"),
Index("ix_core_change_sequence_resource", "module_id", "resource_type", "resource_id"),
)
id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
module_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
collection: Mapped[str] = mapped_column(String(150), nullable=False, index=True)
resource_type: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
resource_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
operation: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
actor_type: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False, index=True)
class ChangeSequenceRetentionFloor(Base):
__tablename__ = "core_change_sequence_retention_floor"
__table_args__ = (
UniqueConstraint("tenant_key", "module_id", "collection", name="uq_core_change_sequence_retention_scope"),
Index("ix_core_change_sequence_retention_scope", "tenant_key", "module_id", "collection"),
)
id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True)
tenant_key: Mapped[str] = mapped_column(String(36), nullable=False, default="")
module_id: Mapped[str] = mapped_column(String(100), nullable=False)
collection: Mapped[str] = mapped_column(String(150), nullable=False)
min_valid_sequence: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), nullable=False, default=0)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False)
def _tenant_key(tenant_id: str | None) -> str:
return tenant_id or ""
def encode_sequence_watermark(sequence_id: int | None) -> str:
return f"{WATERMARK_PREFIX}{int(sequence_id or 0)}"
def decode_sequence_watermark(value: str | None) -> int:
if value is None or not value.strip():
return 0
raw = value.strip()
if raw.startswith(WATERMARK_PREFIX):
raw = raw[len(WATERMARK_PREFIX):]
try:
sequence_id = int(raw)
except ValueError as exc:
raise ValueError("Invalid delta watermark") from exc
if sequence_id < 0:
raise ValueError("Invalid delta watermark")
return sequence_id
def record_change(
session: Session,
*,
module_id: str,
collection: str,
resource_type: str,
resource_id: str,
operation: str,
tenant_id: str | None = None,
actor_type: str | None = None,
actor_id: str | None = None,
payload: dict[str, Any] | None = None,
) -> ChangeSequenceEntry:
entry = ChangeSequenceEntry(
tenant_id=tenant_id,
module_id=module_id,
collection=collection,
resource_type=resource_type,
resource_id=resource_id,
operation=operation,
actor_type=actor_type,
actor_id=actor_id,
payload=payload or {},
)
session.add(entry)
return entry
def max_sequence_id(
session: Session,
*,
tenant_id: str | None = None,
module_id: str | None = None,
collections: Sequence[str] | None = None,
) -> int:
query = session.query(func.max(ChangeSequenceEntry.id))
if tenant_id is not None:
query = query.filter(ChangeSequenceEntry.tenant_id == tenant_id)
if module_id is not None:
query = query.filter(ChangeSequenceEntry.module_id == module_id)
if collections:
query = query.filter(ChangeSequenceEntry.collection.in_(tuple(collections)))
return int(query.scalar() or 0)
def min_sequence_id(
session: Session,
*,
tenant_id: str | None = None,
module_id: str | None = None,
collections: Sequence[str] | None = None,
) -> int:
query = session.query(func.min(ChangeSequenceEntry.id))
if tenant_id is not None:
query = query.filter(ChangeSequenceEntry.tenant_id == tenant_id)
if module_id is not None:
query = query.filter(ChangeSequenceEntry.module_id == module_id)
if collections:
query = query.filter(ChangeSequenceEntry.collection.in_(tuple(collections)))
return int(query.scalar() or 0)
def sequence_watermark_is_expired(
session: Session,
*,
since: int,
tenant_id: str | None = None,
module_id: str | None = None,
collections: Sequence[str] | None = None,
) -> bool:
floor = retained_sequence_floor(
session,
tenant_id=tenant_id,
module_id=module_id,
collections=collections,
)
return floor > 0 and since < floor
def retained_sequence_floor(
session: Session,
*,
tenant_id: str | None = None,
module_id: str | None = None,
collections: Sequence[str] | None = None,
) -> int:
query = session.query(func.max(ChangeSequenceRetentionFloor.min_valid_sequence))
query = query.filter(ChangeSequenceRetentionFloor.tenant_key == _tenant_key(tenant_id))
if module_id is not None:
query = query.filter(ChangeSequenceRetentionFloor.module_id == module_id)
if collections:
query = query.filter(ChangeSequenceRetentionFloor.collection.in_(tuple(collections)))
return int(query.scalar() or 0)
def prune_sequence_entries(
session: Session,
*,
before_or_equal_id: int,
tenant_id: str | None = None,
module_id: str | None = None,
collections: Sequence[str] | None = None,
) -> int:
"""Delete old sequence rows and record the new minimum safe watermark.
After pruning rows up to `before_or_equal_id`, clients with watermarks below
that value cannot be replayed safely and should receive a full snapshot.
"""
cutoff = int(before_or_equal_id)
if cutoff <= 0:
return 0
query = session.query(ChangeSequenceEntry).filter(ChangeSequenceEntry.id <= cutoff)
if tenant_id is not None:
query = query.filter(ChangeSequenceEntry.tenant_id == tenant_id)
if module_id is not None:
query = query.filter(ChangeSequenceEntry.module_id == module_id)
collection_values = tuple(collections or ())
if collection_values:
query = query.filter(ChangeSequenceEntry.collection.in_(collection_values))
scopes = {
(_tenant_key(entry.tenant_id), entry.module_id, entry.collection)
for entry in query.with_entities(
ChangeSequenceEntry.tenant_id,
ChangeSequenceEntry.module_id,
ChangeSequenceEntry.collection,
).distinct()
}
deleted = query.delete(synchronize_session=False)
for tenant_key, scoped_module_id, collection in scopes:
floor = session.query(ChangeSequenceRetentionFloor).filter(
ChangeSequenceRetentionFloor.tenant_key == tenant_key,
ChangeSequenceRetentionFloor.module_id == scoped_module_id,
ChangeSequenceRetentionFloor.collection == collection,
).one_or_none()
if floor is None:
floor = ChangeSequenceRetentionFloor(
tenant_key=tenant_key,
module_id=scoped_module_id,
collection=collection,
min_valid_sequence=cutoff,
)
else:
floor.min_valid_sequence = max(int(floor.min_valid_sequence or 0), cutoff)
session.add(floor)
return int(deleted)
def sequence_entries_since(
session: Session,
*,
since: int,
tenant_id: str | None = None,
module_id: str | None = None,
collections: Iterable[str] | None = None,
limit: int = 500,
) -> list[ChangeSequenceEntry]:
query = session.query(ChangeSequenceEntry).filter(ChangeSequenceEntry.id > since)
if tenant_id is not None:
query = query.filter(ChangeSequenceEntry.tenant_id == tenant_id)
if module_id is not None:
query = query.filter(ChangeSequenceEntry.module_id == module_id)
collection_values = tuple(collections or ())
if collection_values:
query = query.filter(ChangeSequenceEntry.collection.in_(collection_values))
return query.order_by(ChangeSequenceEntry.id.asc()).limit(max(1, limit)).all()
__all__ = [
"ChangeSequenceEntry",
"ChangeSequenceRetentionFloor",
"decode_sequence_watermark",
"encode_sequence_watermark",
"max_sequence_id",
"min_sequence_id",
"prune_sequence_entries",
"record_change",
"retained_sequence_floor",
"sequence_entries_since",
"sequence_watermark_is_expired",
]

View File

@@ -0,0 +1,418 @@
from __future__ import annotations
import hashlib
import json
import uuid
from dataclasses import dataclass
from typing import Any
from sqlalchemy.orm import Session
from govoplan_core.admin.settings import get_system_settings
from govoplan_core.core.change_sequence import record_change
from govoplan_core.core.configuration_safety import classify_configuration_field, plan_configuration_change
from govoplan_core.core.maintenance import saved_maintenance_mode
from govoplan_core.security.time import utc_now
CONFIGURATION_CONTROL_SETTINGS_KEY = "_configuration_control"
CONFIGURATION_CONTROL_MODULE_ID = "core"
CONFIGURATION_CHANGES_COLLECTION = "core.configuration_changes"
CONFIGURATION_CHANGE_REQUEST_RESOURCE = "configuration_change_request"
CONFIGURATION_CHANGE_RECORD_RESOURCE = "configuration_change_record"
MAX_CONFIGURATION_CHANGE_HISTORY = 200
MAX_CONFIGURATION_CHANGE_REQUESTS = 200
class ConfigurationControlError(Exception):
def __init__(self, message: str, *, code: str = "configuration_change_blocked", plan: dict[str, Any] | None = None) -> None:
super().__init__(message)
self.code = code
self.plan = plan or {}
@dataclass(frozen=True, slots=True)
class ConfigurationChangeApproval:
request: dict[str, Any] | None
plan: dict[str, Any]
def configuration_value_digest(value: object) -> str:
payload = json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def configuration_control_snapshot(session: Session) -> dict[str, list[dict[str, Any]]]:
item = get_system_settings(session)
control = _control_payload(item.settings or {})
return {
"requests": list(control.get("requests", [])),
"history": list(control.get("history", [])),
}
def create_configuration_change_request(
session: Session,
*,
key: str,
value: object,
actor_user_id: str,
actor_scopes: tuple[str, ...] | list[str],
dry_run: bool,
target: dict[str, Any] | None = None,
reason: str | None = None,
) -> dict[str, Any]:
field = classify_configuration_field(key)
if field is None:
raise ConfigurationControlError(
"This setting is not in the configuration safety catalog.",
code="unknown_configuration_field",
plan=plan_configuration_change(key, actor_scopes=actor_scopes, value=value).to_dict(),
)
if not field.ui_managed:
raise ConfigurationControlError(
"This setting is deployment-managed and cannot be edited through the UI.",
code="deployment_managed",
plan=plan_configuration_change(key, actor_scopes=actor_scopes, value=value).to_dict(),
)
item = get_system_settings(session)
settings = dict(item.settings or {})
control = _control_payload(settings)
now = _now()
request = {
"id": f"cfgreq-{uuid.uuid4().hex}",
"key": field.key,
"label": field.label,
"target": dict(target or {}),
"value_hash": configuration_value_digest(value),
"value_preview": _sanitize_value(field.key, value),
"dry_run": bool(dry_run),
"requested_by": actor_user_id,
"requested_at": now,
"updated_at": now,
"reason": reason.strip() if isinstance(reason, str) and reason.strip() else None,
"status": "pending_approval" if field.two_person_approval_required else "approved",
"approvals": [
{
"user_id": actor_user_id,
"approved_at": now,
"role": "requester",
}
],
"plan": plan_configuration_change(
field.key,
actor_scopes=actor_scopes,
value=value,
dry_run=bool(dry_run),
maintenance_mode=saved_maintenance_mode(session).enabled,
approval_count=1,
include_env_only=False,
).to_dict(),
}
control["requests"] = _trim_requests([request, *list(control.get("requests", []))])
settings[CONFIGURATION_CONTROL_SETTINGS_KEY] = control
item.settings = settings
session.add(item)
session.flush()
_record_configuration_control_change(
session,
resource_type=CONFIGURATION_CHANGE_REQUEST_RESOURCE,
resource_id=str(request["id"]),
operation="created",
actor_user_id=actor_user_id,
payload={"key": field.key, "status": request["status"]},
)
return dict(request)
def approve_configuration_change_request(
session: Session,
*,
request_id: str,
actor_user_id: str,
actor_scopes: tuple[str, ...] | list[str],
reason: str | None = None,
) -> dict[str, Any]:
item = get_system_settings(session)
settings = dict(item.settings or {})
control = _control_payload(settings)
requests = list(control.get("requests", []))
index, request = _find_request(requests, request_id)
if request is None:
raise ConfigurationControlError("Configuration change request not found.", code="configuration_request_not_found")
if request.get("status") in {"applied", "rejected"}:
raise ConfigurationControlError("Configuration change request is no longer approvable.", code="configuration_request_closed")
if request.get("requested_by") == actor_user_id:
raise ConfigurationControlError("The requester cannot be the second approver.", code="second_approver_required")
approvals = list(request.get("approvals", []))
if not any(item.get("user_id") == actor_user_id for item in approvals if isinstance(item, dict)):
approvals.append(
{
"user_id": actor_user_id,
"approved_at": _now(),
"role": "approver",
"reason": reason.strip() if isinstance(reason, str) and reason.strip() else None,
}
)
request = dict(request)
request["approvals"] = approvals
request["updated_at"] = _now()
request["status"] = "approved" if _approval_count(request) >= 2 else "pending_approval"
request["plan"] = plan_configuration_change(
str(request.get("key") or ""),
actor_scopes=actor_scopes,
value=request.get("value_preview"),
dry_run=bool(request.get("dry_run")),
maintenance_mode=saved_maintenance_mode(session).enabled,
approval_count=_approval_count(request),
include_env_only=False,
).to_dict()
requests[index] = request
control["requests"] = _trim_requests(requests)
settings[CONFIGURATION_CONTROL_SETTINGS_KEY] = control
item.settings = settings
session.add(item)
session.flush()
_record_configuration_control_change(
session,
resource_type=CONFIGURATION_CHANGE_REQUEST_RESOURCE,
resource_id=str(request["id"]),
operation="updated",
actor_user_id=actor_user_id,
payload={"key": request.get("key"), "status": request.get("status")},
)
return dict(request)
def ensure_configuration_change_allowed(
session: Session,
*,
key: str,
value: object,
actor_user_id: str,
actor_scopes: tuple[str, ...] | list[str],
change_request_id: str | None = None,
target: dict[str, Any] | None = None,
dry_run: bool = False,
) -> ConfigurationChangeApproval:
field = classify_configuration_field(key)
if field is None:
plan = plan_configuration_change(key, actor_scopes=actor_scopes, value=value).to_dict()
raise ConfigurationControlError("This setting is not in the configuration safety catalog.", code="unknown_configuration_field", plan=plan)
request: dict[str, Any] | None = None
approval_count = 0
dry_run_satisfied = dry_run
requires_control = field.ui_managed and (field.dry_run_required or field.two_person_approval_required or field.maintenance_required)
if change_request_id:
request = _request_by_id(session, change_request_id)
if request is None:
raise ConfigurationControlError("Configuration change request not found.", code="configuration_request_not_found")
if request.get("status") in {"applied", "rejected"}:
raise ConfigurationControlError("Configuration change request is closed.", code="configuration_request_closed")
if request.get("key") != field.key:
raise ConfigurationControlError("Configuration change request does not match this setting.", code="configuration_request_mismatch")
if request.get("value_hash") != configuration_value_digest(value):
raise ConfigurationControlError("Configuration change request value does not match this apply payload.", code="configuration_value_mismatch")
approval_count = _approval_count(request)
dry_run_satisfied = bool(request.get("dry_run")) or dry_run_satisfied
elif requires_control:
plan = plan_configuration_change(
field.key,
actor_scopes=actor_scopes,
value=value,
dry_run=dry_run_satisfied,
maintenance_mode=saved_maintenance_mode(session).enabled,
approval_count=approval_count,
include_env_only=False,
).to_dict()
raise ConfigurationControlError(
"This configuration change requires a recorded change request.",
code="configuration_request_required",
plan=plan,
)
plan = plan_configuration_change(
field.key,
actor_scopes=actor_scopes,
value=value,
dry_run=dry_run_satisfied,
maintenance_mode=saved_maintenance_mode(session).enabled,
approval_count=approval_count,
include_env_only=False,
).to_dict()
if plan.get("blockers"):
raise ConfigurationControlError("Configuration change is blocked by safety guardrails.", plan=plan)
return ConfigurationChangeApproval(request=request, plan=plan)
def record_configuration_change_applied(
session: Session,
*,
key: str,
before_value: object,
after_value: object,
actor_user_id: str,
approval: ConfigurationChangeApproval | None = None,
target: dict[str, Any] | None = None,
audit_event: str | None = None,
) -> dict[str, Any]:
item = get_system_settings(session)
settings = dict(item.settings or {})
control = _control_payload(settings)
sequence = int(control.get("sequence") or 0) + 1
request = approval.request if approval else None
record = {
"id": f"cfgchg-{sequence:06d}-{uuid.uuid4().hex[:8]}",
"version": sequence,
"key": key,
"target": dict(target or {}),
"actor_user_id": actor_user_id,
"approval_request_id": request.get("id") if request else None,
"approval_user_ids": [item.get("user_id") for item in request.get("approvals", []) if isinstance(item, dict)] if request else [],
"audit_event": audit_event,
"before": _sanitize_value(key, before_value),
"after": _sanitize_value(key, after_value),
"rollback_value": _sanitize_value(key, before_value),
"plan": approval.plan if approval else plan_configuration_change(key, actor_scopes=(), value=after_value).to_dict(),
"created_at": _now(),
"status": "applied",
}
control["sequence"] = sequence
control["history"] = _trim_history([record, *list(control.get("history", []))])
if request:
requests = list(control.get("requests", []))
index, stored = _find_request(requests, str(request.get("id")))
if stored is not None:
stored = dict(stored)
stored["status"] = "applied"
stored["applied_change_id"] = record["id"]
stored["updated_at"] = _now()
requests[index] = stored
control["requests"] = _trim_requests(requests)
settings[CONFIGURATION_CONTROL_SETTINGS_KEY] = control
item.settings = settings
session.add(item)
session.flush()
_record_configuration_control_change(
session,
resource_type=CONFIGURATION_CHANGE_RECORD_RESOURCE,
resource_id=str(record["id"]),
operation="created",
actor_user_id=actor_user_id,
payload={"key": key, "status": record["status"], "version": record["version"]},
)
if request:
_record_configuration_control_change(
session,
resource_type=CONFIGURATION_CHANGE_REQUEST_RESOURCE,
resource_id=str(request.get("id")),
operation="updated",
actor_user_id=actor_user_id,
payload={"key": key, "status": "applied", "applied_change_id": record["id"]},
)
return dict(record)
def _record_configuration_control_change(
session: Session,
*,
resource_type: str,
resource_id: str,
operation: str,
actor_user_id: str,
payload: dict[str, Any],
) -> None:
record_change(
session,
module_id=CONFIGURATION_CONTROL_MODULE_ID,
collection=CONFIGURATION_CHANGES_COLLECTION,
resource_type=resource_type,
resource_id=resource_id,
operation=operation,
actor_type="user",
actor_id=actor_user_id,
payload=payload,
)
def _request_by_id(session: Session, request_id: str) -> dict[str, Any] | None:
item = get_system_settings(session)
_index, request = _find_request(list(_control_payload(item.settings or {}).get("requests", [])), request_id)
return dict(request) if request is not None else None
def _find_request(requests: list[dict[str, Any]], request_id: str) -> tuple[int, dict[str, Any] | None]:
for index, request in enumerate(requests):
if request.get("id") == request_id:
return index, request
return -1, None
def _control_payload(settings: dict[str, Any]) -> dict[str, Any]:
raw = settings.get(CONFIGURATION_CONTROL_SETTINGS_KEY)
if not isinstance(raw, dict):
return {"sequence": 0, "requests": [], "history": []}
return {
"sequence": int(raw.get("sequence") or 0),
"requests": [dict(item) for item in raw.get("requests", []) if isinstance(item, dict)],
"history": [dict(item) for item in raw.get("history", []) if isinstance(item, dict)],
}
def _approval_count(request: dict[str, Any]) -> int:
user_ids = []
for item in request.get("approvals", []):
if isinstance(item, dict) and item.get("user_id"):
user_ids.append(str(item["user_id"]))
return len(set(user_ids))
def _sanitize_value(key: str, value: object) -> object:
field = classify_configuration_field(key)
if field is not None and field.secret_handling in {"reference_only", "env_only"}:
return _redact_secrets(value)
return _redact_secrets(value)
def _redact_secrets(value: object) -> object:
if isinstance(value, dict):
result: dict[str, object] = {}
for key, item in value.items():
if str(key).strip().casefold() in {"password", "token", "secret", "api_key", "access_key", "secret_key"}:
result[str(key)] = "<redacted>"
else:
result[str(key)] = _redact_secrets(item)
return result
if isinstance(value, list):
return [_redact_secrets(item) for item in value]
return value
def _trim_requests(requests: list[dict[str, Any]]) -> list[dict[str, Any]]:
return requests[:MAX_CONFIGURATION_CHANGE_REQUESTS]
def _trim_history(history: list[dict[str, Any]]) -> list[dict[str, Any]]:
return history[:MAX_CONFIGURATION_CHANGE_HISTORY]
def _now() -> str:
return utc_now().isoformat()
__all__ = [
"ConfigurationChangeApproval",
"ConfigurationControlError",
"CONFIGURATION_CHANGE_RECORD_RESOURCE",
"CONFIGURATION_CHANGE_REQUEST_RESOURCE",
"CONFIGURATION_CHANGES_COLLECTION",
"CONFIGURATION_CONTROL_MODULE_ID",
"approve_configuration_change_request",
"configuration_control_snapshot",
"configuration_value_digest",
"create_configuration_change_request",
"ensure_configuration_change_allowed",
"record_configuration_change_applied",
]

View File

@@ -0,0 +1,883 @@
from __future__ import annotations
import base64
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
import json
import os
from typing import Any, Literal, Protocol, runtime_checkable
import urllib.error
import urllib.request
from govoplan_core.core.module_package_catalog import (
_canonical_catalog_bytes,
_catalog_freshness_state,
_catalog_optional_text,
_catalog_sequence,
_catalog_signature_state,
_catalog_source_exists,
_catalog_source_type,
_is_http_url,
_load_private_key,
_parse_trusted_keys,
)
CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider"
DiagnosticSeverity = Literal["blocker", "warning", "info"]
PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"]
@dataclass(frozen=True, slots=True)
class ConfigurationModuleRequirement:
module_id: str
version: str | None = None
optional: bool = False
def to_dict(self) -> dict[str, object]:
return {"module_id": self.module_id, "version": self.version, "optional": self.optional}
@dataclass(frozen=True, slots=True)
class ConfigurationPackageFragment:
module_id: str
fragment_type: str
fragment_id: str | None = None
payload: Mapping[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, object]:
return {
"module_id": self.module_id,
"fragment_type": self.fragment_type,
"fragment_id": self.fragment_id,
"payload": dict(self.payload),
}
@dataclass(frozen=True, slots=True)
class ConfigurationPackageManifest:
package_id: str
name: str
version: str
description: str | None = None
publisher: str | None = None
category: str | None = None
license: str | None = None
required_modules: tuple[ConfigurationModuleRequirement, ...] = ()
required_capabilities: tuple[str, ...] = ()
optional_modules: tuple[ConfigurationModuleRequirement, ...] = ()
fragments: tuple[ConfigurationPackageFragment, ...] = ()
data_requirements: tuple[dict[str, object], ...] = ()
tags: tuple[str, ...] = ()
artifact_ref: str | None = None
artifact_sha256: str | None = None
signature: Mapping[str, Any] | None = None
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationPackageManifest":
return cls(
package_id=_required_str(value, "package_id"),
name=_required_str(value, "name"),
version=_required_str(value, "version"),
description=_optional_str(value, "description"),
publisher=_optional_str(value, "publisher"),
category=_optional_str(value, "category"),
license=_optional_str(value, "license"),
required_modules=tuple(_module_requirements(value.get("required_modules"), optional=False)),
required_capabilities=tuple(_string_list(value.get("required_capabilities"))),
optional_modules=tuple(_module_requirements(value.get("optional_modules"), optional=True)),
fragments=tuple(_fragments(value.get("fragments"))),
data_requirements=tuple(_object_list(value.get("data_requirements"), field_name="data_requirements")),
tags=tuple(_string_list(value.get("tags"))),
artifact_ref=_optional_str(value, "artifact_ref"),
artifact_sha256=_optional_str(value, "artifact_sha256"),
signature=value.get("signature") if isinstance(value.get("signature"), Mapping) else None,
)
def to_dict(self) -> dict[str, object]:
payload: dict[str, object] = {
"package_id": self.package_id,
"name": self.name,
"version": self.version,
"required_modules": [item.to_dict() for item in self.required_modules],
"required_capabilities": list(self.required_capabilities),
"optional_modules": [item.to_dict() for item in self.optional_modules],
"fragments": [item.to_dict() for item in self.fragments],
"data_requirements": [dict(item) for item in self.data_requirements],
"tags": list(self.tags),
}
for key, value in (
("description", self.description),
("publisher", self.publisher),
("category", self.category),
("license", self.license),
("artifact_ref", self.artifact_ref),
("artifact_sha256", self.artifact_sha256),
):
if value is not None:
payload[key] = value
if self.signature is not None:
payload["signature"] = dict(self.signature)
return payload
@dataclass(frozen=True, slots=True)
class ConfigurationDiagnostic:
severity: DiagnosticSeverity
code: str
message: str
module_id: str | None = None
object_ref: str | None = None
resolution: str | None = None
def to_dict(self) -> dict[str, object]:
return {
"severity": self.severity,
"code": self.code,
"message": self.message,
"module_id": self.module_id,
"object_ref": self.object_ref,
"resolution": self.resolution,
}
@dataclass(frozen=True, slots=True)
class ConfigurationPlanItem:
action: PlanAction
module_id: str
fragment_type: str
fragment_id: str | None = None
summary: str | None = None
def to_dict(self) -> dict[str, object]:
return {
"action": self.action,
"module_id": self.module_id,
"fragment_type": self.fragment_type,
"fragment_id": self.fragment_id,
"summary": self.summary,
}
@dataclass(frozen=True, slots=True)
class ConfigurationRequiredData:
key: str
label: str
data_type: str = "string"
required: bool = True
secret: bool = False
description: str | None = None
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationRequiredData":
return cls(
key=_required_str(value, "key"),
label=_optional_str(value, "label") or _required_str(value, "key"),
data_type=_optional_str(value, "data_type") or _optional_str(value, "type") or "string",
required=_bool(value.get("required"), default=True),
secret=_bool(value.get("secret"), default=False),
description=_optional_str(value, "description"),
)
def to_dict(self) -> dict[str, object]:
return {
"key": self.key,
"label": self.label,
"data_type": self.data_type,
"required": self.required,
"secret": self.secret,
"description": self.description,
}
@dataclass(frozen=True, slots=True)
class ConfigurationProviderDescription:
module_id: str
fragment_types: tuple[str, ...] = ()
schema_refs: Mapping[str, str] = field(default_factory=dict)
exported_scopes: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class ConfigurationPreflightContext:
tenant_id: str | None = None
operator_user_id: str | None = None
supplied_data: Mapping[str, Any] = field(default_factory=dict)
installed_modules: Mapping[str, str] = field(default_factory=dict)
capabilities: frozenset[str] = frozenset()
dry_run: bool = True
@dataclass(frozen=True, slots=True)
class ConfigurationPreflightResult:
diagnostics: tuple[ConfigurationDiagnostic, ...] = ()
required_data: tuple[ConfigurationRequiredData, ...] = ()
plan: tuple[ConfigurationPlanItem, ...] = ()
@dataclass(frozen=True, slots=True)
class ConfigurationApplyResult:
diagnostics: tuple[ConfigurationDiagnostic, ...] = ()
created_refs: Mapping[str, str] = field(default_factory=dict)
updated_refs: Mapping[str, str] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ConfigurationExportSelection:
tenant_id: str | None = None
scopes: tuple[str, ...] = ()
module_ids: tuple[str, ...] = ()
object_refs: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class ConfigurationExportResult:
fragments: tuple[ConfigurationPackageFragment, ...] = ()
data_requirements: tuple[ConfigurationRequiredData, ...] = ()
diagnostics: tuple[ConfigurationDiagnostic, ...] = ()
@runtime_checkable
class ConfigurationProvider(Protocol):
module_id: str
def describe(self) -> ConfigurationProviderDescription:
...
def preflight(self, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
...
def apply(self, fragment: ConfigurationPackageFragment, supplied_data: Mapping[str, Any], context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
...
def export(self, selection: ConfigurationExportSelection, context: ConfigurationPreflightContext) -> ConfigurationExportResult:
...
def health(self, import_result: ConfigurationApplyResult, context: ConfigurationPreflightContext) -> tuple[ConfigurationDiagnostic, ...]:
...
def dry_run_configuration_package(
package: ConfigurationPackageManifest | Mapping[str, Any],
providers: Sequence[ConfigurationProvider] | Mapping[str, ConfigurationProvider],
context: ConfigurationPreflightContext,
) -> ConfigurationPreflightResult:
manifest = _configuration_package_manifest(package)
provider_map = _configuration_provider_map(providers)
diagnostics: list[ConfigurationDiagnostic] = []
required_data: list[ConfigurationRequiredData] = []
plan: list[ConfigurationPlanItem] = []
diagnostics.extend(_module_requirement_diagnostics(manifest, context))
diagnostics.extend(_capability_requirement_diagnostics(manifest, context))
for item in manifest.data_requirements:
requirement = ConfigurationRequiredData.from_mapping(item)
required_data.append(requirement)
if requirement.required and requirement.key not in context.supplied_data:
diagnostics.append(ConfigurationDiagnostic(
severity="blocker",
code="required_data_missing",
message=f"Required operator data is missing: {requirement.label}.",
object_ref=requirement.key,
resolution="Collect this value in the configuration package wizard before applying.",
))
for fragment in manifest.fragments:
provider = provider_map.get(fragment.module_id)
if provider is None:
diagnostics.append(ConfigurationDiagnostic(
severity="blocker",
code="configuration_provider_missing",
message=f"No configuration provider is registered for module {fragment.module_id!r}.",
module_id=fragment.module_id,
object_ref=fragment.fragment_id or fragment.fragment_type,
resolution="Install and enable the module version that provides this configuration provider.",
))
plan.append(ConfigurationPlanItem(action="blocked", module_id=fragment.module_id, fragment_type=fragment.fragment_type, fragment_id=fragment.fragment_id, summary="Provider is missing."))
continue
description = provider.describe()
if description.fragment_types and fragment.fragment_type not in description.fragment_types:
diagnostics.append(ConfigurationDiagnostic(
severity="blocker",
code="fragment_type_unsupported",
message=f"Provider {fragment.module_id!r} does not support fragment type {fragment.fragment_type!r}.",
module_id=fragment.module_id,
object_ref=fragment.fragment_id or fragment.fragment_type,
resolution="Use a package version compatible with the installed module provider.",
))
plan.append(ConfigurationPlanItem(action="blocked", module_id=fragment.module_id, fragment_type=fragment.fragment_type, fragment_id=fragment.fragment_id, summary="Fragment type is unsupported."))
continue
try:
result = provider.preflight(fragment, context)
except Exception as exc:
diagnostics.append(ConfigurationDiagnostic(
severity="blocker",
code="provider_preflight_failed",
message=f"Configuration provider {fragment.module_id!r} preflight failed: {exc}",
module_id=fragment.module_id,
object_ref=fragment.fragment_id or fragment.fragment_type,
resolution="Review provider diagnostics and package fragment payload.",
))
plan.append(ConfigurationPlanItem(action="blocked", module_id=fragment.module_id, fragment_type=fragment.fragment_type, fragment_id=fragment.fragment_id, summary="Provider preflight failed."))
continue
diagnostics.extend(result.diagnostics)
required_data.extend(result.required_data)
for requirement in result.required_data:
if requirement.required and requirement.key not in context.supplied_data:
diagnostics.append(ConfigurationDiagnostic(
severity="blocker",
code="required_data_missing",
message=f"Required operator data is missing: {requirement.label}.",
module_id=fragment.module_id,
object_ref=requirement.key,
resolution="Collect this value in the configuration package wizard before applying.",
))
plan.extend(result.plan or (ConfigurationPlanItem(action="noop", module_id=fragment.module_id, fragment_type=fragment.fragment_type, fragment_id=fragment.fragment_id, summary="Provider reported no changes."),))
return ConfigurationPreflightResult(
diagnostics=tuple(_dedupe_diagnostics(diagnostics)),
required_data=tuple(_dedupe_required_data(required_data)),
plan=tuple(plan),
)
def apply_configuration_package(
package: ConfigurationPackageManifest | Mapping[str, Any],
providers: Sequence[ConfigurationProvider] | Mapping[str, ConfigurationProvider],
context: ConfigurationPreflightContext,
supplied_data: Mapping[str, Any] | None = None,
) -> ConfigurationApplyResult:
manifest = _configuration_package_manifest(package)
apply_context = ConfigurationPreflightContext(
tenant_id=context.tenant_id,
operator_user_id=context.operator_user_id,
supplied_data=supplied_data if supplied_data is not None else context.supplied_data,
installed_modules=context.installed_modules,
capabilities=context.capabilities,
dry_run=False,
)
preflight = dry_run_configuration_package(manifest, providers, apply_context)
blockers = [item for item in preflight.diagnostics if item.severity == "blocker"]
if blockers:
return ConfigurationApplyResult(diagnostics=tuple(blockers))
provider_map = _configuration_provider_map(providers)
diagnostics: list[ConfigurationDiagnostic] = list(preflight.diagnostics)
created_refs: dict[str, str] = {}
updated_refs: dict[str, str] = {}
for fragment in manifest.fragments:
provider = provider_map[fragment.module_id]
try:
result = provider.apply(fragment, apply_context.supplied_data, apply_context)
diagnostics.extend(result.diagnostics)
created_refs.update(result.created_refs)
updated_refs.update(result.updated_refs)
diagnostics.extend(provider.health(result, apply_context))
except Exception as exc:
diagnostics.append(ConfigurationDiagnostic(
severity="blocker",
code="provider_apply_failed",
message=f"Configuration provider {fragment.module_id!r} apply failed: {exc}",
module_id=fragment.module_id,
object_ref=fragment.fragment_id or fragment.fragment_type,
resolution="Stop the import, keep previous configuration, and inspect provider logs.",
))
return ConfigurationApplyResult(
diagnostics=tuple(_dedupe_diagnostics(diagnostics)),
created_refs=created_refs,
updated_refs=updated_refs,
)
def export_configuration_package(
providers: Sequence[ConfigurationProvider] | Mapping[str, ConfigurationProvider],
selection: ConfigurationExportSelection,
context: ConfigurationPreflightContext,
) -> ConfigurationExportResult:
provider_map = _configuration_provider_map(providers)
module_ids = selection.module_ids or tuple(provider_map)
fragments: list[ConfigurationPackageFragment] = []
data_requirements: list[ConfigurationRequiredData] = []
diagnostics: list[ConfigurationDiagnostic] = []
for module_id in module_ids:
provider = provider_map.get(module_id)
if provider is None:
diagnostics.append(ConfigurationDiagnostic(
severity="warning",
code="configuration_provider_missing",
message=f"No configuration provider is registered for module {module_id!r}; skipping export.",
module_id=module_id,
resolution="Enable the module provider before exporting its configuration.",
))
continue
try:
result = provider.export(selection, context)
except Exception as exc:
diagnostics.append(ConfigurationDiagnostic(
severity="blocker",
code="provider_export_failed",
message=f"Configuration provider {module_id!r} export failed: {exc}",
module_id=module_id,
resolution="Review provider diagnostics before reusing this package export.",
))
continue
fragments.extend(result.fragments)
data_requirements.extend(result.data_requirements)
diagnostics.extend(result.diagnostics)
return ConfigurationExportResult(
fragments=tuple(fragments),
data_requirements=tuple(_dedupe_required_data(data_requirements)),
diagnostics=tuple(_dedupe_diagnostics(diagnostics)),
)
def configuration_package_catalog(
path: Path | str | None = None,
*,
require_trusted: bool | None = None,
approved_channels: tuple[str, ...] | None = None,
trusted_keys: dict[str, str] | None = None,
) -> tuple[dict[str, object], ...]:
validation = validate_configuration_package_catalog(
path,
require_trusted=require_trusted,
approved_channels=approved_channels,
trusted_keys=trusted_keys,
)
if not validation["valid"]:
raise ValueError(str(validation["error"] or "Configuration package catalog is invalid."))
return tuple(item for item in validation["packages"] if isinstance(item, dict))
def validate_configuration_package_catalog(
path: Path | str | None = None,
*,
require_trusted: bool | None = None,
approved_channels: tuple[str, ...] | None = None,
trusted_keys: dict[str, str] | None = None,
) -> dict[str, object]:
source = _catalog_source(path)
configured = source is not None and _catalog_source_exists(source)
if source is not None and not _catalog_source_exists(source):
return _validation_result(source, configured=True, packages=(), error=f"Configuration package catalog does not exist: {source}")
read_state = {"cache_used": False, "cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() else None}
try:
payload, read_state = _read_catalog_payload_with_metadata(source)
packages = _normalize_catalog_packages(payload)
channel = _catalog_channel(payload)
sequence = _catalog_sequence(payload)
generated_at = _catalog_optional_text(payload, "generated_at")
not_before = _catalog_optional_text(payload, "not_before")
expires_at = _catalog_optional_text(payload, "expires_at")
effective_require_trusted = _configured_require_signature() if require_trusted is None else require_trusted
effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels
effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys()
signature_state = _catalog_signature_state(payload, trusted_keys=effective_trusted_keys)
freshness = _catalog_freshness_state(payload)
replay = _catalog_replay_state(channel=channel, sequence=sequence)
except Exception as exc:
return _validation_result(source, configured=configured, read_state=read_state, packages=(), error=str(exc))
if signature_state.get("fatal"):
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(signature_state["error"]))
if effective_approved_channels and channel not in effective_approved_channels:
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=f"Configuration package catalog channel {channel!r} is not approved. Approved channels: {', '.join(effective_approved_channels)}.")
if effective_require_trusted and not signature_state["trusted"]:
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(signature_state["error"] or "Configuration package catalog must be signed by a trusted key."))
if not freshness["valid"]:
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(freshness["error"]))
if not replay["valid"]:
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(replay["error"]))
warnings = [str(item) for item in freshness.get("warnings", ()) if item]
warnings.extend(str(item) for item in replay.get("warnings", ()) if item)
if not signature_state["signed"]:
warnings.append("Configuration package catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
elif not signature_state["trusted"]:
warnings.append(str(signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
return _validation_result(
source,
valid=True,
configured=configured,
read_state=read_state,
packages=packages,
channel=channel,
sequence=sequence,
generated_at=generated_at,
not_before=not_before,
expires_at=expires_at,
signature_state=signature_state,
warnings=warnings,
)
def sign_configuration_package_catalog(*, path: Path, key_id: str, private_key_path: Path, output_path: Path | None = None) -> Path:
payload = _read_catalog_payload(path)
if not isinstance(payload, dict):
raise ValueError("Only object-style configuration package catalogs can be signed.")
signed_payload = dict(payload)
signed_payload.pop("signature", None)
signed_payload.pop("signatures", None)
private_key = _load_private_key(private_key_path)
signature = private_key.sign(_canonical_catalog_bytes(signed_payload))
signed_payload["signature"] = {
"algorithm": "ed25519",
"key_id": key_id,
"value": base64.b64encode(signature).decode("ascii"),
}
target = output_path or path
target.write_text(json.dumps(signed_payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return target
def record_configuration_package_catalog_acceptance(validation: dict[str, object]) -> None:
state_path = _configured_sequence_state_path()
if state_path is None or validation.get("valid") is not True:
return
channel = validation.get("channel")
sequence = validation.get("sequence")
if not isinstance(channel, str) or not isinstance(sequence, int):
return
try:
state = json.loads(state_path.read_text(encoding="utf-8")) if state_path.exists() else {}
except json.JSONDecodeError:
state = {}
if not isinstance(state, dict):
state = {}
channels = state.get("channels")
if not isinstance(channels, dict):
channels = {}
channel_state = channels.get(channel)
if not isinstance(channel_state, dict):
channel_state = {}
channel_state["last_sequence"] = max(int(channel_state.get("last_sequence") or 0), sequence)
channel_state["accepted_at"] = datetime.now(tz=UTC).isoformat().replace("+00:00", "Z")
channel_state["key_id"] = validation.get("key_id")
channel_state["source"] = validation.get("source") or validation.get("path")
channels[channel] = channel_state
state["channels"] = channels
state_path.parent.mkdir(parents=True, exist_ok=True)
state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def _configuration_package_manifest(package: ConfigurationPackageManifest | Mapping[str, Any]) -> ConfigurationPackageManifest:
if isinstance(package, ConfigurationPackageManifest):
return package
return ConfigurationPackageManifest.from_mapping(package)
def _configuration_provider_map(providers: Sequence[ConfigurationProvider] | Mapping[str, ConfigurationProvider]) -> dict[str, ConfigurationProvider]:
if isinstance(providers, Mapping):
return {str(key): provider for key, provider in providers.items()}
return {provider.module_id: provider for provider in providers}
def _module_requirement_diagnostics(manifest: ConfigurationPackageManifest, context: ConfigurationPreflightContext) -> list[ConfigurationDiagnostic]:
diagnostics: list[ConfigurationDiagnostic] = []
installed = context.installed_modules
for requirement in manifest.required_modules:
installed_version = installed.get(requirement.module_id)
if installed_version is None:
diagnostics.append(ConfigurationDiagnostic(
severity="blocker",
code="required_module_missing",
message=f"Required module {requirement.module_id!r} is not installed.",
module_id=requirement.module_id,
resolution="Install and enable the required module before applying this configuration package.",
))
elif requirement.version and installed_version != requirement.version:
diagnostics.append(ConfigurationDiagnostic(
severity="blocker",
code="required_module_version_mismatch",
message=f"Required module {requirement.module_id!r} needs version {requirement.version}, but {installed_version} is installed.",
module_id=requirement.module_id,
resolution="Install a compatible module version or use a matching configuration package.",
))
return diagnostics
def _capability_requirement_diagnostics(manifest: ConfigurationPackageManifest, context: ConfigurationPreflightContext) -> list[ConfigurationDiagnostic]:
diagnostics: list[ConfigurationDiagnostic] = []
capabilities = set(context.capabilities)
for capability in manifest.required_capabilities:
if capability not in capabilities:
diagnostics.append(ConfigurationDiagnostic(
severity="blocker",
code="required_capability_missing",
message=f"Required capability {capability!r} is not available.",
object_ref=capability,
resolution="Enable the module or platform capability before applying this configuration package.",
))
return diagnostics
def _dedupe_diagnostics(items: Sequence[ConfigurationDiagnostic]) -> list[ConfigurationDiagnostic]:
seen: set[tuple[object, ...]] = set()
result: list[ConfigurationDiagnostic] = []
for item in items:
key = (item.severity, item.code, item.module_id, item.object_ref, item.message)
if key in seen:
continue
seen.add(key)
result.append(item)
return result
def _dedupe_required_data(items: Sequence[ConfigurationRequiredData]) -> list[ConfigurationRequiredData]:
seen: set[str] = set()
result: list[ConfigurationRequiredData] = []
for item in items:
if item.key in seen:
continue
seen.add(item.key)
result.append(item)
return result
def _catalog_source(path: Path | str | None) -> Path | str | None:
if path is not None:
return path if isinstance(path, str) and _is_http_url(path) else Path(path).expanduser()
url = os.getenv("GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_URL", "").strip()
if url:
return url
value = os.getenv("GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG", "").strip()
return Path(value).expanduser() if value else None
def _configured_catalog_cache_path() -> Path | None:
value = os.getenv("GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_CACHE", "").strip()
return Path(value).expanduser() if value else None
def _configured_sequence_state_path() -> Path | None:
value = os.getenv("GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_SEQUENCE_STATE", "").strip()
return Path(value).expanduser() if value else None
def _configured_enforce_sequence() -> bool:
return os.getenv("GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_ENFORCE_SEQUENCE", "").strip().lower() in {"1", "true", "yes", "on"}
def _configured_require_signature() -> bool:
return os.getenv("GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_REQUIRE_SIGNATURE", "").strip().lower() in {"1", "true", "yes", "on"}
def _configured_approved_channels() -> tuple[str, ...]:
value = os.getenv("GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_APPROVED_CHANNELS", "").strip()
return tuple(item.strip() for item in value.split(",") if item.strip()) if value else ()
def _configured_trusted_keys() -> dict[str, str]:
file_value = os.getenv("GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "").strip()
if file_value:
path = Path(file_value).expanduser()
if not path.exists():
raise ValueError(f"Trusted configuration catalog key file does not exist: {path}")
return _parse_trusted_keys(path.read_text(encoding="utf-8"))
url_value = os.getenv("GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_TRUSTED_KEYS_URL", "").strip()
if url_value:
return _parse_trusted_keys(_read_trusted_keys_url(url_value))
value = os.getenv("GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_TRUSTED_KEYS", "").strip()
return _parse_trusted_keys(value) if value else {}
def _configured_trusted_keys_cache_path() -> Path | None:
value = os.getenv("GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_TRUSTED_KEYS_CACHE", "").strip()
return Path(value).expanduser() if value else None
def _read_trusted_keys_url(url: str) -> str:
if not _is_http_url(url):
raise ValueError("Trusted configuration catalog key URL must use http:// or https://.")
cache_path = _configured_trusted_keys_cache_path()
try:
with urllib.request.urlopen(url, timeout=15) as response:
body = response.read().decode("utf-8")
if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8")
return body
except (OSError, urllib.error.URLError):
if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8")
raise
def _read_catalog_payload(source: Path | str | None) -> object:
payload, _metadata = _read_catalog_payload_with_metadata(source)
return payload
def _read_catalog_payload_with_metadata(source: Path | str | None) -> tuple[object, dict[str, object]]:
cache_path = _configured_catalog_cache_path()
metadata: dict[str, object] = {"cache_used": False, "cache_path": str(cache_path) if cache_path else None}
if source is None:
return {"packages": []}, metadata
if isinstance(source, str) and _is_http_url(source):
try:
with urllib.request.urlopen(source, timeout=15) as response:
body = response.read().decode("utf-8")
if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8")
return json.loads(body), metadata
except (OSError, urllib.error.URLError):
if cache_path is not None and cache_path.exists():
metadata["cache_used"] = True
return json.loads(cache_path.read_text(encoding="utf-8")), metadata
raise
return json.loads(Path(source).read_text(encoding="utf-8")), metadata
def _normalize_catalog_packages(payload: object) -> tuple[dict[str, object], ...]:
raw_items = payload.get("packages") if isinstance(payload, dict) else payload
if not isinstance(raw_items, list):
raise ValueError("Configuration package catalog must be a list or an object with a 'packages' list.")
return tuple(ConfigurationPackageManifest.from_mapping(item).to_dict() for item in raw_items if isinstance(item, Mapping))
def _catalog_channel(payload: object) -> str | None:
if not isinstance(payload, dict):
return None
value = payload.get("channel")
return str(value).strip() if value is not None and str(value).strip() else None
def _catalog_replay_state(*, channel: str | None, sequence: int | None) -> dict[str, object]:
state_path = _configured_sequence_state_path()
if state_path is None:
return {"valid": True, "warnings": ()}
if not channel:
return {"valid": False, "error": "Configuration package catalog sequence replay protection needs a channel."}
if sequence is None:
return {"valid": False, "error": "Configuration package catalog sequence replay protection needs a sequence."}
if not state_path.exists():
return {"valid": True, "warnings": ()}
try:
state = json.loads(state_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return {"valid": False, "error": f"Configuration catalog sequence state file is not valid JSON: {state_path}"}
channels = state.get("channels") if isinstance(state, dict) else None
channel_state = channels.get(channel) if isinstance(channels, dict) else None
last_sequence = channel_state.get("last_sequence") if isinstance(channel_state, dict) else None
if last_sequence is None:
return {"valid": True, "warnings": ()}
last = int(last_sequence)
if sequence < last:
return {"valid": False, "error": f"Configuration package catalog sequence {sequence} is older than accepted sequence {last} for channel {channel!r}."}
if sequence == last and _configured_enforce_sequence():
return {"valid": False, "error": f"Configuration package catalog sequence {sequence} was already accepted for channel {channel!r}."}
return {"valid": True, "warnings": ()}
def _validation_result(
source: Path | str | None,
*,
valid: bool = False,
configured: bool = False,
read_state: Mapping[str, object] | None = None,
packages: Sequence[Mapping[str, object]] = (),
channel: str | None = None,
sequence: int | None = None,
generated_at: str | None = None,
not_before: str | None = None,
expires_at: str | None = None,
signature_state: Mapping[str, object] | None = None,
warnings: Sequence[str] = (),
error: str | None = None,
) -> dict[str, object]:
state = signature_state or {"signed": False, "trusted": False, "key_id": None}
read_state = read_state or {}
return {
"valid": valid,
"configured": configured,
"path": str(source) if source is not None else None,
"source": str(source) if source is not None else None,
"source_type": _catalog_source_type(source),
"cache_used": bool(read_state.get("cache_used")),
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
"packages": [dict(item) for item in packages],
"channel": channel,
"sequence": sequence,
"generated_at": generated_at,
"not_before": not_before,
"expires_at": expires_at,
"signed": bool(state.get("signed")),
"trusted": bool(state.get("trusted")),
"key_id": state.get("key_id"),
"warnings": list(warnings),
"error": error,
}
def _module_requirements(value: object, *, optional: bool) -> list[ConfigurationModuleRequirement]:
if value is None:
return []
if not isinstance(value, list):
raise ValueError("Configuration package module requirements must be lists.")
result: list[ConfigurationModuleRequirement] = []
for item in value:
if isinstance(item, str):
result.append(ConfigurationModuleRequirement(module_id=item, optional=optional))
elif isinstance(item, Mapping):
result.append(ConfigurationModuleRequirement(module_id=_required_str(item, "module_id"), version=_optional_str(item, "version"), optional=optional))
else:
raise ValueError("Configuration package module requirements must be strings or objects.")
return result
def _fragments(value: object) -> list[ConfigurationPackageFragment]:
if value is None:
return []
if not isinstance(value, list):
raise ValueError("Configuration package fragments must be a list.")
result: list[ConfigurationPackageFragment] = []
for item in value:
if not isinstance(item, Mapping):
raise ValueError("Configuration package fragments must be objects.")
payload = item.get("payload")
result.append(ConfigurationPackageFragment(
module_id=_required_str(item, "module_id"),
fragment_type=_required_str(item, "fragment_type"),
fragment_id=_optional_str(item, "fragment_id"),
payload=payload if isinstance(payload, Mapping) else {},
))
return result
def _object_list(value: object, *, field_name: str) -> list[dict[str, object]]:
if value is None:
return []
if not isinstance(value, list):
raise ValueError(f"Configuration package {field_name} must be a list.")
return [dict(item) for item in value if isinstance(item, Mapping)]
def _string_list(value: object) -> list[str]:
if value is None:
return []
if not isinstance(value, list):
raise ValueError("Configuration package list fields must be lists.")
return [str(item).strip() for item in value if str(item).strip()]
def _bool(value: object, *, default: bool) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
return str(value).strip().casefold() in {"1", "true", "yes", "on"}
def _required_str(value: Mapping[str, Any], key: str) -> str:
item = _optional_str(value, key)
if not item:
raise ValueError(f"Configuration package entry is missing {key!r}.")
return item
def _optional_str(value: Mapping[str, Any], key: str) -> str | None:
item = value.get(key)
if item is None:
return None
text = str(item).strip()
return text or None

View File

@@ -0,0 +1,395 @@
from __future__ import annotations
from dataclasses import dataclass
from collections.abc import Mapping
from typing import Any, Literal
from govoplan_core.security.permissions import scopes_grant
ConfigurationScope = Literal["system", "tenant", "user", "group", "campaign"]
ConfigurationRisk = Literal["low", "medium", "high", "destructive"]
SecretHandling = Literal["none", "reference_only", "env_only"]
@dataclass(frozen=True, slots=True)
class ConfigurationFieldSafety:
key: str
label: str
owner_module: str
scope: ConfigurationScope
storage: str
ui_managed: bool
risk: ConfigurationRisk
secret_handling: SecretHandling = "none"
required_scopes: tuple[str, ...] = ()
dry_run_required: bool = False
validation_required: bool = True
policy_explanation_required: bool = False
audit_event: str | None = None
maintenance_required: bool = False
two_person_approval_required: bool = False
rollback_history_required: bool = False
notes: str | None = None
def to_dict(self) -> dict[str, object]:
return {
"key": self.key,
"label": self.label,
"owner_module": self.owner_module,
"scope": self.scope,
"storage": self.storage,
"ui_managed": self.ui_managed,
"risk": self.risk,
"secret_handling": self.secret_handling,
"required_scopes": list(self.required_scopes),
"dry_run_required": self.dry_run_required,
"validation_required": self.validation_required,
"policy_explanation_required": self.policy_explanation_required,
"audit_event": self.audit_event,
"maintenance_required": self.maintenance_required,
"two_person_approval_required": self.two_person_approval_required,
"rollback_history_required": self.rollback_history_required,
"notes": self.notes,
}
@dataclass(frozen=True, slots=True)
class ConfigurationChangeSafetyPlan:
key: str
allowed: bool
field: ConfigurationFieldSafety | None
risk: ConfigurationRisk | None = None
missing_scopes: tuple[str, ...] = ()
dry_run_required: bool = False
dry_run_satisfied: bool = False
approval_required: bool = False
approval_satisfied: bool = False
maintenance_required: bool = False
maintenance_satisfied: bool = False
rollback_history_required: bool = False
secret_handling: SecretHandling = "none"
audit_event: str | None = None
policy_explanation: str | None = None
blockers: tuple[str, ...] = ()
warnings: tuple[str, ...] = ()
def to_dict(self) -> dict[str, object]:
return {
"key": self.key,
"allowed": self.allowed,
"field": self.field.to_dict() if self.field else None,
"risk": self.risk,
"missing_scopes": list(self.missing_scopes),
"dry_run_required": self.dry_run_required,
"dry_run_satisfied": self.dry_run_satisfied,
"approval_required": self.approval_required,
"approval_satisfied": self.approval_satisfied,
"maintenance_required": self.maintenance_required,
"maintenance_satisfied": self.maintenance_satisfied,
"rollback_history_required": self.rollback_history_required,
"secret_handling": self.secret_handling,
"audit_event": self.audit_event,
"policy_explanation": self.policy_explanation,
"blockers": list(self.blockers),
"warnings": list(self.warnings),
}
_CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
ConfigurationFieldSafety(
key="module_management.desired_enabled",
label="Enabled modules",
owner_module="core",
scope="system",
storage="system_settings",
ui_managed=True,
risk="high",
required_scopes=("system:settings:write",),
dry_run_required=True,
policy_explanation_required=True,
audit_event="module_management.updated",
maintenance_required=True,
rollback_history_required=True,
notes="Changing enabled modules must use module preflight and installer rollback records.",
),
ConfigurationFieldSafety(
key="module_management.install_plan",
label="Module package install plan",
owner_module="core",
scope="system",
storage="system_settings",
ui_managed=True,
risk="destructive",
required_scopes=("system:settings:write",),
dry_run_required=True,
policy_explanation_required=True,
audit_event="module_install.requested",
maintenance_required=True,
two_person_approval_required=True,
rollback_history_required=True,
notes="Package mutation needs maintenance mode, dry-run preflight, approval, and run records.",
),
ConfigurationFieldSafety(
key="configuration_packages.apply",
label="Configuration package apply",
owner_module="core",
scope="system",
storage="configuration_packages",
ui_managed=True,
risk="high",
required_scopes=("system:settings:write",),
dry_run_required=True,
policy_explanation_required=True,
audit_event="configuration_package.applied",
maintenance_required=True,
two_person_approval_required=True,
rollback_history_required=True,
notes="Configuration package apply needs a dry-run, maintenance mode, approval, and version history.",
),
ConfigurationFieldSafety(
key="maintenance_mode",
label="Maintenance mode",
owner_module="core",
scope="system",
storage="system_settings",
ui_managed=True,
risk="high",
required_scopes=("system:settings:write",),
validation_required=True,
audit_event="system_settings.updated",
rollback_history_required=True,
notes="Maintenance mode controls platform availability and gates dangerous operations.",
),
ConfigurationFieldSafety(
key="privacy_retention_policy",
label="Privacy retention policy",
owner_module="core",
scope="system",
storage="system_settings",
ui_managed=True,
risk="high",
required_scopes=("system:settings:write", "admin:policies:write"),
dry_run_required=True,
policy_explanation_required=True,
audit_event="privacy_retention.policy_updated",
two_person_approval_required=True,
rollback_history_required=True,
notes="Retention reductions and destructive cleanup require explainable policy decisions and dry-run counts.",
),
ConfigurationFieldSafety(
key="governance_templates",
label="Governance templates",
owner_module="access",
scope="system",
storage="governance_templates",
ui_managed=True,
risk="high",
required_scopes=("system:governance:write",),
dry_run_required=True,
policy_explanation_required=True,
audit_event="governance_template.updated",
two_person_approval_required=True,
rollback_history_required=True,
notes="Central groups, roles, and assignments need previews before materialization.",
),
ConfigurationFieldSafety(
key="mail_profiles.credentials",
label="Mail profile credentials",
owner_module="mail",
scope="tenant",
storage="module_settings",
ui_managed=True,
risk="high",
secret_handling="reference_only",
required_scopes=("mail_servers:manage_credentials",),
validation_required=True,
audit_event="mail_server_profile.credential_updated",
two_person_approval_required=True,
rollback_history_required=True,
notes="UI must store secret references/placeholders only; secret values are never echoed.",
),
ConfigurationFieldSafety(
key="files.connector_profiles",
label="File connector profiles",
owner_module="files",
scope="tenant",
storage="module_settings",
ui_managed=True,
risk="high",
secret_handling="reference_only",
required_scopes=("files:file:admin",),
dry_run_required=True,
policy_explanation_required=True,
audit_event="files.connector_profile.updated",
two_person_approval_required=True,
rollback_history_required=True,
notes="Connector endpoints are UI-manageable, but passwords/tokens remain env or secret refs.",
),
ConfigurationFieldSafety(
key="DATABASE_URL",
label="Database URL",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="destructive",
secret_handling="env_only",
maintenance_required=True,
notes="Database connectivity remains deployment-managed and must not be changed from the running UI.",
),
ConfigurationFieldSafety(
key="MASTER_KEY_B64",
label="Master encryption key",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="destructive",
secret_handling="env_only",
maintenance_required=True,
two_person_approval_required=True,
notes="Encryption roots remain out of band; UI may only report missing/rotated state.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS",
label="Module package catalog trusted keys",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only",
notes="Trust roots are deployment-managed; UI can validate catalogs but should not edit key material.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_TRUSTED_KEYS",
label="Configuration package catalog trusted keys",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only",
notes="Configuration package trust roots are deployment-managed.",
),
)
def configuration_safety_catalog(*, include_env_only: bool = True) -> tuple[ConfigurationFieldSafety, ...]:
if include_env_only:
return _CONFIGURATION_FIELD_SAFETY
return tuple(item for item in _CONFIGURATION_FIELD_SAFETY if item.ui_managed)
def classify_configuration_field(key: str) -> ConfigurationFieldSafety | None:
clean = key.strip()
for item in _CONFIGURATION_FIELD_SAFETY:
if item.key == clean:
return item
return None
def high_impact_configuration_fields() -> tuple[ConfigurationFieldSafety, ...]:
return tuple(item for item in _CONFIGURATION_FIELD_SAFETY if item.risk in {"high", "destructive"})
def ui_managed_configuration_fields_requiring_approval() -> tuple[ConfigurationFieldSafety, ...]:
return tuple(item for item in _CONFIGURATION_FIELD_SAFETY if item.ui_managed and item.two_person_approval_required)
def plan_configuration_change(
key: str,
*,
actor_scopes: tuple[str, ...] | list[str] = (),
value: object = None,
dry_run: bool = False,
maintenance_mode: bool = False,
approval_count: int = 0,
include_env_only: bool = True,
) -> ConfigurationChangeSafetyPlan:
field = classify_configuration_field(key)
if field is None:
return ConfigurationChangeSafetyPlan(
key=key,
allowed=False,
field=None,
blockers=("unknown_configuration_field",),
policy_explanation="This setting is not in the configuration safety catalog.",
)
if not include_env_only and not field.ui_managed:
return ConfigurationChangeSafetyPlan(
key=key,
allowed=False,
field=field,
risk=field.risk,
secret_handling=field.secret_handling,
blockers=("deployment_managed",),
policy_explanation="This setting is deployment-managed and cannot be edited through the UI.",
)
blockers: list[str] = []
warnings: list[str] = []
missing_scopes = tuple(scope for scope in field.required_scopes if not scopes_grant(actor_scopes, scope))
if missing_scopes:
blockers.append("missing_required_scope")
if not field.ui_managed:
blockers.append("deployment_managed")
if field.dry_run_required and not dry_run:
blockers.append("dry_run_required")
if field.maintenance_required and not maintenance_mode:
blockers.append("maintenance_mode_required")
approval_required = field.two_person_approval_required
approval_satisfied = not approval_required or approval_count >= 2
if approval_required and not approval_satisfied:
blockers.append("two_person_approval_required")
if field.secret_handling == "reference_only" and _contains_plain_secret(value):
blockers.append("secret_reference_required")
if field.secret_handling == "env_only" and value is not None:
blockers.append("env_only_secret")
if field.rollback_history_required:
warnings.append("rollback_history_required")
return ConfigurationChangeSafetyPlan(
key=field.key,
allowed=not blockers,
field=field,
risk=field.risk,
missing_scopes=missing_scopes,
dry_run_required=field.dry_run_required,
dry_run_satisfied=not field.dry_run_required or dry_run,
approval_required=approval_required,
approval_satisfied=approval_satisfied,
maintenance_required=field.maintenance_required,
maintenance_satisfied=not field.maintenance_required or maintenance_mode,
rollback_history_required=field.rollback_history_required,
secret_handling=field.secret_handling,
audit_event=field.audit_event,
policy_explanation=_policy_explanation(field),
blockers=tuple(dict.fromkeys(blockers)),
warnings=tuple(dict.fromkeys(warnings)),
)
def _policy_explanation(field: ConfigurationFieldSafety) -> str:
parts = [f"{field.label} is {field.risk}-risk"]
if field.dry_run_required:
parts.append("requires dry-run preview")
if field.two_person_approval_required:
parts.append("requires two-person approval")
if field.maintenance_required:
parts.append("requires maintenance mode")
if field.secret_handling != "none":
parts.append(f"uses {field.secret_handling} secret handling")
return "; ".join(parts) + "."
def _contains_plain_secret(value: object) -> bool:
if not isinstance(value, Mapping):
return False
secret_keys = {"password", "token", "secret", "api_key", "access_key", "secret_key"}
for key, item in value.items():
name = str(key).strip().casefold()
if name in secret_keys and item not in (None, "", {"secret_ref": ""}):
return True
if isinstance(item, Mapping) and _contains_plain_secret(item):
return True
return False

View File

@@ -2,9 +2,36 @@ from __future__ import annotations
from collections import defaultdict
from collections.abc import Callable, Mapping
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass, field
from datetime import datetime, timezone
import re
from typing import Any
import uuid
_TRACE_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
def new_event_id() -> str:
return str(uuid.uuid4())
def normalize_trace_id(value: str | None) -> str | None:
if value is None:
return None
candidate = value.strip()
return candidate if _TRACE_ID_RE.fullmatch(candidate) else None
@dataclass(frozen=True, slots=True)
class EventTrace:
correlation_id: str
causation_id: str | None = None
_current_trace: ContextVar[EventTrace | None] = ContextVar("govoplan_event_trace", default=None)
@dataclass(frozen=True, slots=True)
@@ -13,11 +40,59 @@ class PlatformEvent:
module_id: str
payload: Mapping[str, Any] = field(default_factory=dict)
occurred_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
event_id: str = field(default_factory=new_event_id)
correlation_id: str | None = None
causation_id: str | None = None
EventHandler = Callable[[PlatformEvent], None]
def current_event_trace() -> EventTrace | None:
return _current_trace.get()
@contextmanager
def event_context(
*,
correlation_id: str | None = None,
causation_id: str | None = None,
):
parent = current_event_trace()
trace = EventTrace(
correlation_id=normalize_trace_id(correlation_id) or (parent.correlation_id if parent else new_event_id()),
causation_id=normalize_trace_id(causation_id) or (parent.causation_id if parent else None),
)
token = _current_trace.set(trace)
try:
yield trace
finally:
_current_trace.reset(token)
def event_trace_for(event: PlatformEvent) -> EventTrace:
active = current_event_trace()
return EventTrace(
correlation_id=normalize_trace_id(event.correlation_id) or (active.correlation_id if active else event.event_id),
causation_id=normalize_trace_id(event.causation_id) or (active.causation_id if active else None),
)
def ensure_event_trace(event: PlatformEvent) -> PlatformEvent:
trace = event_trace_for(event)
if event.correlation_id == trace.correlation_id and event.causation_id == trace.causation_id:
return event
return PlatformEvent(
type=event.type,
module_id=event.module_id,
payload=event.payload,
occurred_at=event.occurred_at,
event_id=event.event_id,
correlation_id=trace.correlation_id,
causation_id=trace.causation_id,
)
class EventBus:
def __init__(self) -> None:
self._subscribers: dict[str, list[EventHandler]] = defaultdict(list)
@@ -26,8 +101,9 @@ class EventBus:
self._subscribers[event_type].append(handler)
def publish(self, event: PlatformEvent) -> None:
for handler in self._subscribers.get(event.type, ()):
handler(event)
for handler in self._subscribers.get("*", ()):
handler(event)
traced = ensure_event_trace(event)
with event_context(correlation_id=traced.correlation_id, causation_id=traced.event_id):
for handler in self._subscribers.get(traced.type, ()):
handler(traced)
for handler in self._subscribers.get("*", ()):
handler(traced)

View File

@@ -0,0 +1,46 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Literal, Protocol, runtime_checkable
IDENTITY_MODULE_ID = "identity"
CAPABILITY_IDENTITY_DIRECTORY = "identity.directory"
IdentityStatus = Literal["active", "inactive", "suspended"]
@dataclass(frozen=True, slots=True)
class IdentityRef:
id: str
display_name: str | None = None
external_subject: str | None = None
source: str = "local"
primary_account_id: str | None = None
account_ids: tuple[str, ...] = ()
status: IdentityStatus = "active"
@dataclass(frozen=True, slots=True)
class IdentityAccountLinkRef:
id: str
identity_id: str
account_id: str
is_primary: bool = False
source: str = "local"
@runtime_checkable
class IdentityDirectory(Protocol):
def get_identity(self, identity_id: str) -> IdentityRef | None:
...
def identity_for_account(self, account_id: str) -> IdentityRef | None:
...
def identities_for_accounts(self, account_ids: Sequence[str]) -> Sequence[IdentityRef]:
...
def accounts_for_identity(self, identity_id: str) -> Sequence[IdentityAccountLinkRef]:
...

View File

@@ -10,6 +10,7 @@ from govoplan_core.core.module_management import ModuleManagementError, REQUIRED
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import configure_runtime
from govoplan_core.server.route_validation import validate_router_can_mount
@dataclass(frozen=True, slots=True)
@@ -129,6 +130,7 @@ class ModuleLifecycleManager:
module_router = manifest.route_factory(self.context)
guarded = APIRouter(dependencies=[Depends(require_module_active(module_id))])
guarded.include_router(module_router)
validate_router_can_mount(app, guarded, prefix=self.api_prefix, owner=f"module {module_id!r}")
app.include_router(guarded, prefix=self.api_prefix)
app.openapi_schema = None
self._mounted_modules.add(module_id)

View File

@@ -1,10 +1,11 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping
from contextlib import AbstractContextManager
from contextlib import AbstractContextManager, closing
from dataclasses import dataclass
from datetime import UTC, datetime
from importlib import metadata
import hashlib
import json
import os
from pathlib import Path
@@ -22,6 +23,7 @@ import urllib.request
from sqlalchemy.orm import Session
from govoplan_core.core.maintenance import saved_maintenance_mode
from govoplan_core.core.events import current_event_trace
from govoplan_core.core.module_management import (
PROTECTED_MODULES,
ModuleInstallPlan,
@@ -85,6 +87,7 @@ class ModuleInstallerPreflight:
rollback_commands: tuple[str, ...]
issues: tuple[ModuleInstallerIssue, ...]
checklist: tuple[ModuleInstallChecklistItem, ...] = ()
artifact_integrity: tuple[dict[str, object], ...] = ()
def as_dict(self) -> dict[str, object]:
return {
@@ -96,6 +99,7 @@ class ModuleInstallerPreflight:
"rollback_commands": list(self.rollback_commands),
"issues": [issue.as_dict() for issue in self.issues],
"checklist": [item.as_dict() for item in self.checklist],
"artifact_integrity": list(self.artifact_integrity),
}
@@ -203,6 +207,12 @@ def module_install_preflight(
if item.webui_package or item.webui_ref:
frontend_rebuild_required = True
artifact_integrity, artifact_integrity_issues = _verify_artifact_integrity(
planned_items,
require_verified=_configured_require_artifact_integrity(),
)
issues.extend(artifact_integrity_issues)
if frontend_rebuild_required:
if webui_root is None:
issues.append(ModuleInstallerIssue("blocker", "webui_root_missing", "WebUI package changes need a webui root for npm install/build."))
@@ -229,6 +239,7 @@ def module_install_preflight(
rollback_commands=rollback_commands,
issues=tuple(issues),
checklist=checklist,
artifact_integrity=artifact_integrity,
)
@@ -251,6 +262,7 @@ def run_module_install_plan(
activate_installed_modules: bool = True,
remove_uninstalled_modules_from_desired: bool = True,
dry_run: bool = False,
request_context: Mapping[str, object] | None = None,
) -> ModuleInstallerRunResult:
maintenance_mode = saved_maintenance_mode(session)
effective_runtime_dir = runtime_dir or default_installer_runtime_dir(database_url)
@@ -302,6 +314,8 @@ def run_module_install_plan(
"activate_installed_modules": activate_installed_modules,
"remove_uninstalled_modules_from_desired": remove_uninstalled_modules_from_desired,
}
if request_context:
record["request"] = dict(request_context)
_write_json(record_path, record)
if dry_run:
@@ -409,6 +423,7 @@ def supervise_module_install_plan(
health_urls: Iterable[str] | None = None,
health_timeout_seconds: float = 60.0,
health_interval_seconds: float = 2.0,
request_context: Mapping[str, object] | None = None,
) -> ModuleInstallerRunResult:
effective_runtime_dir = runtime_dir or default_installer_runtime_dir(database_url)
effective_restart_commands = _command_list(restart_command, restart_commands)
@@ -431,6 +446,7 @@ def supervise_module_install_plan(
activate_installed_modules=activate_installed_modules,
remove_uninstalled_modules_from_desired=remove_uninstalled_modules_from_desired,
dry_run=False,
request_context=request_context,
)
supervisor: dict[str, object] = {
"started_at": datetime.now(tz=UTC).isoformat(),
@@ -647,8 +663,10 @@ def queue_module_installer_request(
options: Mapping[str, object] | None = None,
requested_by: str | None = None,
retry_of: str | None = None,
trace: Mapping[str, object] | None = None,
) -> dict[str, object]:
request_id = _run_id()
effective_trace = dict(trace) if trace is not None else _current_trace_payload()
record = {
"request_id": request_id,
"status": "queued",
@@ -656,6 +674,8 @@ def queue_module_installer_request(
"requested_by": requested_by,
"options": dict(options or {}),
}
if effective_trace:
record["trace"] = effective_trace
if retry_of:
record["retry_of"] = retry_of
with _request_queue_lock(runtime_dir):
@@ -1273,6 +1293,7 @@ def _run_summary(path: Path, record: Mapping[str, object]) -> dict[str, object]:
supervisor = record.get("supervisor") if isinstance(record.get("supervisor"), Mapping) else {}
commands = record.get("commands") if isinstance(record.get("commands"), list) else []
plan = record.get("plan") if isinstance(record.get("plan"), list) else []
request = record.get("request") if isinstance(record.get("request"), Mapping) else {}
return {
"run_id": str(record.get("run_id") or path.parent.name),
"status": str(record.get("status") or "unknown"),
@@ -1284,9 +1305,22 @@ def _run_summary(path: Path, record: Mapping[str, object]) -> dict[str, object]:
"record_path": str(path),
"commands_count": len(commands),
"planned_modules": [str(item.get("module_id")) for item in plan if isinstance(item, Mapping) and item.get("module_id")],
"request_id": request.get("request_id") if isinstance(request, Mapping) else None,
"requested_by": request.get("requested_by") if isinstance(request, Mapping) else None,
"trace": request.get("trace") if isinstance(request.get("trace"), Mapping) else None,
}
def _current_trace_payload() -> dict[str, object]:
trace = current_event_trace()
if trace is None:
return {}
payload: dict[str, object] = {"correlation_id": trace.correlation_id}
if trace.causation_id:
payload["causation_id"] = trace.causation_id
return payload
def _database_restore_was_applied(record: Mapping[str, object]) -> bool:
restore = record.get("rollback_database_restore")
return isinstance(restore, Mapping) and restore.get("restored") is True
@@ -1305,6 +1339,156 @@ def _looks_pinned_dependency_ref(value: str) -> bool:
return False
def _configured_require_artifact_integrity() -> bool:
return os.getenv("GOVOPLAN_MODULE_INSTALLER_REQUIRE_ARTIFACT_INTEGRITY", "").strip().lower() in {"1", "true", "yes", "on"}
def _verify_artifact_integrity(
planned_items: tuple[ModuleInstallPlanItem, ...],
*,
require_verified: bool,
) -> tuple[tuple[dict[str, object], ...], tuple[ModuleInstallerIssue, ...]]:
records: list[dict[str, object]] = []
issues: list[ModuleInstallerIssue] = []
for item in planned_items:
if item.action != "install":
continue
for kind, package_name, package_ref in (
("python", item.python_package, item.python_ref),
("webui", item.webui_package, item.webui_ref),
):
if not package_ref:
continue
raw_metadata = _artifact_metadata(item.artifact_integrity, kind)
if raw_metadata is None:
if require_verified:
issues.append(ModuleInstallerIssue(
"blocker",
"artifact_integrity_required",
f"{kind.capitalize()} artifact integrity metadata is required in production mode.",
item.module_id,
))
continue
record, record_issues = _verify_artifact_metadata(
item,
kind=kind,
package_name=package_name,
package_ref=package_ref,
metadata=raw_metadata,
require_verified=require_verified,
)
records.append(record)
issues.extend(record_issues)
return tuple(records), tuple(issues)
def _artifact_metadata(value: Mapping[str, object] | None, kind: str) -> Mapping[str, object] | None:
if not isinstance(value, Mapping):
return None
raw = value.get(kind)
return raw if isinstance(raw, Mapping) else None
def _verify_artifact_metadata(
item: ModuleInstallPlanItem,
*,
kind: str,
package_name: str | None,
package_ref: str,
metadata: Mapping[str, object],
require_verified: bool,
) -> tuple[dict[str, object], tuple[ModuleInstallerIssue, ...]]:
issues: list[ModuleInstallerIssue] = []
expected_sha256 = _artifact_text(metadata, "sha256")
artifact_path = _artifact_path(metadata)
expected_ref = _artifact_text(metadata, "ref") or _artifact_text(metadata, "expected_ref")
record: dict[str, object] = {
"module_id": item.module_id,
"kind": kind,
"package_ref": package_ref,
"verified": False,
}
if package_name:
record["package"] = package_name
for key in ("sha256", "sbom_url", "provenance_url", "registry_identity", "git_ref"):
value = _artifact_text(metadata, key)
if value:
record[key] = value
if expected_ref:
record["expected_ref"] = expected_ref
if expected_ref != package_ref:
issues.append(ModuleInstallerIssue(
"blocker",
"artifact_ref_mismatch",
f"{kind.capitalize()} artifact metadata expected ref {expected_ref!r} but the install plan uses {package_ref!r}.",
item.module_id,
))
if not expected_sha256:
issues.append(ModuleInstallerIssue(
"blocker" if require_verified else "warning",
"artifact_digest_missing",
f"{kind.capitalize()} artifact integrity metadata has no sha256 digest.",
item.module_id,
))
return record, tuple(issues)
if artifact_path is None:
issues.append(ModuleInstallerIssue(
"blocker" if require_verified else "warning",
"artifact_not_verified",
f"{kind.capitalize()} artifact digest is declared but no local artifact path is available for verification.",
item.module_id,
))
return record, tuple(issues)
record["artifact_path"] = str(artifact_path)
if not artifact_path.exists():
issues.append(ModuleInstallerIssue(
"blocker",
"artifact_missing",
f"{kind.capitalize()} artifact does not exist: {artifact_path}",
item.module_id,
))
return record, tuple(issues)
actual_sha256 = _sha256_file(artifact_path)
record["actual_sha256"] = actual_sha256
if actual_sha256 != expected_sha256.lower():
issues.append(ModuleInstallerIssue(
"blocker",
"artifact_digest_mismatch",
f"{kind.capitalize()} artifact digest mismatch for {artifact_path}.",
item.module_id,
))
return record, tuple(issues)
record["verified"] = True
record["verified_at"] = datetime.now(tz=UTC).isoformat()
return record, tuple(issues)
def _artifact_text(metadata: Mapping[str, object], key: str) -> str | None:
value = metadata.get(key)
if value is None:
return None
text = str(value).strip()
return text or None
def _artifact_path(metadata: Mapping[str, object]) -> Path | None:
value = _artifact_text(metadata, "path") or _artifact_text(metadata, "artifact_path")
if not value:
return None
if value.startswith("file://"):
value = value[len("file://"):]
path = Path(value).expanduser()
return path if path.is_absolute() else Path.cwd() / path
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _snapshot_environment(
run_dir: Path,
*,
@@ -1368,7 +1552,7 @@ def _snapshot_sqlite_database(run_dir: Path, database_url: str | None) -> dict[s
raise ModuleInstallerError("Automatic database backup for --migrate currently supports sqlite:/// URLs only.")
backup_path = run_dir / "database.sqlite.before"
if db_path.exists():
with sqlite3.connect(str(db_path)) as source, sqlite3.connect(str(backup_path)) as target:
with closing(sqlite3.connect(str(db_path))) as source, closing(sqlite3.connect(str(backup_path))) as target:
source.backup(target)
else:
backup_path.touch()
@@ -1467,7 +1651,7 @@ def _restore_database_snapshot(
if backup_path.stat().st_size == 0:
target_path.unlink(missing_ok=True)
return {"restored": True, "type": "sqlite", "source": str(target_path), "empty": True}
with sqlite3.connect(str(backup_path)) as source, sqlite3.connect(str(target_path)) as target:
with closing(sqlite3.connect(str(backup_path))) as source, closing(sqlite3.connect(str(target_path))) as target:
source.backup(target)
return {"restored": True, "type": "sqlite", "source": str(target_path)}
@@ -1523,6 +1707,9 @@ def _run_database_hook(
})
if database_url:
env["GOVOPLAN_DATABASE_URL"] = database_url
pgtools_url = _database_url_for_pgtools(database_url)
if pgtools_url:
env["GOVOPLAN_DATABASE_URL_PGTOOLS"] = pgtools_url
env.setdefault("DATABASE_URL", database_url)
return subprocess.run(
command,
@@ -1535,6 +1722,14 @@ def _run_database_hook(
)
def _database_url_for_pgtools(database_url: str) -> str | None:
if database_url.startswith(("postgresql://", "postgres://")):
return database_url
if database_url.startswith("postgresql+"):
return re.sub(r"^postgresql\+[^:]+://", "postgresql://", database_url)
return None
def _sqlite_database_path(database_url: str | None) -> Path | None:
if not database_url or not database_url.startswith("sqlite:///"):
return None
@@ -1572,6 +1767,7 @@ def _mark_applied(item: ModuleInstallPlanItem) -> ModuleInstallPlanItem:
python_ref=item.python_ref,
webui_package=item.webui_package,
webui_ref=item.webui_ref,
artifact_integrity=item.artifact_integrity,
destroy_data=item.destroy_data,
status="applied",
notes=item.notes,

View File

@@ -11,14 +11,103 @@ import urllib.error
import urllib.request
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
def module_license_decision(required_features: list[str] | tuple[str, ...]) -> dict[str, object]:
return _module_license_decision_from_validation(validate_module_license(), required_features)
def module_license_diagnostics(
path: Path | None = None,
*,
required_features: list[str] | tuple[str, ...] = (),
trusted_keys: dict[str, str] | None = None,
require_trusted: bool | None = None,
) -> dict[str, object]:
validation = validate_module_license(path, trusted_keys=trusted_keys, require_trusted=require_trusted)
decision = _module_license_decision_from_validation(validation, required_features)
guidance = _license_guidance(validation, decision)
return {
"configured": validation["configured"],
"valid": validation["valid"],
"path": validation["path"],
"license_id": validation["license_id"],
"subject": validation["subject"],
"features": validation["features"],
"valid_from": validation["valid_from"],
"valid_until": validation["valid_until"],
"signed": validation["signed"],
"trusted": validation["trusted"],
"key_id": validation["key_id"],
"enforced": _license_enforcement_enabled(),
"allowed": decision["allowed"],
"required_features": decision["required_features"],
"missing_features": decision["missing_features"],
"expires_in_days": _expires_in_days(validation.get("valid_until")),
"reason": decision.get("reason") or validation.get("error"),
"guidance": guidance,
}
def issue_module_license(
*,
path: Path,
license_id: str,
subject: str,
features: list[str] | tuple[str, ...],
valid_until: str | datetime,
signing_key_id: str,
signing_private_key_path: Path,
valid_from: str | datetime | None = None,
issuer: str | None = None,
notes: str | None = None,
) -> Path:
clean_license_id = str(license_id).strip()
clean_subject = str(subject).strip()
clean_key_id = str(signing_key_id).strip()
clean_features = _normalize_required_features(features)
if not clean_license_id:
raise ValueError("License id is required.")
if not clean_subject:
raise ValueError("License subject is required.")
if not clean_key_id:
raise ValueError("License signing key id is required.")
if not clean_features:
raise ValueError("At least one license feature is required.")
payload: dict[str, object] = {
"license_id": clean_license_id,
"subject": clean_subject,
"features": list(clean_features),
"valid_from": _datetime_text(valid_from or datetime.now(tz=UTC)),
"valid_until": _datetime_text(valid_until),
}
clean_issuer = str(issuer).strip() if issuer else ""
if clean_issuer:
payload["issuer"] = clean_issuer
clean_notes = str(notes).strip() if notes else ""
if clean_notes:
payload["notes"] = clean_notes
private_key = _load_private_key(signing_private_key_path)
signature = private_key.sign(_canonical_bytes(payload))
payload["signature"] = {
"algorithm": "ed25519",
"key_id": clean_key_id,
"value": base64.b64encode(signature).decode("ascii"),
}
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return path
def _module_license_decision_from_validation(
validation: dict[str, object],
required_features: list[str] | tuple[str, ...],
) -> dict[str, object]:
required = tuple(dict.fromkeys(str(item).strip() for item in required_features if str(item).strip()))
if not required:
return {"allowed": True, "required_features": [], "missing_features": [], "enforced": False}
validation = validate_module_license()
enforced = _license_enforcement_enabled()
if not validation["valid"]:
return {
@@ -102,6 +191,43 @@ def _license_result(
}
def _license_guidance(validation: dict[str, object], decision: dict[str, object]) -> list[str]:
guidance: list[str] = []
if not validation.get("configured"):
guidance.append("Configure GOVOPLAN_LICENSE_FILE with the current signed license file.")
elif validation.get("path") and not validation.get("valid") and validation.get("error"):
guidance.append("Import a renewed license file or correct the configured license path before enforcing installs.")
if validation.get("configured") and not validation.get("signed"):
guidance.append("Use a signed license for production and configure GOVOPLAN_LICENSE_TRUSTED_KEYS_FILE.")
elif validation.get("signed") and not validation.get("trusted"):
guidance.append("Add the signing public key to the trusted license keyring, or rotate to a trusted issuer key.")
expires_in_days = _expires_in_days(validation.get("valid_until"))
if expires_in_days is not None:
if expires_in_days < 0:
guidance.append("Renew the license; the validity window has ended.")
elif expires_in_days <= 30:
guidance.append(f"Renew the license soon; it expires in {expires_in_days} day(s).")
missing = decision.get("missing_features")
if isinstance(missing, list) and missing:
guidance.append("Request a renewal or entitlement update for: " + ", ".join(str(item) for item in missing))
if not _license_enforcement_enabled():
guidance.append("License enforcement is observe-only until GOVOPLAN_LICENSE_ENFORCEMENT=true is set.")
return guidance
def _expires_in_days(value: object) -> int | None:
if not value:
return None
try:
expires_at = _parse_datetime(value)
except (TypeError, ValueError):
return None
if expires_at is None:
return None
seconds = (expires_at - datetime.now(tz=UTC)).total_seconds()
return int(seconds // 86400)
def _configured_license_path() -> Path | None:
value = os.getenv("GOVOPLAN_LICENSE_FILE", "").strip()
return Path(value).expanduser() if value else None
@@ -203,6 +329,13 @@ def _canonical_bytes(payload: object) -> bytes:
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
def _datetime_text(value: str | datetime) -> str:
parsed = _parse_datetime(value)
if parsed is None:
raise ValueError("License validity timestamp is required.")
return parsed.isoformat().replace("+00:00", "Z")
def _parse_datetime(value: object) -> datetime | None:
if value is None:
return None
@@ -223,3 +356,14 @@ def _string_list(value: object) -> list[str]:
if not isinstance(value, list):
raise ValueError("License features must be a list.")
return [str(item).strip() for item in value if str(item).strip()]
def _normalize_required_features(value: list[str] | tuple[str, ...]) -> tuple[str, ...]:
return tuple(dict.fromkeys(str(item).strip() for item in value if str(item).strip()))
def _load_private_key(path: Path) -> Ed25519PrivateKey:
private_key = serialization.load_pem_private_key(path.expanduser().read_bytes(), password=None)
if not isinstance(private_key, Ed25519PrivateKey):
raise ValueError("License signing key must be an Ed25519 private key.")
return private_key

View File

@@ -42,6 +42,7 @@ class ModuleInstallPlanItem:
python_ref: str | None = None
webui_package: str | None = None
webui_ref: str | None = None
artifact_integrity: Mapping[str, object] | None = None
destroy_data: bool = False
status: str = "planned"
notes: str | None = None
@@ -58,6 +59,8 @@ class ModuleInstallPlanItem:
value = getattr(self, key)
if value:
payload[key] = value
if self.artifact_integrity:
payload["artifact_integrity"] = dict(self.artifact_integrity)
return payload
@@ -113,7 +116,7 @@ def load_startup_enabled_modules(
with get_database().session() as session:
desired = saved_desired_enabled_modules(session, fallback)
except (RuntimeError, SQLAlchemyError):
return fallback
desired = fallback
if available is None:
return desired
@@ -293,6 +296,7 @@ def normalize_module_install_plan_item(
python_ref = _clean_optional_string(raw.get("python_ref"))
webui_package = _clean_optional_string(raw.get("webui_package"))
webui_ref = _clean_optional_string(raw.get("webui_ref"))
artifact_integrity = _clean_optional_mapping(raw.get("artifact_integrity"), field="artifact_integrity", module_id=module_id)
destroy_data = _clean_bool(raw.get("destroy_data"))
notes = _clean_optional_string(raw.get("notes"))
@@ -322,6 +326,7 @@ def normalize_module_install_plan_item(
python_ref=python_ref,
webui_package=webui_package,
webui_ref=webui_ref,
artifact_integrity=artifact_integrity,
destroy_data=destroy_data,
status=status,
notes=notes,
@@ -413,6 +418,14 @@ def _clean_bool(value: object) -> bool:
return bool(value)
def _clean_optional_mapping(value: object, *, field: str, module_id: str) -> dict[str, object] | None:
if value is None:
return None
if not isinstance(value, Mapping):
raise ModuleManagementError(f"Install plan item {module_id!r} field {field} must be an object.")
return {str(key): item for key, item in value.items() if str(key).strip()}
def _validate_dependency_ref(value: str, *, field: str, module_id: str) -> None:
lowered = value.lower()
if lowered.startswith(LOCAL_DEPENDENCY_REF_PREFIXES) or value.startswith(("/", "./", "../", "~")):

View File

@@ -54,10 +54,13 @@ def validate_module_package_catalog(
"path": str(catalog_source),
"source": str(catalog_source),
"source_type": _catalog_source_type(catalog_source),
"cache_used": False,
"cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() is not None else None,
"modules": [],
"channel": None,
"sequence": None,
"generated_at": None,
"not_before": None,
"expires_at": None,
"signed": False,
"trusted": False,
@@ -66,12 +69,14 @@ def validate_module_package_catalog(
"error": f"Module package catalog does not exist: {catalog_source}",
}
warnings: list[str] = []
read_state = {"cache_used": False, "cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() is not None else None}
try:
payload = _read_catalog_payload(catalog_source)
payload, read_state = _read_catalog_payload_with_metadata(catalog_source)
modules = _normalize_catalog_modules(payload)
channel = _catalog_channel(payload)
sequence = _catalog_sequence(payload)
generated_at = _catalog_optional_text(payload, "generated_at")
not_before = _catalog_optional_text(payload, "not_before")
expires_at = _catalog_optional_text(payload, "expires_at")
signature_state = _catalog_signature_state(payload, trusted_keys=effective_trusted_keys)
freshness = _catalog_freshness_state(payload)
@@ -83,10 +88,13 @@ def validate_module_package_catalog(
"path": str(catalog_source) if catalog_source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None,
"source_type": _catalog_source_type(catalog_source),
"cache_used": bool(read_state.get("cache_used")),
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
"modules": [],
"channel": None,
"sequence": None,
"generated_at": None,
"not_before": None,
"expires_at": None,
"signed": False,
"trusted": False,
@@ -101,10 +109,13 @@ def validate_module_package_catalog(
"path": str(catalog_source) if catalog_source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None,
"source_type": _catalog_source_type(catalog_source),
"cache_used": bool(read_state.get("cache_used")),
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
"modules": [],
"channel": channel,
"sequence": sequence,
"generated_at": generated_at,
"not_before": not_before,
"expires_at": expires_at,
"signed": signature_state["signed"],
"trusted": signature_state["trusted"],
@@ -119,10 +130,13 @@ def validate_module_package_catalog(
"path": str(catalog_source) if catalog_source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None,
"source_type": _catalog_source_type(catalog_source),
"cache_used": bool(read_state.get("cache_used")),
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
"modules": [],
"channel": channel,
"sequence": sequence,
"generated_at": generated_at,
"not_before": not_before,
"expires_at": expires_at,
"signed": signature_state["signed"],
"trusted": signature_state["trusted"],
@@ -137,10 +151,13 @@ def validate_module_package_catalog(
"path": str(catalog_source) if catalog_source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None,
"source_type": _catalog_source_type(catalog_source),
"cache_used": bool(read_state.get("cache_used")),
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
"modules": [],
"channel": channel,
"sequence": sequence,
"generated_at": generated_at,
"not_before": not_before,
"expires_at": expires_at,
"signed": signature_state["signed"],
"trusted": False,
@@ -155,8 +172,10 @@ def validate_module_package_catalog(
channel=channel,
sequence=sequence,
generated_at=generated_at,
not_before=not_before,
expires_at=expires_at,
signature_state=signature_state,
read_state=read_state,
error=str(freshness["error"]),
)
if not replay["valid"]:
@@ -166,8 +185,10 @@ def validate_module_package_catalog(
channel=channel,
sequence=sequence,
generated_at=generated_at,
not_before=not_before,
expires_at=expires_at,
signature_state=signature_state,
read_state=read_state,
error=str(replay["error"]),
)
warnings.extend(str(item) for item in freshness.get("warnings", ()) if item)
@@ -182,10 +203,13 @@ def validate_module_package_catalog(
"path": str(catalog_source) if catalog_source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None,
"source_type": _catalog_source_type(catalog_source),
"cache_used": bool(read_state.get("cache_used")),
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
"modules": list(modules),
"channel": channel,
"sequence": sequence,
"generated_at": generated_at,
"not_before": not_before,
"expires_at": expires_at,
"signed": signature_state["signed"],
"trusted": signature_state["trusted"],
@@ -353,14 +377,31 @@ def _parse_trusted_keys(value: str) -> dict[str, str]:
def _read_catalog_payload(source: Path | str | None) -> object:
payload, _metadata = _read_catalog_payload_with_metadata(source)
return payload
def _read_catalog_payload_with_metadata(source: Path | str | None) -> tuple[object, dict[str, object]]:
cache_path = _configured_catalog_cache_path()
metadata: dict[str, object] = {
"cache_used": False,
"cache_path": str(cache_path) if cache_path is not None else None,
}
if source is None:
return {"modules": []}
return {"modules": []}, metadata
if isinstance(source, str) and _is_http_url(source):
return json.loads(_read_catalog_url(source))
return json.loads(Path(source).read_text(encoding="utf-8"))
body, cache_used = _read_catalog_url_with_metadata(source)
metadata["cache_used"] = cache_used
return json.loads(body), metadata
return json.loads(Path(source).read_text(encoding="utf-8")), metadata
def _read_catalog_url(url: str) -> str:
body, _cache_used = _read_catalog_url_with_metadata(url)
return body
def _read_catalog_url_with_metadata(url: str) -> tuple[str, bool]:
cache_path = _configured_catalog_cache_path()
try:
with urllib.request.urlopen(url, timeout=15) as response:
@@ -368,10 +409,10 @@ def _read_catalog_url(url: str) -> str:
if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8")
return body
return body, False
except (OSError, urllib.error.URLError):
if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8")
return cache_path.read_text(encoding="utf-8"), True
raise
@@ -549,7 +590,7 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
action = str(value.get("action") or "install")
if action not in {"install", "uninstall"}:
raise ValueError(f"Unsupported catalog action for {module_id!r}: {action!r}")
return {
item = {
"module_id": module_id,
"name": str(value.get("name") or module_id),
"description": _optional_str(value, "description"),
@@ -563,6 +604,10 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
"notes": _optional_str(value, "notes"),
"tags": _string_list(value.get("tags")),
}
artifact_integrity = _normalize_artifact_integrity(value.get("artifact_integrity"))
if artifact_integrity:
item["artifact_integrity"] = artifact_integrity
return item
def _required_str(value: dict[str, Any], key: str) -> str:
@@ -588,6 +633,37 @@ def _string_list(value: Any) -> list[str]:
return [str(item).strip() for item in value if str(item).strip()]
def _normalize_artifact_integrity(value: Any) -> dict[str, object]:
if value is None:
return {}
if not isinstance(value, dict):
raise ValueError("Module package catalog artifact_integrity must be an object.")
normalized: dict[str, object] = {}
for key in ("python", "webui"):
raw = value.get(key)
if raw is None:
continue
if not isinstance(raw, dict):
raise ValueError(f"Module package catalog artifact_integrity.{key} must be an object.")
clean = {
field: text
for field in (
"ref",
"path",
"artifact_path",
"sha256",
"sbom_url",
"provenance_url",
"registry_identity",
"git_ref",
)
if (text := _optional_str(raw, field))
}
if clean:
normalized[key] = clean
return normalized
def _parse_datetime(value: object) -> datetime | None:
if value is None:
return None
@@ -628,8 +704,10 @@ def _invalid_catalog_result(
channel: str | None,
sequence: int | None,
generated_at: str | None,
not_before: str | None,
expires_at: str | None,
signature_state: dict[str, object],
read_state: dict[str, object],
error: str,
) -> dict[str, object]:
return {
@@ -638,10 +716,13 @@ def _invalid_catalog_result(
"path": str(source) if source is not None else None,
"source": str(source) if source is not None else None,
"source_type": _catalog_source_type(source),
"cache_used": bool(read_state.get("cache_used")),
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
"modules": list(modules),
"channel": channel,
"sequence": sequence,
"generated_at": generated_at,
"not_before": not_before,
"expires_at": expires_at,
"signed": signature_state["signed"],
"trusted": signature_state["trusted"],

View File

@@ -8,6 +8,9 @@ if TYPE_CHECKING:
from fastapi import APIRouter
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION = "1"
PermissionLevel = Literal["system", "tenant"]
SubjectType = Literal["account", "membership", "group", "service_account", "tenant"]
@@ -129,6 +132,61 @@ class ModuleContext:
data: Mapping[str, Any] = field(default_factory=dict)
DocumentationLayer = Literal["always", "configured", "available", "evidence"]
DocumentationLinkKind = Literal["runtime", "api", "repository", "wiki", "public"]
DocumentationType = Literal["admin", "user"]
@dataclass(frozen=True, slots=True)
class DocumentationLink:
label: str
href: str
kind: DocumentationLinkKind = "runtime"
@dataclass(frozen=True, slots=True)
class DocumentationCondition:
required_modules: tuple[str, ...] = ()
any_modules: tuple[str, ...] = ()
missing_modules: tuple[str, ...] = ()
required_capabilities: tuple[str, ...] = ()
required_scopes: tuple[str, ...] = ()
any_scopes: tuple[str, ...] = ()
configuration_keys: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class DocumentationTopic:
id: str
title: str
summary: str
body: str = ""
layer: DocumentationLayer = "configured"
documentation_types: tuple[DocumentationType, ...] = ("admin",)
audience: tuple[str, ...] = ()
order: int = 100
conditions: tuple[DocumentationCondition, ...] = ()
links: tuple[DocumentationLink, ...] = ()
related_modules: tuple[str, ...] = ()
unlocks: tuple[str, ...] = ()
configuration_keys: tuple[str, ...] = ()
i18n_key: str | None = None
translations: Mapping[str, Mapping[str, str]] = field(default_factory=dict)
source_module_id: str | None = None
metadata: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DocumentationContext:
registry: object
principal: object | None = None
settings: object | None = None
session: object | None = None
documentation_type: DocumentationType = "admin"
locale: str = "en"
data: Mapping[str, Any] = field(default_factory=dict)
class ResourceAclProvider(Protocol):
resource_type: str
@@ -146,6 +204,7 @@ TenantSummaryProvider = Callable[[object, str], Mapping[str, int]]
DeleteVetoProvider = Callable[[object, str, str], None]
RouteFactory = Callable[[ModuleContext], "APIRouter"]
CapabilityFactory = Callable[[ModuleContext], object]
DocumentationProvider = Callable[[DocumentationContext], Iterable[DocumentationTopic]]
LifecycleHook = Callable[[ModuleContext], None]
@@ -170,3 +229,5 @@ class ModuleManifest:
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
on_activate: LifecycleHook | None = None
on_deactivate: LifecycleHook | None = None
documentation: tuple[DocumentationTopic, ...] = ()
documentation_providers: tuple[DocumentationProvider, ...] = ()

View File

@@ -0,0 +1,85 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
ORGANIZATIONS_MODULE_ID = "organizations"
CAPABILITY_ORGANIZATION_DIRECTORY = "organizations.directory"
OrganizationStatus = Literal["active", "inactive", "suspended"]
FunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
@dataclass(frozen=True, slots=True)
class OrganizationUnitRef:
id: str
tenant_id: str
slug: str
name: str
parent_id: str | None = None
description: str | None = None
status: OrganizationStatus = "active"
@dataclass(frozen=True, slots=True)
class OrganizationFunctionRef:
id: str
tenant_id: str
organization_unit_id: str
slug: str
name: str
description: str | None = None
delegable: bool = False
act_in_place_allowed: bool = False
status: OrganizationStatus = "active"
@dataclass(frozen=True, slots=True)
class OrganizationFunctionAssignmentRef:
id: str
tenant_id: str
account_id: str
function_id: str
organization_unit_id: str
identity_id: str | None = None
applies_to_subunits: bool = False
source: FunctionAssignmentSource = "direct"
delegated_from_assignment_id: str | None = None
acting_for_account_id: str | None = None
valid_from: datetime | None = None
valid_until: datetime | None = None
status: OrganizationStatus = "active"
@runtime_checkable
class OrganizationDirectory(Protocol):
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
...
def organization_units_for_tenant(self, tenant_id: str) -> Sequence[OrganizationUnitRef]:
...
def get_function(self, function_id: str) -> OrganizationFunctionRef | None:
...
def functions_for_organization_unit(
self,
organization_unit_id: str,
*,
include_subunits: bool = False,
) -> Sequence[OrganizationFunctionRef]:
...
def get_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
...
def function_assignments_for_account(
self,
account_id: str,
*,
tenant_id: str | None = None,
) -> Sequence[OrganizationFunctionAssignmentRef]:
...

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
import base64
import hashlib
import json
from datetime import date, datetime
from decimal import Decimal
from typing import Any, Mapping
KEYSET_CURSOR_PREFIX = "ks1:"
class KeysetCursorError(ValueError):
"""Raised when a keyset cursor cannot be decoded for the current query."""
def keyset_query_fingerprint(scope: str, params: Mapping[str, Any]) -> str:
payload = {"scope": scope, "params": _jsonable(params)}
raw = json.dumps(payload, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24]
def encode_keyset_cursor(scope: str, *, fingerprint: str, values: Mapping[str, Any]) -> str:
payload = {
"v": 1,
"scope": scope,
"fingerprint": fingerprint,
"values": _jsonable(values),
}
raw = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
encoded = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
return f"{KEYSET_CURSOR_PREFIX}{encoded}"
def decode_keyset_cursor(scope: str, cursor: str | None, *, fingerprint: str | None = None) -> dict[str, Any] | None:
if cursor is None or not cursor.strip():
return None
raw_cursor = cursor.strip()
if not raw_cursor.startswith(KEYSET_CURSOR_PREFIX):
raise KeysetCursorError("Invalid pagination cursor")
encoded = raw_cursor[len(KEYSET_CURSOR_PREFIX):]
try:
padded = encoded + ("=" * (-len(encoded) % 4))
payload = json.loads(base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8"))
except (UnicodeDecodeError, ValueError, json.JSONDecodeError) as exc:
raise KeysetCursorError("Invalid pagination cursor") from exc
if not isinstance(payload, dict) or payload.get("v") != 1:
raise KeysetCursorError("Invalid pagination cursor")
if payload.get("scope") != scope:
raise KeysetCursorError("Pagination cursor does not match this endpoint")
if fingerprint is not None and payload.get("fingerprint") != fingerprint:
raise KeysetCursorError("Pagination cursor does not match the current query")
values = payload.get("values")
if not isinstance(values, dict):
raise KeysetCursorError("Invalid pagination cursor")
return values
def _jsonable(value: Any) -> Any:
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, date):
return value.isoformat()
if isinstance(value, Decimal):
return str(value)
if isinstance(value, Mapping):
return {str(key): _jsonable(item) for key, item in sorted(value.items(), key=lambda pair: str(pair[0]))}
if isinstance(value, (list, tuple, set)):
return [_jsonable(item) for item in value]
return value

View File

@@ -0,0 +1,124 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Iterable, Literal, Mapping, cast
from urllib.parse import quote, unquote
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign")
def normalize_policy_scope_type(scope_type: str) -> PolicyScopeType:
clean = scope_type.strip().casefold()
if clean not in POLICY_SCOPE_TYPES:
raise ValueError("Policy scope must be system, tenant, user, group or campaign")
return cast(PolicyScopeType, clean)
@dataclass(frozen=True, slots=True)
class PolicySourceRef:
scope_type: PolicyScopeType
scope_id: str | None = None
@property
def path(self) -> str:
return policy_source_path(self.scope_type, self.scope_id)
def to_dict(self) -> dict[str, Any]:
return {"scope_type": self.scope_type, "scope_id": self.scope_id, "path": self.path}
def policy_source_path(scope_type: str, scope_id: str | None = None) -> str:
clean_scope = normalize_policy_scope_type(scope_type)
if clean_scope == "system":
if scope_id:
raise ValueError("System policy sources do not carry a scope_id")
return "system"
if not scope_id:
raise ValueError(f"{clean_scope.capitalize()} policy sources require a scope_id")
return f"{clean_scope}:{quote(str(scope_id), safe='')}"
def parse_policy_source_path(path: str) -> PolicySourceRef:
clean_path = path.strip()
if clean_path == "system":
return PolicySourceRef(scope_type="system")
scope_type, separator, encoded_scope_id = clean_path.partition(":")
if not separator:
raise ValueError("Policy source path must be system or <scope_type>:<url-encoded-scope-id>")
clean_scope = normalize_policy_scope_type(scope_type)
if clean_scope == "system":
raise ValueError("System policy source path must be exactly system")
scope_id = unquote(encoded_scope_id)
if not scope_id:
raise ValueError("Policy source path requires a non-empty scope id")
return PolicySourceRef(scope_type=clean_scope, scope_id=scope_id)
@dataclass(frozen=True, slots=True)
class PolicySourceStep:
scope_type: PolicyScopeType
label: str
scope_id: str | None = None
applied_fields: tuple[str, ...] = ()
policy: Mapping[str, Any] = field(default_factory=dict)
@property
def path(self) -> str:
return policy_source_path(self.scope_type, self.scope_id)
def to_dict(self) -> dict[str, Any]:
return {
"scope_type": self.scope_type,
"scope_id": self.scope_id,
"path": self.path,
"label": self.label,
"applied_fields": list(self.applied_fields),
"policy": dict(self.policy),
}
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> PolicySourceStep:
policy_value = value.get("policy")
return cls(
scope_type=normalize_policy_scope_type(str(value.get("scope_type", ""))),
scope_id=str(value["scope_id"]) if value.get("scope_id") is not None else None,
label=str(value.get("label") or ""),
applied_fields=tuple(str(field) for field in (value.get("applied_fields") or ())),
policy=policy_value if isinstance(policy_value, Mapping) else {},
)
def policy_source_step(
scope_type: str,
label: str,
scope_id: str | None,
applied_fields: Iterable[str] = (),
policy: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
return PolicySourceStep(
scope_type=normalize_policy_scope_type(scope_type),
scope_id=scope_id,
label=label,
applied_fields=tuple(str(field) for field in applied_fields),
policy=dict(policy or {}),
).to_dict()
@dataclass(frozen=True, slots=True)
class PolicyDecision:
allowed: bool
reason: str | None = None
source_path: tuple[PolicySourceStep, ...] = ()
requirements: tuple[str, ...] = ()
details: Mapping[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"allowed": self.allowed,
"reason": self.reason,
"source_path": [step.to_dict() for step in self.source_path],
"requirements": list(self.requirements),
"details": dict(self.details),
}

View File

@@ -14,9 +14,13 @@ from govoplan_core.core.modules import (
PermissionDefinition,
ResourceAclProvider,
RoleTemplate,
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
SUPPORTED_MANIFEST_CONTRACT_VERSION,
TenantSummaryProvider,
)
_MODULE_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
_NPM_PACKAGE_RE = re.compile(r"^(?:@[a-z0-9][a-z0-9_.-]*/)?[a-z0-9][a-z0-9_.-]*$")
_SCOPE_RE = re.compile(r"^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:[a-z][a-z0-9_]*$")
_WILDCARD_RE = re.compile(r"^([a-z][a-z0-9_]*|\*):\*$|^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:\*$")
@@ -150,6 +154,7 @@ class PlatformRegistry:
ordered = tuple(self._topologically_sorted())
seen_permissions: dict[str, PermissionDefinition] = {}
for manifest in ordered:
_validate_manifest_shape(manifest)
for dependency in manifest.dependencies:
if dependency not in self._manifests:
raise RegistryError(f"Module {manifest.id!r} depends on unknown module {dependency!r}")
@@ -207,3 +212,73 @@ class PlatformRegistry:
unresolved = ", ".join(sorted(module_id for module_id, dependencies in incoming.items() if dependencies))
raise RegistryError(f"Module dependency cycle or unresolved dependency: {unresolved}")
return (self._manifests[module_id] for module_id in ordered)
def _validate_manifest_shape(manifest: ModuleManifest) -> None:
if not _MODULE_ID_RE.match(manifest.id):
raise RegistryError(f"Module manifest id must match {_MODULE_ID_RE.pattern}: {manifest.id!r}")
if not manifest.name.strip():
raise RegistryError(f"Module {manifest.id!r} must declare a non-empty name")
if not manifest.version.strip():
raise RegistryError(f"Module {manifest.id!r} must declare a non-empty version")
if manifest.compatibility.manifest_contract_version != SUPPORTED_MANIFEST_CONTRACT_VERSION:
raise RegistryError(
f"Module {manifest.id!r} uses unsupported manifest contract version "
f"{manifest.compatibility.manifest_contract_version!r}; supported version is "
f"{SUPPORTED_MANIFEST_CONTRACT_VERSION!r}"
)
_validate_dependency_list(manifest.id, "dependencies", manifest.dependencies)
_validate_dependency_list(manifest.id, "optional_dependencies", manifest.optional_dependencies)
overlap = set(manifest.dependencies) & set(manifest.optional_dependencies)
if overlap:
joined = ", ".join(sorted(overlap))
raise RegistryError(f"Module {manifest.id!r} lists dependencies as both required and optional: {joined}")
if manifest.migration_spec is not None:
if manifest.migration_spec.module_id != manifest.id:
raise RegistryError(f"Module {manifest.id!r} has migration spec for {manifest.migration_spec.module_id!r}")
if manifest.migration_spec.metadata is None and not manifest.migration_spec.script_location:
raise RegistryError(f"Module {manifest.id!r} migration spec must declare metadata or script location")
if manifest.frontend is not None:
frontend = manifest.frontend
if frontend.module_id != manifest.id:
raise RegistryError(f"Module {manifest.id!r} has frontend metadata for {frontend.module_id!r}")
if frontend.asset_manifest_contract_version != SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION:
raise RegistryError(
f"Module {manifest.id!r} uses unsupported frontend asset manifest contract version "
f"{frontend.asset_manifest_contract_version!r}; supported version is "
f"{SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION!r}"
)
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
for route in (*frontend.routes, *frontend.settings_routes):
if not route.path.startswith("/"):
raise RegistryError(f"Frontend route for module {manifest.id!r} must start with '/': {route.path!r}")
if not route.component.strip():
raise RegistryError(f"Frontend route {route.path!r} for module {manifest.id!r} must declare a component")
for item in frontend.nav_items:
_validate_nav_item(manifest.id, item)
for item in manifest.nav_items:
_validate_nav_item(manifest.id, item)
def _validate_dependency_list(module_id: str, field_name: str, dependencies: tuple[str, ...]) -> None:
if len(dependencies) != len(set(dependencies)):
raise RegistryError(f"Module {module_id!r} has duplicate {field_name}")
for dependency in dependencies:
if dependency == module_id:
raise RegistryError(f"Module {module_id!r} cannot depend on itself")
if not _MODULE_ID_RE.match(dependency):
raise RegistryError(f"Module {module_id!r} has invalid dependency id {dependency!r}")
def _validate_nav_item(module_id: str, item: NavItem) -> None:
if not item.path.startswith("/"):
raise RegistryError(f"Navigation item for module {module_id!r} must start with '/': {item.path!r}")
if not item.label.strip():
raise RegistryError(f"Navigation item {item.path!r} for module {module_id!r} must declare a label")
if item.icon is not None and not item.icon.strip():
raise RegistryError(f"Navigation item {item.path!r} for module {module_id!r} has an empty icon name")

View File

@@ -29,6 +29,7 @@ def create_all_tables() -> None:
# Build the configured registry so enabled module manifests register their
# model metadata with the shared SQLAlchemy base before create_all runs.
from govoplan_core.admin import models as core_admin_models # noqa: F401
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules)

View File

@@ -10,6 +10,8 @@ from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect, text
from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.db.session import configure_database
from govoplan_core.server.default_config import get_server_config
@@ -26,8 +28,27 @@ from govoplan_core.settings import settings
REVISION_AUTH_RBAC = "2c3d4e5f6a7b"
REVISION_FILE_STORAGE = "3d4e5f6a7b8c"
REVISION_FILE_FOLDERS = "4e5f6a7b8c9d"
REVISION_NAMESPACE_PLATFORM_TABLES = "2e3f4a5b6c7d"
REVISION_CORE_CHANGE_SEQUENCE = "3f4a5b6c7d8e"
REVISION_HIERARCHICAL_SETTINGS = "f5a6b7c8d9e0"
_NAMESPACE_TABLE_RENAMES = (
("tenants", "tenancy_tenants"),
("accounts", "access_accounts"),
("users", "access_users"),
("groups", "access_groups"),
("roles", "access_roles"),
("system_role_assignments", "access_system_role_assignments"),
("user_group_memberships", "access_user_group_memberships"),
("user_role_assignments", "access_user_role_assignments"),
("group_role_assignments", "access_group_role_assignments"),
("api_keys", "access_api_keys"),
("auth_sessions", "access_auth_sessions"),
("system_settings", "core_system_settings"),
("governance_templates", "admin_governance_templates"),
("governance_template_assignments", "admin_governance_template_assignments"),
)
_FILE_STORAGE_TABLES = {
"file_blobs",
"file_assets",
@@ -74,21 +95,21 @@ _FILE_STORAGE_COLUMNS = {
}
_CREATE_ALL_THROUGH_HIERARCHICAL_TABLES = {
"accounts",
"auth_sessions",
"access_accounts",
"access_auth_sessions",
"audit_log",
"campaign_versions",
"campaign_jobs",
"send_attempts",
"system_role_assignments",
"system_settings",
"governance_templates",
"governance_template_assignments",
"access_system_role_assignments",
"core_system_settings",
"admin_governance_templates",
"admin_governance_template_assignments",
"mail_server_profiles",
}
_CREATE_ALL_THROUGH_HIERARCHICAL_COLUMNS = {
"auth_sessions": {"account_id", "csrf_token_hash"},
"access_auth_sessions": {"account_id", "csrf_token_hash"},
"audit_log": {"scope", "tenant_id", "user_id", "api_key_id"},
"campaign_versions": {
"workflow_state",
@@ -102,13 +123,13 @@ _CREATE_ALL_THROUGH_HIERARCHICAL_COLUMNS = {
},
"campaign_jobs": {"claimed_at", "claim_token", "smtp_started_at", "outcome_unknown_at", "eml_sha256"},
"send_attempts": {"status", "claim_token"},
"users": {"account_id", "settings", "mail_profile_policy"},
"groups": {"system_template_id", "system_required", "settings", "mail_profile_policy"},
"roles": {"system_template_id", "system_required"},
"tenants": {"settings", "allow_custom_groups", "allow_custom_roles", "allow_api_keys"},
"access_users": {"account_id", "settings", "mail_profile_policy"},
"access_groups": {"system_template_id", "system_required", "settings", "mail_profile_policy"},
"access_roles": {"system_template_id", "system_required"},
"tenancy_tenants": {"settings", "allow_custom_groups", "allow_custom_roles", "allow_api_keys"},
"campaigns": {"settings", "mail_profile_policy"},
"mail_server_profiles": {"scope_type", "scope_id"},
"system_settings": {"settings", "allow_tenant_custom_groups", "allow_tenant_custom_roles", "allow_tenant_api_keys"},
"core_system_settings": {"settings", "allow_tenant_custom_groups", "allow_tenant_custom_roles", "allow_tenant_api_keys"},
}
_CREATE_ALL_THROUGH_HIERARCHICAL_INDEXES = {
@@ -121,10 +142,10 @@ _CREATE_ALL_THROUGH_HIERARCHICAL_INDEXES = {
"campaign_jobs": {"ix_campaign_jobs_claim_token", "ix_campaign_jobs_eml_sha256"},
"send_attempts": {"ix_send_attempts_status", "ix_send_attempts_claim_token"},
"audit_log": {"ix_audit_log_scope_created_at", "ix_audit_log_tenant_scope_created_at"},
"auth_sessions": {"ix_auth_sessions_account_id"},
"users": {"ix_users_account_id"},
"groups": {"ix_groups_system_template_id"},
"roles": {"ix_roles_system_template_id"},
"access_auth_sessions": {"ix_access_auth_sessions_account_id"},
"access_users": {"ix_access_users_account_id"},
"access_groups": {"ix_access_groups_system_template_id"},
"access_roles": {"ix_access_roles_system_template_id"},
"mail_server_profiles": {"ix_mail_server_profiles_scope_type", "ix_mail_server_profiles_scope_id", "ix_mail_server_profiles_scope"},
}
@@ -258,6 +279,111 @@ def _backfill_user_lock_state_for_create_all_schema(database_url: str) -> None:
engine.dispose()
def _row_count(connection, table_name: str) -> int:
quoted = connection.dialect.identifier_preparer.quote(table_name)
return int(connection.execute(text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one())
def _drop_table(connection, table_name: str) -> None:
quoted = connection.dialect.identifier_preparer.quote(table_name)
connection.execute(text(f"DROP TABLE {quoted}"))
def _rename_table(connection, old_name: str, new_name: str) -> None:
quoted_old = connection.dialect.identifier_preparer.quote(old_name)
quoted_new = connection.dialect.identifier_preparer.quote(new_name)
connection.execute(text(f"ALTER TABLE {quoted_old} RENAME TO {quoted_new}"))
def reconcile_namespace_table_drift(database_url: str | None = None) -> bool:
"""Repair dev databases stamped past the namespace-table migration.
During the repository split some development databases were stamped at the
newer Alembic heads while still carrying the old platform table names. The
real migration only renames tables, so replay that idempotent operation
before startup bootstrap code touches the ORM.
"""
url = database_url or settings.database_url
changed = False
engine = create_engine(url)
try:
with engine.begin() as connection:
schema = inspect(connection)
tables = set(schema.get_table_names())
if not any(old_name in tables and new_name not in tables for old_name, new_name in _NAMESPACE_TABLE_RENAMES):
return False
old_tables_to_drop: set[str] = set()
new_tables_to_drop: set[str] = set()
for old_name, new_name in _NAMESPACE_TABLE_RENAMES:
if old_name not in tables or new_name not in tables:
continue
if _row_count(connection, old_name) == 0:
old_tables_to_drop.add(old_name)
elif _row_count(connection, new_name) == 0:
new_tables_to_drop.add(new_name)
else:
raise RuntimeError(f"Cannot reconcile non-empty {old_name} over non-empty {new_name}")
for old_name, _new_name in reversed(_NAMESPACE_TABLE_RENAMES):
if old_name in old_tables_to_drop:
_drop_table(connection, old_name)
tables.remove(old_name)
changed = True
for _old_name, new_name in reversed(_NAMESPACE_TABLE_RENAMES):
if new_name in new_tables_to_drop:
_drop_table(connection, new_name)
tables.remove(new_name)
changed = True
for old_name, new_name in _NAMESPACE_TABLE_RENAMES:
if old_name not in tables or new_name in tables:
continue
_rename_table(connection, old_name, new_name)
tables.remove(old_name)
tables.add(new_name)
changed = True
finally:
engine.dispose()
return changed
def reconcile_change_sequence_retention_floor_drift(database_url: str | None = None) -> bool:
"""Repair databases stamped after the change-sequence migration changed.
Early development databases may have applied the change-sequence revision
before the retention-floor table was added to that migration. The main
change-sequence table is enough for full snapshots, but incremental delta
requests need the retention floor to decide whether a watermark is stale.
"""
url = database_url or settings.database_url
changed = False
engine = create_engine(url)
try:
with engine.begin() as connection:
schema = inspect(connection)
tables = set(schema.get_table_names())
if ChangeSequenceEntry.__tablename__ not in tables:
return False
if ChangeSequenceRetentionFloor.__tablename__ not in tables:
ChangeSequenceRetentionFloor.__table__.create(bind=connection, checkfirst=True)
changed = True
else:
indexes = {
index["name"]
for index in schema.get_indexes(ChangeSequenceRetentionFloor.__tablename__)
}
for index in ChangeSequenceRetentionFloor.__table__.indexes:
if index.name not in indexes:
index.create(bind=connection)
changed = True
finally:
engine.dispose()
return changed
def reconcile_legacy_create_all_schema(database_url: str | None = None) -> str | None:
"""Repair the known Alembic/create_all drift without modifying application data.
@@ -327,6 +453,9 @@ def migrate_database(
manifest_factories: tuple[ManifestFactory, ...] = (),
) -> MigrationResult:
url = database_url or settings.database_url
if reconcile_legacy_schema:
reconcile_namespace_table_drift(url)
reconcile_change_sequence_retention_floor_drift(url)
previous = database_revision(url)
reconciled = reconcile_legacy_create_all_schema(url) if reconcile_legacy_schema else None
command.upgrade(

View File

@@ -38,15 +38,21 @@ class DatabaseHandle:
with self.SessionLocal() as session:
yield session
def dispose(self) -> None:
self.engine.dispose()
_default_database: DatabaseHandle | None = None
def configure_database(database_url: str, *, engine: Engine | None = None) -> DatabaseHandle:
def configure_database(database_url: str, *, engine: Engine | None = None, dispose_previous: bool = False) -> DatabaseHandle:
global _default_database
if engine is None and _default_database is not None and _default_database.database_url == database_url:
return _default_database
previous_database = _default_database
_default_database = DatabaseHandle(database_url, engine=engine)
if dispose_previous and previous_database is not None and previous_database is not _default_database:
previous_database.dispose()
return _default_database
@@ -56,6 +62,13 @@ def set_database(handle: DatabaseHandle) -> DatabaseHandle:
return handle
def reset_database(*, dispose: bool = False) -> None:
global _default_database
if dispose and _default_database is not None:
_default_database.dispose()
_default_database = None
def get_database() -> DatabaseHandle:
if _default_database is None:
raise RuntimeError("GovOPlaN database is not configured")

View File

@@ -20,6 +20,9 @@ from govoplan_core.server.registry import available_module_manifests, build_plat
DEFAULT_APP = "govoplan_core.server.app:app"
DEFAULT_CONFIG = "govoplan_core.server.default_config:get_server_config"
DEFAULT_DEV_DATABASE_URL = "postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev"
DEFAULT_DEV_DATABASE_URL_PGTOOLS = "postgresql://govoplan_dev@127.0.0.1:5432/govoplan_dev"
DEFAULT_SQLITE_DATABASE_NAME = "multimailer-dev.db"
@dataclass(slots=True)
@@ -61,13 +64,23 @@ def apply_runtime_defaults(config_path: str | None) -> Path | None:
(runtime_root / "runtime" / "files").mkdir(parents=True, exist_ok=True)
(runtime_root / "runtime" / "mock-mailbox").mkdir(parents=True, exist_ok=True)
dev_database_backend = os.getenv("GOVOPLAN_DEV_DATABASE_BACKEND", "postgres").strip().lower()
default_database_url = DEFAULT_DEV_DATABASE_URL
default_pgtools_url = DEFAULT_DEV_DATABASE_URL_PGTOOLS
if dev_database_backend == "sqlite":
default_database_url = f"sqlite:///{runtime_root / 'runtime' / DEFAULT_SQLITE_DATABASE_NAME}"
default_pgtools_url = ""
defaults = {
"DATABASE_URL": f"sqlite:///{runtime_root / 'runtime' / 'multimailer-dev.db'}",
"DATABASE_URL": os.getenv("GOVOPLAN_DEV_DATABASE_URL", default_database_url),
"DEV_BOOTSTRAP_ENABLED": "true",
"FILE_STORAGE_LOCAL_ROOT": str(runtime_root / "runtime" / "files"),
"MOCK_MAILBOX_DIR": str(runtime_root / "runtime" / "mock-mailbox"),
}
for key, value in defaults.items():
os.environ.setdefault(key, value)
if default_pgtools_url:
os.environ.setdefault("GOVOPLAN_DATABASE_URL_PGTOOLS", os.getenv("GOVOPLAN_DEV_DATABASE_URL_PGTOOLS", default_pgtools_url))
return runtime_root
@@ -95,7 +108,7 @@ def validate_sqlite_database_url(database_url: str) -> None:
raise SystemExit(
f"DATABASE_URL points to {db_path}, but its parent directory does not exist. "
"Unset stale DATABASE_URL or set it to "
f"sqlite:///{Path.cwd().resolve() / 'runtime' / 'multimailer-dev.db'}."
f"sqlite:///{Path.cwd().resolve() / 'runtime' / DEFAULT_SQLITE_DATABASE_NAME}."
)
@@ -285,6 +298,10 @@ def print_devserver_summary(state: DevserverState, *, app: str, no_reload: bool)
print(f"Runtime root: {state.runtime_root}")
if state.database_url:
print(f"Database: {state.database_url}")
if state.database_url.startswith("postgresql"):
pgtools_url = os.getenv("GOVOPLAN_DATABASE_URL_PGTOOLS")
if pgtools_url:
print(f"PostgreSQL tools URL: {pgtools_url}")
if state.bootstrap_db_path is not None:
bootstrap_state = "enabled" if getattr(state.config.settings, "dev_bootstrap_enabled", False) else "disabled by DEV_BOOTSTRAP_ENABLED"
print(f"Dev bootstrap for missing SQLite DB: {bootstrap_state} ({state.bootstrap_db_path})")

152
src/govoplan_core/i18n.py Normal file
View File

@@ -0,0 +1,152 @@
from __future__ import annotations
import re
from typing import Any, Iterable
I18N_SETTINGS_KEY = "i18n"
DEFAULT_LANGUAGE_PACKAGES: tuple[dict[str, str], ...] = (
{"code": "en", "label": "English", "native_label": "English"},
{"code": "de", "label": "German", "native_label": "Deutsch"},
)
def normalize_language_code(value: object) -> str:
raw = str(value or "").strip().lower().replace("_", "-")
parts = [part for part in re.split(r"[^a-z0-9]+", raw) if part]
return "-".join(parts)
def language_code_candidates(value: object) -> tuple[str, ...]:
code = normalize_language_code(value)
if not code:
return ()
primary = code.split("-", 1)[0]
return tuple(dict.fromkeys((code, primary)))
def resolve_language_code(value: object, allowed_codes: Iterable[str]) -> str:
allowed = {normalize_language_code(code) for code in allowed_codes if normalize_language_code(code)}
for candidate in language_code_candidates(value):
if candidate in allowed:
return candidate
return ""
def i18n_settings(settings: dict[str, Any] | None) -> dict[str, Any]:
raw = (settings or {}).get(I18N_SETTINGS_KEY)
return dict(raw) if isinstance(raw, dict) else {}
def normalize_language_packages(raw_packages: object = None) -> list[dict[str, str]]:
packages: dict[str, dict[str, str]] = {}
for item in DEFAULT_LANGUAGE_PACKAGES:
packages[item["code"]] = dict(item)
if isinstance(raw_packages, (list, tuple)):
for raw in raw_packages:
if not isinstance(raw, dict):
continue
code = normalize_language_code(raw.get("code"))
if not code:
continue
label = str(raw.get("label") or code.upper()).strip()
native_label = str(raw.get("native_label") or raw.get("nativeLabel") or label).strip()
packages[code] = {"code": code, "label": label, "native_label": native_label}
return list(packages.values())
def system_language_packages(settings: dict[str, Any] | None) -> list[dict[str, str]]:
return normalize_language_packages(i18n_settings(settings).get("available_languages"))
def normalize_enabled_language_codes(
codes: object,
available_languages: Iterable[dict[str, Any]],
*,
default_locale: object = None,
fallback_codes: Iterable[str] = ("en", "de"),
) -> list[str]:
available_codes = [normalize_language_code(item.get("code")) for item in available_languages if isinstance(item, dict)]
available = {code for code in available_codes if code}
requested: list[str] = []
if isinstance(codes, (list, tuple, set)):
requested = [normalize_language_code(item) for item in codes]
if not requested:
requested = [normalize_language_code(item) for item in fallback_codes]
enabled: list[str] = []
for item in requested:
code = resolve_language_code(item, available)
if code and code not in enabled:
enabled.append(code)
default_code = resolve_language_code(default_locale, available)
if default_code and default_code not in enabled:
enabled.insert(0, default_code)
if enabled:
return enabled
if "en" in available:
return ["en"]
return available_codes[:1]
def system_enabled_language_codes(settings: dict[str, Any] | None, *, default_locale: object = None) -> list[str]:
i18n = i18n_settings(settings)
raw_enabled = i18n.get("enabled_language_codes", i18n.get("enabled_languages"))
return normalize_enabled_language_codes(raw_enabled, system_language_packages(settings), default_locale=default_locale)
def tenant_enabled_language_codes(
tenant_settings: dict[str, Any] | None,
system_enabled_codes: Iterable[str],
*,
default_locale: object = None,
) -> list[str]:
enabled = [normalize_language_code(code) for code in system_enabled_codes if normalize_language_code(code)]
available = [{"code": code} for code in enabled]
raw_enabled = i18n_settings(tenant_settings).get("enabled_language_codes")
return normalize_enabled_language_codes(raw_enabled, available, default_locale=default_locale, fallback_codes=enabled)
def user_enabled_language_codes(user_settings: dict[str, Any] | None, tenant_enabled_codes: Iterable[str]) -> list[str]:
enabled = [normalize_language_code(code) for code in tenant_enabled_codes if normalize_language_code(code)]
available = [{"code": code} for code in enabled]
raw_enabled = i18n_settings(user_settings).get("enabled_language_codes")
return normalize_enabled_language_codes(raw_enabled, available, fallback_codes=enabled)
def preferred_language_code(user_settings: dict[str, Any] | None, allowed_codes: Iterable[str], *, default_locale: object = None) -> str:
enabled = [normalize_language_code(code) for code in allowed_codes if normalize_language_code(code)]
preferred = resolve_language_code(i18n_settings(user_settings).get("preferred_language"), enabled)
if preferred:
return preferred
default = resolve_language_code(default_locale, enabled)
if default:
return default
return enabled[0] if enabled else "en"
def update_i18n_settings(settings: dict[str, Any] | None, **values: Any) -> dict[str, Any]:
updated = dict(settings or {})
current = i18n_settings(updated)
current.update(values)
updated[I18N_SETTINGS_KEY] = current
return updated
def system_i18n_payload(settings_item: Any | None) -> dict[str, object]:
settings = getattr(settings_item, "settings", None)
packages = system_language_packages(settings)
available_codes = [item["code"] for item in packages]
default_language = resolve_language_code(getattr(settings_item, "default_locale", None), available_codes) or "en"
enabled = system_enabled_language_codes(settings, default_locale=default_language)
if default_language not in enabled:
default_language = enabled[0] if enabled else "en"
return {
"available_languages": packages,
"enabled_languages": enabled,
"default_language": default_language,
}

View File

@@ -17,6 +17,7 @@ from govoplan_core.core.campaigns import (
CampaignPolicyContextProvider,
CampaignRetentionProvider,
)
from govoplan_core.core.policy import policy_source_step
from govoplan_core.core.runtime import get_registry
from govoplan_access.backend.db.models import Group, User
from govoplan_audit.backend.db.models import AuditLog
@@ -339,13 +340,13 @@ def _retention_policy_source_fields(patch: dict[str, Any], *, baseline: bool = F
def _retention_policy_source_step(scope_type: str, label: str, scope_id: str | None, patch: dict[str, Any], *, baseline: bool = False) -> dict[str, Any]:
source_policy = PrivacyRetentionPolicy.model_validate(patch).model_dump(mode="json") if baseline else dict(patch)
return {
"scope_type": scope_type,
"scope_id": scope_id,
"label": label,
"applied_fields": _retention_policy_source_fields(patch, baseline=baseline),
"policy": source_policy,
}
return policy_source_step(
scope_type,
label,
scope_id,
applied_fields=_retention_policy_source_fields(patch, baseline=baseline),
policy=source_policy,
)
def effective_privacy_policy_sources(

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import base64
import hashlib
from functools import lru_cache
from typing import Protocol, runtime_checkable
from cryptography.fernet import Fernet, InvalidToken
@@ -17,6 +18,21 @@ class SecretDecryptionError(RuntimeError):
pass
CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider"
@runtime_checkable
class SecretProvider(Protocol):
def store_secret(self, *, scope: str, name: str, value: str) -> str:
...
def read_secret(self, secret_ref: str) -> str | None:
...
def delete_secret(self, secret_ref: str) -> None:
...
def _normalize_fernet_key(value: str) -> bytes:
candidate = value.strip().encode("utf-8")
try:

View File

@@ -9,6 +9,7 @@ from govoplan_core.server.config import GovoplanServerConfig, load_server_config
from govoplan_core.server.fastapi import create_govoplan_app
from govoplan_core.server.platform import create_platform_router
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.server.route_validation import validate_no_route_collisions
def create_app(config: GovoplanServerConfig | str | None = None):
@@ -19,7 +20,8 @@ def create_app(config: GovoplanServerConfig | str | None = None):
database_url = getattr(server_config.settings, "database_url", None) if server_config.settings is not None else None
if database_url:
configure_database(str(database_url))
dispose_previous = getattr(server_config.settings, "app_env", None) == "test"
configure_database(str(database_url), dispose_previous=dispose_previous)
raw_enabled_modules = load_startup_enabled_modules(server_config.enabled_modules)
candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
@@ -55,6 +57,8 @@ def create_app(config: GovoplanServerConfig | str | None = None):
if contribution.should_include(server_config.settings, registry):
api_router.include_router(contribution.router)
validate_no_route_collisions(api_router, owner="server startup routes")
app = create_govoplan_app(
title=server_config.title,
version=server_config.version,

View File

@@ -0,0 +1,123 @@
from __future__ import annotations
import hashlib
from collections.abc import Awaitable, Callable
from fastapi import Request
from starlette.responses import Response
JSON_CACHE_CONTROL = "private, no-cache"
JSON_ETAG_VARY_HEADERS = ("Authorization", "Cookie", "X-API-Key", "Accept-Language")
async def conditional_json_get_middleware(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
"""Attach ETags to JSON GET responses and return 304 when unchanged.
The middleware deliberately works after route handling. That keeps the
contract platform-wide without requiring every module router to learn about
conditional requests, while still limiting buffering to successful JSON GET
responses.
"""
response = await call_next(request)
if not _eligible_for_conditional_json_get(request, response):
return response
body = b"".join([chunk async for chunk in response.body_iterator])
etag = response.headers.get("etag") or json_response_etag(body)
headers = dict(response.headers)
headers["etag"] = etag
headers["cache-control"] = _conditional_cache_control(headers.get("cache-control"))
headers["vary"] = _merge_vary(headers.get("vary"), JSON_ETAG_VARY_HEADERS)
headers.pop("content-length", None)
if if_none_match_matches(request.headers.get("if-none-match"), etag):
return Response(status_code=304, headers=_not_modified_headers(headers), background=response.background)
return Response(content=body, status_code=response.status_code, headers=headers, background=response.background)
def json_response_etag(body: bytes) -> str:
digest = hashlib.sha256(body).hexdigest()
return f'W/"sha256-{digest}"'
def if_none_match_matches(header_value: str | None, etag: str) -> bool:
if not header_value:
return False
normalized_etag = _normalize_etag_for_weak_compare(etag)
for raw_candidate in header_value.split(","):
candidate = raw_candidate.strip()
if candidate == "*":
return True
if _normalize_etag_for_weak_compare(candidate) == normalized_etag:
return True
return False
def _eligible_for_conditional_json_get(request: Request, response: Response) -> bool:
if request.method.upper() != "GET":
return False
if response.status_code != 200:
return False
if "set-cookie" in response.headers:
return False
if "content-encoding" in response.headers:
return False
if "content-disposition" in response.headers:
return False
if "no-store" in request.headers.get("cache-control", "").lower():
return False
if "no-store" in response.headers.get("cache-control", "").lower():
return False
return _is_json_content_type(response.headers.get("content-type"))
def _is_json_content_type(value: str | None) -> bool:
media_type = (value or "").split(";", 1)[0].strip().lower()
return media_type == "application/json" or media_type.endswith("+json")
def _conditional_cache_control(current: str | None) -> str:
if not current:
return JSON_CACHE_CONTROL
directives = [item.strip() for item in current.split(",") if item.strip()]
normalized = {item.split("=", 1)[0].strip().lower() for item in directives}
if "private" not in normalized and "public" not in normalized:
directives.append("private")
if "no-cache" not in normalized:
directives.append("no-cache")
return ", ".join(directives)
def _merge_vary(current: str | None, additions: tuple[str, ...]) -> str:
tokens: list[str] = []
seen: set[str] = set()
for value in [current or "", *additions]:
for token in value.split(","):
stripped = token.strip()
if not stripped:
continue
normalized = stripped.lower()
if normalized in seen:
continue
seen.add(normalized)
tokens.append(stripped)
return ", ".join(tokens)
def _not_modified_headers(headers: dict[str, str]) -> dict[str, str]:
allowed = {"cache-control", "content-location", "date", "etag", "expires", "vary", "x-correlation-id"}
return {key: value for key, value in headers.items() if key.lower() in allowed}
def _normalize_etag_for_weak_compare(value: str) -> str:
normalized = value.strip()
if normalized.startswith("W/"):
normalized = normalized[2:].strip()
if len(normalized) >= 2 and normalized[0] == '"' and normalized[-1] == '"':
normalized = normalized[1:-1]
return normalized

View File

@@ -3,8 +3,9 @@ from __future__ import annotations
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI
from sqlalchemy.engine import make_url
from govoplan_access.backend.auth.dependencies import ApiPrincipal, require_scope
from govoplan_access.auth import ApiPrincipal, require_scope
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.bootstrap import bootstrap_dev_data, create_all_tables
from govoplan_core.db.session import get_database
@@ -12,6 +13,13 @@ from govoplan_core.server.config import GovoplanServerConfig
from govoplan_core.settings import Settings, settings
def _dev_bootstrap_needs_create_all(database_url: str) -> bool:
try:
return make_url(database_url).get_backend_name() == "sqlite"
except Exception:
return database_url.startswith("sqlite:")
@asynccontextmanager
async def lifespan(app: FastAPI):
if settings.app_env.lower() == "dev" and settings.dev_auto_migrate_enabled:
@@ -19,7 +27,8 @@ async def lifespan(app: FastAPI):
migrate_database(database_url=settings.database_url)
if settings.app_env.lower() == "dev" and settings.dev_bootstrap_enabled:
create_all_tables()
if _dev_bootstrap_needs_create_all(settings.database_url):
create_all_tables()
with get_database().SessionLocal() as session:
bootstrap_dev_data(
session,

View File

@@ -4,10 +4,12 @@ from collections.abc import AsyncIterator, Callable, Iterable
from contextlib import AbstractAsyncContextManager
from typing import Any
from fastapi import APIRouter, FastAPI
from fastapi import APIRouter, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from govoplan_core.core.events import event_context, new_event_id, normalize_trace_id
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.server.conditional_requests import conditional_json_get_middleware
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
@@ -25,6 +27,20 @@ def create_govoplan_app(
app = FastAPI(title=title, version=version, lifespan=lifespan)
app.state.govoplan_registry = registry
@app.middleware("http")
async def request_correlation_context(request: Request, call_next):
correlation_id = (
normalize_trace_id(request.headers.get("x-correlation-id"))
or normalize_trace_id(request.headers.get("x-request-id"))
or new_event_id()
)
with event_context(correlation_id=correlation_id):
response = await call_next(request)
response.headers["X-Correlation-ID"] = correlation_id
return response
app.middleware("http")(conditional_json_get_middleware)
origins = [item.strip() for item in cors_origins if item.strip()]
if origins:
app.add_middleware(

View File

@@ -3,10 +3,13 @@ from __future__ import annotations
from fastapi import APIRouter, 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.core.maintenance import saved_maintenance_mode
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.session import get_database
from govoplan_core.i18n import system_i18n_payload
def _registry(request: Request) -> PlatformRegistry:
@@ -72,10 +75,13 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
try:
with get_database().session() as session:
maintenance_mode = saved_maintenance_mode(session)
settings_item = session.get(SystemSettings, SYSTEM_SETTINGS_ID)
except (RuntimeError, SQLAlchemyError):
maintenance_mode = None
settings_item = None
return {
"maintenance_mode": maintenance_mode.as_dict() if maintenance_mode is not None else {"enabled": False, "message": None},
"i18n": system_i18n_payload(settings_item),
}
@router.get("/modules")

View File

@@ -0,0 +1,73 @@
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
class RouteCollisionError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class RouteSignature:
method: str
path: str
@dataclass(frozen=True, slots=True)
class RouteRecord:
signature: RouteSignature
owner: str
def validate_no_route_collisions(routes_or_app: object, *, owner: str = "router") -> None:
seen: dict[RouteSignature, RouteRecord] = {}
for record in iter_route_records(routes_or_app, owner=owner):
previous = seen.get(record.signature)
if previous is not None:
raise RouteCollisionError(_collision_message(record.signature, previous.owner, record.owner))
seen[record.signature] = record
def validate_router_can_mount(existing_routes_or_app: object, candidate_router: object, *, prefix: str = "", owner: str) -> None:
validate_no_route_collisions(candidate_router, owner=owner)
existing = {record.signature: record for record in iter_route_records(existing_routes_or_app, owner="existing application")}
for record in iter_route_records(candidate_router, prefix=prefix, owner=owner):
previous = existing.get(record.signature)
if previous is not None:
raise RouteCollisionError(_collision_message(record.signature, previous.owner, record.owner))
def iter_route_records(routes_or_app: object, *, prefix: str = "", owner: str = "router") -> Iterable[RouteRecord]:
routes = getattr(routes_or_app, "routes", routes_or_app)
for route in routes or ():
path = _join_route_path(prefix, str(getattr(route, "path", "") or ""))
include_context = getattr(route, "include_context", None)
nested_prefix = _join_route_path(prefix, str(getattr(include_context, "prefix", "") or ""))
nested_router = getattr(route, "original_router", None)
if nested_router is not None:
yield from iter_route_records(nested_router, prefix=nested_prefix, owner=owner)
methods = getattr(route, "methods", None)
if methods:
for method in sorted(str(item).upper() for item in methods):
yield RouteRecord(RouteSignature(method=method, path=path), owner)
nested_routes = getattr(route, "routes", None)
if nested_routes is not None and nested_routes is not routes:
yield from iter_route_records(nested_routes, prefix=path, owner=owner)
def _join_route_path(prefix: str, path: str) -> str:
if not prefix:
return path or "/"
if not path:
return prefix or "/"
return f"{prefix.rstrip('/')}/{path.lstrip('/')}" or "/"
def _collision_message(signature: RouteSignature, previous_owner: str, next_owner: str) -> str:
return (
f"Route collision: {signature.method} {signature.path} is registered by "
f"{previous_owner} and {next_owner}"
)

View File

@@ -8,7 +8,7 @@ class Settings(BaseSettings):
app_env: str = Field(default="dev", alias="APP_ENV")
database_url: str = Field(
default="sqlite:///./runtime/multimailer-dev.db",
default="postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev",
alias="DATABASE_URL",
)
access_database_url: str | None = Field(default=None, alias="ACCESS_DATABASE_URL")
@@ -17,7 +17,7 @@ class Settings(BaseSettings):
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,access,admin,policy,audit,campaigns,files,mail,calendar", alias="ENABLED_MODULES")
enabled_modules: str = Field(default="tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops", alias="ENABLED_MODULES")
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")