Prepare GovOPlaN self-hosted release workflow
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 50s
Module Matrix / module-matrix (push) Failing after 49s

This commit is contained in:
2026-07-10 21:57:22 +02:00
parent 8dd5123aab
commit 94236a7d7e
51 changed files with 3576 additions and 803 deletions

View File

@@ -1,12 +1,14 @@
from __future__ import annotations
from collections.abc import Mapping
from uuid import uuid4
from typing import Any
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.change_sequence import record_change
from govoplan_core.core.access import AuditEvent, AuditRecordRef, AuditRecorder, CAPABILITY_AUDIT_RECORDER
from govoplan_core.core.events import (
EventActorRef,
EventObjectRef,
@@ -16,8 +18,8 @@ from govoplan_core.core.events import (
normalize_trace_id,
publish_platform_event,
)
from govoplan_core.core.runtime import get_registry
from govoplan_core.privacy.retention import sanitize_audit_details_for_policy
from govoplan_audit.backend.db.models import AuditLog
AUDIT_MODULE_ID = "audit"
@@ -47,6 +49,7 @@ _ACTION_MODULE_PREFIXES = {
"configuration_change": "access",
"configuration_package": "access",
"group": "access",
"idm": "idm",
"profile": "access",
"role": "access",
"system_access": "access",
@@ -169,7 +172,33 @@ def _module_id_for_audit_action(action: str) -> str:
return _ACTION_MODULE_PREFIXES.get(prefix, prefix or AUDIT_MODULE_ID)
def _publish_audit_platform_event(item: AuditLog) -> None:
def _audit_recorder() -> AuditRecorder:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_AUDIT_RECORDER):
return _NullAuditRecorder()
capability = registry.require_capability(CAPABILITY_AUDIT_RECORDER)
if not isinstance(capability, AuditRecorder):
raise RuntimeError("Audit recorder capability is invalid")
return capability
class _NullAuditRecorder:
def record_event(self, session: object, event: AuditEvent) -> AuditRecordRef:
del session
return AuditRecordRef(
id=str(uuid4()),
scope=event.scope,
tenant_id=event.tenant_id,
user_id=event.user_id,
api_key_id=event.api_key_id,
action=event.action,
object_type=event.resource_type,
object_id=event.resource_id,
details=dict(event.details or {}),
)
def _publish_audit_platform_event(item: AuditRecordRef) -> None:
trace = _compact_trace(item.details.get("_trace") if isinstance(item.details, Mapping) else None)
publish_platform_event(
PlatformEvent(
@@ -206,7 +235,7 @@ def audit_event(
correlation_id: str | None = None,
causation_id: str | None = None,
commit: bool = False,
) -> AuditLog:
) -> AuditRecordRef:
"""Persist one audit event.
The function deliberately accepts primitive IDs so it can be used from API
@@ -227,18 +256,17 @@ def audit_event(
trace,
)
item = AuditLog(
item = _audit_recorder().record_event(session, AuditEvent(
event_type=action,
action=action,
scope=scope,
tenant_id=tenant_id,
user_id=user_id,
api_key_id=api_key_id,
action=action,
object_type=object_type,
object_id=object_id,
resource_type=object_type,
resource_id=object_id,
details=stored_details,
)
session.add(item)
session.flush()
))
record_change(
session,
module_id=AUDIT_MODULE_ID,
@@ -269,7 +297,7 @@ def audit_from_principal(
correlation_id: str | None = None,
causation_id: str | None = None,
commit: bool = False,
) -> AuditLog:
) -> AuditRecordRef:
return audit_event(
session,
tenant_id=principal.tenant_id,

View File

@@ -3,11 +3,167 @@ from __future__ import annotations
"""Core auth dependency facade.
Routers depend on this module instead of a concrete access-provider package.
The current implementation delegates to the access module; replacing this
facade is the remaining step toward a fully provider-neutral auth kernel.
The active auth module provides the request principal through the platform
capability registry.
"""
from govoplan_access.auth import ApiPrincipal, get_api_principal, has_scope, require_any_scope, require_scope
from dataclasses import dataclass
from fastapi import Depends, Header, HTTPException, Request, status
from sqlalchemy.orm import Session
from govoplan_core.core.access import (
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
ApiPrincipalProvider,
PermissionEvaluator,
PrincipalRef,
)
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.session import get_session
from govoplan_core.security.module_permissions import scopes_grant_compatible
@dataclass(slots=True)
class ApiPrincipal:
"""Request principal exposed to API routers.
The stable fields live in ``principal``. ``account``, ``user``,
``api_key``, and ``auth_session`` are provider-owned objects kept for
current routers that still need concrete account/session state.
"""
principal: PrincipalRef
account: object
user: object
api_key: object | None = None
auth_session: object | None = None
permission_evaluator: PermissionEvaluator | None = None
@property
def account_id(self) -> str:
return self.principal.account_id
@property
def membership_id(self) -> str | None:
return self.principal.membership_id
@property
def tenant_id(self) -> str:
if self.principal.tenant_id is None:
raise RuntimeError("Tenant principal has no active tenant id.")
return self.principal.tenant_id
@property
def scopes(self) -> frozenset[str]:
return self.principal.scopes
@property
def group_ids(self) -> frozenset[str]:
return self.principal.group_ids
@property
def role_ids(self) -> frozenset[str]:
return self.principal.role_ids
@property
def function_assignment_ids(self) -> frozenset[str]:
return self.principal.function_assignment_ids
@property
def delegation_ids(self) -> frozenset[str]:
return self.principal.delegation_ids
@property
def identity_id(self) -> str | None:
return self.principal.identity_id
@property
def acting_for_account_id(self) -> str | None:
return self.principal.acting_for_account_id
@property
def auth_method(self) -> str:
return self.principal.auth_method
@property
def api_key_id(self) -> str | None:
return self.principal.api_key_id
@property
def session_id(self) -> str | None:
return self.principal.session_id
@property
def email(self) -> str | None:
return self.principal.email
@property
def display_name(self) -> str | None:
return self.principal.display_name
def has(self, required_scope: str) -> bool:
if self.permission_evaluator is not None:
return self.permission_evaluator.has_scope(self.principal, required_scope)
return scopes_grant_compatible(self.scopes, required_scope)
def to_platform_principal(self) -> PrincipalRef:
return self.principal
def _registry_from_request(request: Request) -> PlatformRegistry | None:
registry = getattr(request.app.state, "govoplan_registry", None)
return registry if isinstance(registry, PlatformRegistry) else None
def _api_principal_provider_from_request(request: Request) -> ApiPrincipalProvider:
registry = _registry_from_request(request)
if registry is None or not registry.has_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Auth provider is not available")
capability = registry.require_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER)
if not isinstance(capability, ApiPrincipalProvider):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid auth provider capability")
return capability
def get_api_principal(
request: Request,
session: Session = Depends(get_session),
authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
) -> ApiPrincipal:
principal = _api_principal_provider_from_request(request).resolve_api_principal(
request,
session,
authorization=authorization,
x_api_key=x_api_key,
)
if not isinstance(principal, ApiPrincipal):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid API principal")
return principal
def has_scope(principal: ApiPrincipal, required_scope: str) -> bool:
return principal.has(required_scope)
def require_scope(required_scope: str):
def dependency(principal: ApiPrincipal = Depends(get_api_principal)) -> ApiPrincipal:
if not has_scope(principal, required_scope):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {required_scope}")
return principal
return dependency
def require_any_scope(*required_scopes: str):
def dependency(principal: ApiPrincipal = Depends(get_api_principal)) -> ApiPrincipal:
if not any(has_scope(principal, required) for required in required_scopes):
joined = ", ".join(required_scopes)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Requires one of: {joined}")
return principal
return dependency
__all__ = [
"ApiPrincipal",

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import Sequence
from govoplan_core.core.install_config import env_template, validate_runtime_configuration
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Generate and validate GovOPlaN install/runtime configuration.")
subparsers = parser.add_subparsers(dest="command", required=True)
env_parser = subparsers.add_parser("env-template", help="Print or write an install .env template.")
env_parser.add_argument("--profile", default="self-hosted", choices=("self-hosted", "production-like"), help="Template profile to generate.")
env_parser.add_argument("--output", type=Path, help="Write the template to this path instead of stdout.")
env_parser.add_argument("--overwrite", action="store_true", help="Overwrite an existing output path.")
env_parser.add_argument("--generate-secrets", action="store_true", help="Generate a fresh MASTER_KEY_B64 in the template.")
validate_parser = subparsers.add_parser("validate", help="Validate the current process environment.")
validate_parser.add_argument("--profile", help="Install profile. Defaults to GOVOPLAN_INSTALL_PROFILE or APP_ENV.")
validate_parser.add_argument("--strict", action="store_true", help="Treat warnings as errors.")
validate_parser.add_argument("--format", choices=("text", "json"), default="text", help="Output format.")
return parser.parse_args(argv)
def main(argv: Sequence[str] | None = None) -> int:
args = parse_args(argv)
if args.command == "env-template":
payload = env_template(profile=args.profile, generate_secrets=args.generate_secrets)
if args.output is None:
print(payload, end="")
return 0
output = args.output.expanduser()
if output.exists() and not args.overwrite:
print(f"Refusing to overwrite existing file: {output}", file=sys.stderr)
return 2
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(payload, encoding="utf-8")
print(f"Wrote {output}")
return 0
if args.command == "validate":
result = validate_runtime_configuration(profile=args.profile, strict=args.strict)
print(result.to_json() if args.format == "json" else result.to_text())
return 0 if result.ok else 1
raise AssertionError(f"Unhandled command: {args.command}")
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping, Sequence
from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, cast, runtime_checkable
@@ -24,9 +24,13 @@ CAPABILITY_ACCESS_ADMINISTRATION = "access.administration"
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER = "access.governanceMaterializer"
CAPABILITY_TENANCY_TENANT_RESOLVER = "tenancy.tenantResolver"
CAPABILITY_AUDIT_SINK = "audit.sink"
CAPABILITY_AUDIT_RECORDER = "audit.recorder"
CAPABILITY_AUDIT_RETENTION = "audit.retention"
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER = "auth.apiPrincipalProvider"
CAPABILITY_AUTH_PRINCIPAL_RESOLVER = "auth.principalResolver"
CAPABILITY_AUTH_PERMISSION_EVALUATOR = "auth.permissionEvaluator"
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER = "auth.tenantContextSwitcher"
ACCESS_CAPABILITY_NAMES = frozenset(
{
@@ -41,15 +45,21 @@ ACCESS_CAPABILITY_NAMES = frozenset(
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
CAPABILITY_TENANCY_TENANT_RESOLVER,
CAPABILITY_AUDIT_SINK,
CAPABILITY_AUDIT_RECORDER,
CAPABILITY_AUDIT_RETENTION,
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
}
)
AUTH_CAPABILITY_NAMES = frozenset(
{
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
}
)
@@ -302,6 +312,26 @@ class TenantOwnerCandidateRef:
display_name: str | None = None
@dataclass(frozen=True, slots=True)
class CreatedApiKeyRef:
id: str
secret: str
@dataclass(frozen=True, slots=True)
class DevelopmentBootstrapRef:
user: UserRef
created_api_key: CreatedApiKeyRef | None = None
@dataclass(frozen=True, slots=True)
class TenantContextSwitchRef:
account_id: str
membership_id: str
tenant_id: str
session_id: str | None = None
@dataclass(frozen=True, slots=True)
class GovernanceTemplateMaterialization:
template_id: str
@@ -321,7 +351,10 @@ class AuditEvent:
action: str
outcome: AuditOutcome = "unknown"
actor: PrincipalRef | None = None
scope: Literal["tenant", "system"] = "tenant"
tenant_id: str | None = None
user_id: str | None = None
api_key_id: str | None = None
resource_type: str | None = None
resource_id: str | None = None
occurred_at: datetime | None = None
@@ -330,12 +363,45 @@ class AuditEvent:
details: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class AuditRecordRef:
id: str
scope: str
tenant_id: str | None
user_id: str | None
api_key_id: str | None
action: str
object_type: str | None = None
object_id: str | None = None
details: Mapping[str, object] = field(default_factory=dict)
created_at: datetime | None = None
@runtime_checkable
class PrincipalResolver(Protocol):
def resolve_request(self, request: object, *, session: object | None = None) -> PrincipalRef:
...
@runtime_checkable
class ApiPrincipalProvider(Protocol):
def resolve_api_principal(
self,
request: object,
session: object,
*,
authorization: str | None = None,
x_api_key: str | None = None,
) -> object:
...
@runtime_checkable
class TenantContextSwitcher(Protocol):
def switch_tenant_context(self, session: object, *, principal: object, tenant_id: str) -> TenantContextSwitchRef:
...
@runtime_checkable
class TenantResolver(Protocol):
def get_tenant(self, tenant_id: str) -> TenantRef | None:
@@ -467,9 +533,24 @@ class TenantAccessProvisioner(Protocol):
) -> str:
...
def ensure_development_admin(
self,
session: object,
*,
tenant: object,
user_email: str,
user_password: str,
api_key_secret: str | None,
scopes: Sequence[str],
) -> DevelopmentBootstrapRef:
...
@runtime_checkable
class AccessAdministration(Protocol):
def tenant_counts(self, session: object, tenant_id: str) -> Mapping[str, int]:
...
def system_account_count(self, session: object) -> int:
...
@@ -485,6 +566,32 @@ class AccessAdministration(Protocol):
def user_ids_for_actor_filter(self, session: object, *, operator: str, value: str) -> Sequence[str]:
...
def user_settings(self, session: object, user_id: str, *, tenant_id: str) -> Mapping[str, object] | None:
...
def set_user_settings(
self,
session: object,
user_id: str,
*,
tenant_id: str,
settings: Mapping[str, object],
) -> Mapping[str, object] | None:
...
def group_settings(self, session: object, group_id: str, *, tenant_id: str) -> Mapping[str, object] | None:
...
def set_group_settings(
self,
session: object,
group_id: str,
*,
tenant_id: str,
settings: Mapping[str, object],
) -> Mapping[str, object] | None:
...
@runtime_checkable
class AccessGovernanceMaterializer(Protocol):
@@ -499,3 +606,22 @@ class AccessGovernanceMaterializer(Protocol):
class AuditSink(Protocol):
def record(self, event: AuditEvent) -> None:
...
@runtime_checkable
class AuditRecorder(Protocol):
def record_event(self, session: object, event: AuditEvent) -> AuditRecordRef:
...
@runtime_checkable
class AuditRetentionProvider(Protocol):
def apply_detail_retention(
self,
session: object,
*,
dry_run: bool,
now: datetime,
policy_for_record: Callable[[AuditRecordRef], object],
) -> Mapping[str, int]:
...

View File

@@ -205,7 +205,21 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
policy_explanation_required=True,
audit_event="organizations.model.updated",
rollback_history_required=True,
notes="Organization meta-model, concrete units, relations, functions, and assignments can be governed per tenant.",
notes="Organization meta-model, concrete units, relations, and function definitions can be governed per tenant.",
),
ConfigurationFieldSafety(
key="idm.organization_assignments",
label="IDM organization function assignments",
owner_module="idm",
scope="tenant",
storage="module_settings",
ui_managed=True,
risk="high",
dry_run_required=True,
policy_explanation_required=True,
audit_event="idm.organization_assignment.updated",
rollback_history_required=True,
notes="Identity-to-organization-function assignments can grant role-derived access when access consumes IDM assignment links.",
),
ConfigurationFieldSafety(
key="mail_profiles.credentials",

View File

@@ -0,0 +1,52 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
IDM_MODULE_ID = "idm"
CAPABILITY_IDM_DIRECTORY = "idm.directory"
IdmStatus = Literal["active", "inactive", "suspended"]
OrganizationFunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
@dataclass(frozen=True, slots=True)
class OrganizationFunctionAssignmentRef:
id: str
tenant_id: str
identity_id: str
function_id: str
organization_unit_id: str
account_id: str | None = None
applies_to_subunits: bool = False
source: OrganizationFunctionAssignmentSource = "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: IdmStatus = "active"
@runtime_checkable
class IdmDirectory(Protocol):
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
...
def organization_function_assignments_for_identity(
self,
identity_id: str,
*,
tenant_id: str | None = None,
) -> Sequence[OrganizationFunctionAssignmentRef]:
...
def organization_function_assignments_for_account(
self,
account_id: str,
*,
tenant_id: str | None = None,
) -> Sequence[OrganizationFunctionAssignmentRef]:
...

View File

@@ -0,0 +1,314 @@
from __future__ import annotations
import base64
import json
import os
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, Literal
from cryptography.fernet import Fernet
from sqlalchemy.engine import make_url
ConfigIssueLevel = Literal["error", "warning"]
@dataclass(frozen=True, slots=True)
class ConfigIssue:
level: ConfigIssueLevel
key: str
message: str
action: str
def to_dict(self) -> dict[str, str]:
return {
"level": self.level,
"key": self.key,
"message": self.message,
"action": self.action,
}
@dataclass(frozen=True, slots=True)
class ConfigValidationResult:
profile: str
issues: tuple[ConfigIssue, ...]
@property
def errors(self) -> tuple[ConfigIssue, ...]:
return tuple(issue for issue in self.issues if issue.level == "error")
@property
def warnings(self) -> tuple[ConfigIssue, ...]:
return tuple(issue for issue in self.issues if issue.level == "warning")
@property
def ok(self) -> bool:
return not self.errors
def to_dict(self) -> dict[str, object]:
return {
"profile": self.profile,
"ok": self.ok,
"errors": [issue.to_dict() for issue in self.errors],
"warnings": [issue.to_dict() for issue in self.warnings],
}
def to_json(self) -> str:
return json.dumps(self.to_dict(), indent=2, sort_keys=True)
def to_text(self) -> str:
lines = [
f"GovOPlaN configuration validation: {'OK' if self.ok else 'FAILED'}",
f"Profile: {self.profile}",
]
if not self.issues:
lines.append("No configuration issues found.")
return "\n".join(lines)
for issue in self.issues:
lines.append(f"- {issue.level.upper()} {issue.key}: {issue.message}")
lines.append(f" Action: {issue.action}")
return "\n".join(lines)
_LOCAL_PROFILES = {"dev", "local", "local-dev", "test"}
_PRODUCTION_PROFILES = {"prod", "production", "self-hosted"}
_PRODUCTION_LIKE_PROFILES = {"staging", "production-like", "production-like-dev"}
_DEFAULT_LOCAL_CORS = {
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:8080",
}
def normalize_install_profile(value: str | None) -> str:
profile = (value or "").strip().lower().replace("_", "-")
if profile in {"prod", "production"}:
return "production"
if profile in {"selfhosted", "self-hosted"}:
return "self-hosted"
if profile in {"productionlike", "production-like"}:
return "production-like"
if profile in {"local", "local-dev"}:
return "local-dev"
return profile or "local-dev"
def generate_master_key() -> str:
return Fernet.generate_key().decode("ascii")
def env_template(*, profile: str = "self-hosted", generate_secrets: bool = False) -> str:
clean_profile = normalize_install_profile(profile)
master_key = generate_master_key() if generate_secrets else "<generate-with-govoplan-config-env-template-generate-secrets>"
if clean_profile == "production-like":
return _production_like_env_template(master_key)
return _self_hosted_env_template(master_key)
def validate_runtime_configuration(
environ: Mapping[str, str] | None = None,
*,
profile: str | None = None,
strict: bool = False,
) -> ConfigValidationResult:
env = dict(os.environ if environ is None else environ)
clean_profile = normalize_install_profile(profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV"))
issues: list[ConfigIssue] = []
production = clean_profile in _PRODUCTION_PROFILES or env.get("APP_ENV", "").strip().lower() in {"prod", "production"}
production_like = production or clean_profile in _PRODUCTION_LIKE_PROFILES
local = clean_profile in _LOCAL_PROFILES and not production_like
def issue(level: ConfigIssueLevel, key: str, message: str, action: str) -> None:
if strict and level == "warning":
level = "error"
issues.append(ConfigIssue(level=level, key=key, message=message, action=action))
app_env = _clean(env.get("APP_ENV"))
if not app_env and production_like:
issue("error", "APP_ENV", "APP_ENV is missing for a production-like install.", "Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.")
elif app_env.lower() in {"dev", "test", "local"} and production_like:
issue("error", "APP_ENV", f"APP_ENV={app_env!r} is not valid for profile {clean_profile!r}.", "Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.")
database_url = _clean(env.get("DATABASE_URL"))
if not database_url:
issue("error", "DATABASE_URL", "DATABASE_URL is missing.", "Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.")
else:
backend = _database_backend(database_url)
if backend is None:
issue("error", "DATABASE_URL", "DATABASE_URL is not a valid SQLAlchemy URL.", "Use a value like postgresql+psycopg://user:password@host:5432/database.")
elif backend == "sqlite" and production_like:
issue("error", "DATABASE_URL", "SQLite is only supported for disposable local development.", "Use PostgreSQL for production-like and self-hosted installs.")
elif backend != "postgresql" and production:
issue("warning", "DATABASE_URL", f"Database backend {backend!r} is not the preferred production target.", "Use PostgreSQL unless this deployment has an explicit support decision.")
if backend == "postgresql" and not _clean(env.get("GOVOPLAN_DATABASE_URL_PGTOOLS")):
issue("warning", "GOVOPLAN_DATABASE_URL_PGTOOLS", "PostgreSQL backup/restore tools URL is missing.", "Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.")
master_key = _clean(env.get("MASTER_KEY_B64"))
if not master_key and not local:
issue("error", "MASTER_KEY_B64", "MASTER_KEY_B64 is required outside local dev/test.", "Generate a Fernet key with `govoplan-config env-template --generate-secrets` or `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` and store it in deployment secrets.")
elif master_key:
error = _master_key_error(master_key)
if error:
issue("error", "MASTER_KEY_B64", error, "Replace MASTER_KEY_B64 with a Fernet key or base64-encoded 32-byte key.")
elif "change-me" in master_key.lower() or "generate" in master_key.lower():
issue("error", "MASTER_KEY_B64", "MASTER_KEY_B64 still looks like a placeholder.", "Generate a real deployment key and store it outside git.")
enabled_modules = _csv(env.get("ENABLED_MODULES"))
if not enabled_modules and production_like:
issue("error", "ENABLED_MODULES", "ENABLED_MODULES is missing.", "Set ENABLED_MODULES explicitly so startup module composition is intentional.")
elif "access" not in enabled_modules and production_like:
issue("error", "ENABLED_MODULES", "The access module is not enabled.", "Include `access` unless this deployment has a replacement auth/principal provider.")
elif enabled_modules and "admin" not in enabled_modules and production_like:
issue("warning", "ENABLED_MODULES", "The admin module is not enabled.", "Keep `admin` enabled for operator UI unless this is a deliberately headless install.")
celery_enabled = _truthy(env.get("CELERY_ENABLED"))
if celery_enabled and not _clean(env.get("REDIS_URL")):
issue("error", "REDIS_URL", "CELERY_ENABLED=true but REDIS_URL is missing.", "Set REDIS_URL to the Redis broker/result backend used by workers.")
if production and _truthy(env.get("DEV_BOOTSTRAP_ENABLED")):
issue("error", "DEV_BOOTSTRAP_ENABLED", "Development bootstrap is enabled in production.", "Set DEV_BOOTSTRAP_ENABLED=false and create first administrators through the controlled bootstrap path.")
if production and not _truthy(env.get("AUTH_COOKIE_SECURE")):
issue("error", "AUTH_COOKIE_SECURE", "Secure auth cookies are disabled for production.", "Set AUTH_COOKIE_SECURE=true behind HTTPS.")
cors_origins = _csv(env.get("CORS_ORIGINS"))
if production_like and not cors_origins:
issue("error", "CORS_ORIGINS", "CORS_ORIGINS is missing.", "Set CORS_ORIGINS to the exact WebUI origin or origins.")
elif "*" in cors_origins and production_like:
issue("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.")
elif production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
issue("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.")
storage_backend = _clean(env.get("FILE_STORAGE_BACKEND")) or "local"
if storage_backend == "local":
if not _clean(env.get("FILE_STORAGE_LOCAL_ROOT")) and production_like:
issue("error", "FILE_STORAGE_LOCAL_ROOT", "Local file storage root is missing.", "Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.")
elif production:
issue("warning", "FILE_STORAGE_BACKEND", "Production is configured for local file storage.", "Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.")
elif storage_backend == "s3":
for key in ("FILE_STORAGE_S3_ENDPOINT_URL", "FILE_STORAGE_S3_REGION", "FILE_STORAGE_S3_ACCESS_KEY_ID", "FILE_STORAGE_S3_SECRET_ACCESS_KEY", "FILE_STORAGE_S3_BUCKET"):
if not _clean(env.get(key)):
issue("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
else:
issue("error", "FILE_STORAGE_BACKEND", f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.", "Use `local` or `s3`.")
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG"))
if production and catalog_source:
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE")):
issue("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "A module catalog source is configured without a trusted keyring file.", "Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.")
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL")):
issue("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL", "A module catalog source is configured without an approved release channel.", "Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.")
return ConfigValidationResult(profile=clean_profile, issues=tuple(issues))
def _self_hosted_env_template(master_key: str) -> str:
return f"""# GovOPlaN self-hosted install configuration.
# Copy to a deployment-local .env or secret store. Do not commit populated secrets.
APP_ENV=production
GOVOPLAN_INSTALL_PROFILE=self-hosted
MASTER_KEY_B64={master_key}
DATABASE_URL=postgresql+psycopg://govoplan:change-me@127.0.0.1:5432/govoplan
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:change-me@127.0.0.1:5432/govoplan
ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,files,mail,campaigns,calendar,docs,ops
CELERY_ENABLED=true
REDIS_URL=redis://127.0.0.1:6379/0
CELERY_QUEUES=send_email,append_sent,default
CORS_ORIGINS=https://govoplan.example.org
AUTH_COOKIE_SECURE=true
AUTH_COOKIE_SAMESITE=lax
AUTH_COOKIE_DOMAIN=
FILE_STORAGE_BACKEND=local
FILE_STORAGE_LOCAL_ROOT=/var/lib/govoplan/files
FILE_STORAGE_LOCAL_FALLBACK_ROOTS=
DEV_AUTO_MIGRATE_ENABLED=false
DEV_BOOTSTRAP_ENABLED=false
DEV_MAILBOX_API_ENABLED=false
GOVOPLAN_MODULE_PACKAGE_CATALOG_URL=https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json
GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE=/etc/govoplan/catalog-keyring.json
GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable
"""
def _production_like_env_template(master_key: str) -> str:
return f"""# GovOPlaN production-like development profile.
# Copy to dev/production-like/.env for local overrides.
APP_ENV=staging
GOVOPLAN_INSTALL_PROFILE=production-like
MASTER_KEY_B64={master_key}
GOVOPLAN_PRODUCTION_LIKE_POSTGRES_DB=govoplan
GOVOPLAN_PRODUCTION_LIKE_POSTGRES_USER=govoplan
GOVOPLAN_PRODUCTION_LIKE_POSTGRES_PASSWORD=govoplan-dev
GOVOPLAN_PRODUCTION_LIKE_POSTGRES_PORT=55433
GOVOPLAN_PRODUCTION_LIKE_REDIS_PORT=56379
GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
GOVOPLAN_PRODUCTION_LIKE_REDIS_URL=redis://127.0.0.1:56379/0
DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
REDIS_URL=redis://127.0.0.1:56379/0
CELERY_ENABLED=true
CELERY_QUEUES=send_email,append_sent,default
ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
AUTH_COOKIE_SECURE=false
FILE_STORAGE_BACKEND=local
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
DEV_AUTO_MIGRATE_ENABLED=false
DEV_BOOTSTRAP_ENABLED=true
"""
def _clean(value: str | None) -> str:
return str(value or "").strip()
def _csv(value: str | None) -> tuple[str, ...]:
return tuple(dict.fromkeys(item.strip() for item in str(value or "").split(",") if item.strip()))
def _truthy(value: str | None) -> bool:
return _clean(value).lower() in {"1", "true", "yes", "on"}
def _database_backend(database_url: str) -> str | None:
try:
return make_url(database_url).get_backend_name()
except Exception:
return None
def _master_key_error(value: str) -> str | None:
candidate = value.strip().encode("utf-8")
try:
Fernet(candidate)
return None
except Exception:
pass
try:
raw = base64.b64decode(candidate)
except Exception:
return "MASTER_KEY_B64 must be a Fernet key or base64-encoded 32-byte key."
if len(raw) != 32:
return "MASTER_KEY_B64 must decode to exactly 32 bytes."
try:
Fernet(base64.urlsafe_b64encode(raw))
except Exception:
return "MASTER_KEY_B64 is not usable as a Fernet key."
return None

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from collections import defaultdict
from collections.abc import Iterable, Mapping
from contextlib import AbstractContextManager, closing
from dataclasses import dataclass
@@ -35,6 +36,7 @@ from govoplan_core.core.module_management import (
save_module_install_plan,
)
from govoplan_core.core.modules import ModuleManifest, MigrationRetirementPlan
from govoplan_core.core.versioning import compare_versions, format_version_range, version_satisfies_range
IssueSeverity = Literal["blocker", "warning", "info"]
ChecklistStatus = Literal["done", "pending", "blocked", "warning"]
@@ -162,7 +164,9 @@ def module_install_preflight(
if not maintenance_mode:
issues.append(ModuleInstallerIssue("blocker", "maintenance_required", "Package changes require maintenance mode."))
issues.extend(module_manifest_compatibility_issues(available))
activation_candidates = desired_modules_after_package_plan(desired, plan)
issues.extend(module_manifest_compatibility_issues(available, module_ids=activation_candidates))
issues.extend(_package_catalog_preflight_issues(plan, available))
frontend_rebuild_required = False
for item in planned_items:
@@ -965,10 +969,15 @@ def _module_verify_command(plan: ModuleInstallPlan) -> dict[str, Any] | None:
return _structured_command(argv)
def module_manifest_compatibility_issues(available: Mapping[str, ModuleManifest]) -> tuple[ModuleInstallerIssue, ...]:
def module_manifest_compatibility_issues(
available: Mapping[str, ModuleManifest],
*,
module_ids: Iterable[str] | None = None,
) -> tuple[ModuleInstallerIssue, ...]:
current_core_version = _current_core_version()
issues: list[ModuleInstallerIssue] = []
for manifest in available.values():
manifests = _selected_manifests(available, module_ids)
for manifest in manifests.values():
compatibility = manifest.compatibility
if compatibility.manifest_contract_version != SUPPORTED_MANIFEST_CONTRACT_VERSION:
issues.append(ModuleInstallerIssue(
@@ -980,20 +989,21 @@ def module_manifest_compatibility_issues(available: Mapping[str, ModuleManifest]
),
manifest.id,
))
if compatibility.core_version_min and _compare_versions(current_core_version, compatibility.core_version_min) < 0:
if compatibility.core_version_min and compare_versions(current_core_version, compatibility.core_version_min) < 0:
issues.append(ModuleInstallerIssue(
"blocker",
"core_version_too_low",
f"Module requires core >= {compatibility.core_version_min}; current core is {current_core_version}.",
manifest.id,
))
if compatibility.core_version_max and _compare_versions(current_core_version, compatibility.core_version_max) > 0:
if compatibility.core_version_max and compare_versions(current_core_version, compatibility.core_version_max) > 0:
issues.append(ModuleInstallerIssue(
"blocker",
"core_version_too_high",
f"Module supports core <= {compatibility.core_version_max}; current core is {current_core_version}.",
manifest.id,
))
issues.extend(_interface_contract_issues(manifests))
return tuple(issues)
@@ -1013,24 +1023,210 @@ def _current_core_version() -> str:
return "0.0.0"
def _compare_versions(left: str, right: str) -> int:
left_parts = _version_tuple(left)
right_parts = _version_tuple(right)
max_length = max(len(left_parts), len(right_parts))
padded_left = left_parts + (0,) * (max_length - len(left_parts))
padded_right = right_parts + (0,) * (max_length - len(right_parts))
if padded_left < padded_right:
return -1
if padded_left > padded_right:
return 1
return 0
def _selected_manifests(
available: Mapping[str, ModuleManifest],
module_ids: Iterable[str] | None,
) -> dict[str, ModuleManifest]:
if module_ids is None:
return dict(available)
selected: dict[str, ModuleManifest] = {}
for module_id in module_ids:
manifest = available.get(module_id)
if manifest is not None:
selected[module_id] = manifest
return selected
def _version_tuple(value: str) -> tuple[int, ...]:
match = re.match(r"^\s*(\d+(?:\.\d+)*)", value)
if not match:
return (0,)
return tuple(int(part) for part in match.group(1).split("."))
def _interface_contract_issues(available: Mapping[str, ModuleManifest]) -> tuple[ModuleInstallerIssue, ...]:
providers: dict[str, list[tuple[str, str]]] = defaultdict(list)
for manifest in available.values():
for provided in manifest.provides_interfaces:
providers[provided.name].append((manifest.id, provided.version))
issues: list[ModuleInstallerIssue] = []
for manifest in available.values():
for requirement in manifest.requires_interfaces:
available_versions = providers.get(requirement.name, [])
matching = [
(module_id, version)
for module_id, version in available_versions
if version_satisfies_range(
version,
version_min=requirement.version_min,
version_max_exclusive=requirement.version_max_exclusive,
)
]
if matching:
continue
version_range = format_version_range(
version_min=requirement.version_min,
version_max_exclusive=requirement.version_max_exclusive,
)
if not available_versions:
if requirement.optional:
continue
issues.append(ModuleInstallerIssue(
"blocker",
"required_interface_missing",
f"Module requires interface {requirement.name!r} ({version_range}), but no provider is installed.",
manifest.id,
))
continue
provider_summary = ", ".join(f"{module_id}@{version}" for module_id, version in available_versions)
issues.append(ModuleInstallerIssue(
"blocker",
"interface_version_mismatch",
(
f"Module requires interface {requirement.name!r} ({version_range}), "
f"but installed providers are {provider_summary}."
),
manifest.id,
))
return tuple(issues)
def _package_catalog_preflight_issues(
plan: ModuleInstallPlan,
available: Mapping[str, ModuleManifest],
) -> tuple[ModuleInstallerIssue, ...]:
install_items = tuple(item for item in plan.items if item.status == "planned" and item.action == "install")
if not install_items:
return ()
catalog_items = tuple(item for item in install_items if item.source == "catalog")
try:
from govoplan_core.core.module_package_catalog import validate_module_package_catalog
result = validate_module_package_catalog()
except Exception as exc:
severity: IssueSeverity = "blocker" if catalog_items else "warning"
return (ModuleInstallerIssue(
severity,
"catalog_validation_failed",
f"Package catalog could not be validated for this install plan: {exc}",
),)
if not result.get("configured"):
if catalog_items:
return (ModuleInstallerIssue(
"blocker",
"catalog_required",
"Catalog-sourced package installs require a configured package catalog before activation.",
),)
return ()
issues: list[ModuleInstallerIssue] = []
if result.get("valid") is not True:
severity = "blocker" if catalog_items else "warning"
issues.append(ModuleInstallerIssue(
severity,
"catalog_validation_failed",
str(result.get("error") or "Module package catalog is invalid."),
))
return tuple(issues)
warnings = result.get("warnings")
if isinstance(warnings, list):
issues.extend(
ModuleInstallerIssue("warning", "catalog_warning", str(warning))
for warning in warnings
)
if catalog_items:
issues.extend(_selected_catalog_interface_issues(catalog_items, result, available))
return tuple(issues)
def _selected_catalog_interface_issues(
catalog_items: tuple[ModuleInstallPlanItem, ...],
validation: Mapping[str, object],
available: Mapping[str, ModuleManifest],
) -> tuple[ModuleInstallerIssue, ...]:
modules = {
str(item.get("module_id")): item
for item in validation.get("modules", [])
if isinstance(item, Mapping) and item.get("module_id")
}
catalog_provider_versions: dict[str, list[tuple[str, str]]] = defaultdict(list)
for module_id, module in modules.items():
for provided in _catalog_interface_items(module.get("provides_interfaces")):
name = provided.get("name") if isinstance(provided.get("name"), str) else None
version = provided.get("version") if isinstance(provided.get("version"), str) else None
if name and version:
catalog_provider_versions[name].append((module_id, version))
installed_provider_versions: dict[str, list[tuple[str, str]]] = defaultdict(list)
for manifest in available.values():
for provided in manifest.provides_interfaces:
installed_provider_versions[provided.name].append((manifest.id, provided.version))
issues: list[ModuleInstallerIssue] = []
for item in catalog_items:
module = modules.get(item.module_id)
if module is None:
issues.append(ModuleInstallerIssue(
"blocker",
"catalog_entry_missing",
f"Catalog-sourced install plan item {item.module_id!r} is no longer present in the package catalog.",
item.module_id,
))
continue
for required in _catalog_interface_items(module.get("requires_interfaces")):
name = required.get("name") if isinstance(required.get("name"), str) else None
if not name:
continue
version_min = required.get("version_min") if isinstance(required.get("version_min"), str) else None
version_max_exclusive = (
required.get("version_max_exclusive")
if isinstance(required.get("version_max_exclusive"), str)
else None
)
optional = required.get("optional") is True
providers = [
*catalog_provider_versions.get(name, ()),
*installed_provider_versions.get(name, ()),
]
matching = [
(provider_module_id, version)
for provider_module_id, version in providers
if version_satisfies_range(
version,
version_min=version_min,
version_max_exclusive=version_max_exclusive,
)
]
if matching:
continue
version_range = format_version_range(
version_min=version_min,
version_max_exclusive=version_max_exclusive,
)
if not providers:
if optional:
continue
issues.append(ModuleInstallerIssue(
"blocker",
"catalog_required_interface_missing",
(
f"Catalog-sourced module {item.module_id!r} requires interface "
f"{name!r} ({version_range}), but neither the package catalog nor "
"installed modules provide it."
),
item.module_id,
))
continue
provider_summary = ", ".join(f"{provider_module_id}@{version}" for provider_module_id, version in providers)
severity: IssueSeverity = "warning" if optional else "blocker"
issues.append(ModuleInstallerIssue(
severity,
"catalog_interface_version_mismatch",
(
f"Catalog-sourced module {item.module_id!r} requires interface "
f"{name!r} ({version_range}), but available providers are {provider_summary}."
),
item.module_id,
))
return tuple(issues)
def _catalog_interface_items(value: object) -> tuple[Mapping[str, object], ...]:
if not isinstance(value, list):
return ()
return tuple(item for item in value if isinstance(item, Mapping))
def _post_install_checklist(

View File

@@ -21,6 +21,7 @@ REQUIRED_PLATFORM_MODULES = ("access",)
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin")
INSTALL_PLAN_ACTIONS = ("install", "uninstall")
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
INSTALL_PLAN_SOURCES = ("manual", "catalog")
LOCAL_DEPENDENCY_REF_PREFIXES = ("file:", "path:", "workspace:", "link:")
@@ -38,6 +39,8 @@ class ModuleStatePlan:
class ModuleInstallPlanItem:
module_id: str
action: str
source: str = "manual"
catalog: Mapping[str, object] | None = None
python_package: str | None = None
python_ref: str | None = None
webui_package: str | None = None
@@ -51,10 +54,13 @@ class ModuleInstallPlanItem:
payload: dict[str, object] = {
"module_id": self.module_id,
"action": self.action,
"source": self.source,
"status": self.status,
}
if self.destroy_data:
payload["destroy_data"] = True
if self.catalog:
payload["catalog"] = dict(self.catalog)
for key in ("python_package", "python_ref", "webui_package", "webui_ref", "notes"):
value = getattr(self, key)
if value:
@@ -291,6 +297,8 @@ def normalize_module_install_plan_item(
module_id = _required_string(raw.get("module_id"), "module_id")
action = _required_string(raw.get("action"), "action")
source = _clean_optional_string(raw.get("source")) or "manual"
catalog = _clean_optional_mapping(raw.get("catalog"), field="catalog", module_id=module_id)
status = _clean_optional_string(raw.get("status")) or "planned"
python_package = _clean_optional_string(raw.get("python_package"))
python_ref = _clean_optional_string(raw.get("python_ref"))
@@ -304,6 +312,8 @@ def normalize_module_install_plan_item(
raise ModuleManagementError(f"Unsupported install plan action for {module_id!r}: {action!r}.")
if status not in INSTALL_PLAN_STATUSES:
raise ModuleManagementError(f"Unsupported install plan status for {module_id!r}: {status!r}.")
if source not in INSTALL_PLAN_SOURCES:
raise ModuleManagementError(f"Unsupported install plan source for {module_id!r}: {source!r}.")
if action == "install" and not python_ref:
raise ModuleManagementError(f"Install plan item {module_id!r} needs a Python package reference.")
if action == "uninstall" and not python_package:
@@ -322,6 +332,8 @@ def normalize_module_install_plan_item(
return ModuleInstallPlanItem(
module_id=module_id,
action=action,
source=source,
catalog=catalog,
python_package=python_package,
python_ref=python_ref,
webui_package=webui_package,

View File

@@ -2,10 +2,12 @@ from __future__ import annotations
import base64
import binascii
from collections import defaultdict
from datetime import UTC, datetime
from pathlib import Path
import json
import os
import re
from typing import Any
import urllib.error
import urllib.request
@@ -14,6 +16,10 @@ from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
def module_package_catalog(
path: Path | str | None = None,
@@ -193,6 +199,7 @@ def validate_module_package_catalog(
)
warnings.extend(str(item) for item in freshness.get("warnings", ()) if item)
warnings.extend(str(item) for item in replay.get("warnings", ()) if item)
warnings.extend(_catalog_interface_warnings(modules))
if not signature_state["signed"]:
warnings.append("Catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
elif not signature_state["trusted"]:
@@ -303,7 +310,10 @@ def _configured_enforce_sequence() -> bool:
def _configured_require_signature() -> bool:
return os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE", "").strip().lower() in {"1", "true", "yes", "on"}
value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE")
if value is not None and value.strip():
return value.strip().lower() in {"1", "true", "yes", "on"}
return os.getenv("APP_ENV", "").strip().lower() in {"prod", "production"}
def _configured_approved_channels() -> tuple[str, ...]:
@@ -601,6 +611,8 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
"webui_package": _optional_str(value, "webui_package"),
"webui_ref": _optional_str(value, "webui_ref"),
"license_features": _string_list(value.get("license_features")),
"provides_interfaces": _normalize_catalog_interface_providers(value.get("provides_interfaces"), module_id=module_id),
"requires_interfaces": _normalize_catalog_interface_requirements(value.get("requires_interfaces"), module_id=module_id),
"notes": _optional_str(value, "notes"),
"tags": _string_list(value.get("tags")),
}
@@ -633,6 +645,134 @@ def _string_list(value: Any) -> list[str]:
return [str(item).strip() for item in value if str(item).strip()]
def _normalize_catalog_interface_providers(value: Any, *, module_id: str) -> list[dict[str, object]]:
if value is None:
return []
if not isinstance(value, list):
raise ValueError(f"Module package catalog provides_interfaces for {module_id!r} must be a list.")
normalized: list[dict[str, object]] = []
seen: set[str] = set()
for raw in value:
if not isinstance(raw, dict):
raise ValueError(f"Module package catalog provides_interfaces entries for {module_id!r} must be objects.")
name = _catalog_interface_required_str(raw, "name", module_id=module_id, field="provides_interfaces")
version = _catalog_interface_required_str(raw, "version", module_id=module_id, field="provides_interfaces")
_validate_catalog_interface_name(name, module_id=module_id)
if name in seen:
raise ValueError(f"Module package catalog entry {module_id!r} provides interface {name!r} more than once.")
seen.add(name)
normalized.append({"name": name, "version": version})
return normalized
def _normalize_catalog_interface_requirements(value: Any, *, module_id: str) -> list[dict[str, object]]:
if value is None:
return []
if not isinstance(value, list):
raise ValueError(f"Module package catalog requires_interfaces for {module_id!r} must be a list.")
normalized: list[dict[str, object]] = []
seen: set[str] = set()
for raw in value:
if not isinstance(raw, dict):
raise ValueError(f"Module package catalog requires_interfaces entries for {module_id!r} must be objects.")
name = _catalog_interface_required_str(raw, "name", module_id=module_id, field="requires_interfaces")
_validate_catalog_interface_name(name, module_id=module_id)
if name in seen:
raise ValueError(f"Module package catalog entry {module_id!r} requires interface {name!r} more than once.")
seen.add(name)
version_min = _optional_str(raw, "version_min")
version_max_exclusive = _optional_str(raw, "version_max_exclusive")
if not version_range_is_valid(version_min=version_min, version_max_exclusive=version_max_exclusive):
version_range = format_version_range(version_min=version_min, version_max_exclusive=version_max_exclusive)
raise ValueError(f"Module package catalog entry {module_id!r} has invalid interface range {version_range!r}.")
item: dict[str, object] = {
"name": name,
"optional": _optional_bool(raw, "optional"),
}
if version_min is not None:
item["version_min"] = version_min
if version_max_exclusive is not None:
item["version_max_exclusive"] = version_max_exclusive
normalized.append(item)
return normalized
def _catalog_interface_warnings(modules: tuple[dict[str, object], ...]) -> list[str]:
providers: dict[str, list[tuple[str, str]]] = defaultdict(list)
for module in modules:
module_id = str(module.get("module_id") or "")
for raw in _catalog_interface_list(module.get("provides_interfaces")):
name = str(raw.get("name") or "")
version = str(raw.get("version") or "")
providers[name].append((module_id, version))
warnings: list[str] = []
for module in modules:
module_id = str(module.get("module_id") or "")
for raw in _catalog_interface_list(module.get("requires_interfaces")):
name = str(raw.get("name") or "")
version_min = raw.get("version_min") if isinstance(raw.get("version_min"), str) else None
version_max_exclusive = raw.get("version_max_exclusive") if isinstance(raw.get("version_max_exclusive"), str) else None
optional = raw.get("optional") is True
available = providers.get(name, [])
matching = [
(provider_module_id, version)
for provider_module_id, version in available
if version_satisfies_range(
version,
version_min=version_min,
version_max_exclusive=version_max_exclusive,
)
]
if matching:
continue
version_range = format_version_range(
version_min=version_min,
version_max_exclusive=version_max_exclusive,
)
if not available:
if optional:
continue
warnings.append(
f"Catalog module {module_id!r} requires interface {name!r} ({version_range}), "
"but no catalog entry provides it; activation may still succeed if an installed module provides it."
)
continue
provider_summary = ", ".join(f"{provider_module_id}@{version}" for provider_module_id, version in available)
warnings.append(
f"Catalog module {module_id!r} requires interface {name!r} ({version_range}), "
f"but catalog providers are {provider_summary}."
)
return warnings
def _catalog_interface_list(value: object) -> list[dict[str, object]]:
if not isinstance(value, list):
return []
return [item for item in value if isinstance(item, dict)]
def _catalog_interface_required_str(value: dict[str, Any], key: str, *, module_id: str, field: str) -> str:
item = _optional_str(value, key)
if not item:
raise ValueError(f"Module package catalog {field} entry for {module_id!r} is missing {key!r}.")
return item
def _validate_catalog_interface_name(name: str, *, module_id: str) -> None:
if not _INTERFACE_NAME_RE.match(name):
raise ValueError(f"Module package catalog entry {module_id!r} has invalid interface name {name!r}.")
def _optional_bool(value: dict[str, Any], key: str) -> bool:
item = value.get(key)
if item is None:
return False
if not isinstance(item, bool):
raise ValueError(f"Module package catalog boolean field {key!r} must be true or false.")
return item
def _normalize_artifact_integrity(value: Any) -> dict[str, object]:
if value is None:
return {}

View File

@@ -108,6 +108,20 @@ class ModuleCompatibility:
manifest_contract_version: str = "1"
@dataclass(frozen=True, slots=True)
class ModuleInterfaceProvider:
name: str
version: str
@dataclass(frozen=True, slots=True)
class ModuleInterfaceRequirement:
name: str
version_min: str | None = None
version_max_exclusive: str | None = None
optional: bool = False
@dataclass(frozen=True, slots=True)
class ModuleUninstallGuardResult:
severity: Literal["blocker", "warning", "info"]
@@ -217,6 +231,8 @@ class ModuleManifest:
optional_dependencies: tuple[str, ...] = ()
required_capabilities: tuple[str, ...] = ()
optional_capabilities: tuple[str, ...] = ()
provides_interfaces: tuple[ModuleInterfaceProvider, ...] = ()
requires_interfaces: tuple[ModuleInterfaceRequirement, ...] = ()
permissions: tuple[PermissionDefinition, ...] = ()
role_templates: tuple[RoleTemplate, ...] = ()
route_factory: RouteFactory | None = None

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
@@ -10,7 +9,6 @@ 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)
@@ -39,23 +37,6 @@ class OrganizationFunctionRef:
status: OrganizationStatus = "active"
@dataclass(frozen=True, slots=True)
class OrganizationFunctionAssignmentRef:
id: str
tenant_id: str
identity_id: str
function_id: str
organization_unit_id: str
account_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:
@@ -74,22 +55,3 @@ class OrganizationDirectory(Protocol):
include_subunits: bool = False,
) -> Sequence[OrganizationFunctionRef]:
...
def get_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
...
def function_assignments_for_identity(
self,
identity_id: str,
*,
tenant_id: str | None = None,
) -> Sequence[OrganizationFunctionAssignmentRef]:
...
def function_assignments_for_account(
self,
account_id: str,
*,
tenant_id: str | None = None,
) -> Sequence[OrganizationFunctionAssignmentRef]:
...

View File

@@ -1,11 +1,13 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Iterable, Literal, Mapping, cast
from typing import Any, Iterable, Literal, Mapping, Protocol, cast, runtime_checkable
from urllib.parse import quote, unquote
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
CAPABILITY_POLICY_PRIVACY_RETENTION = "policy.privacyRetention"
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign")
@@ -122,3 +124,39 @@ class PolicyDecision:
"requirements": list(self.requirements),
"details": dict(self.details),
}
@runtime_checkable
class PrivacyRetentionService(Protocol):
def privacy_policy_from_settings(self, *args: Any, **kwargs: Any) -> Any:
...
def privacy_policy_from_session(self, *args: Any, **kwargs: Any) -> Any:
...
def set_privacy_policy(self, *args: Any, **kwargs: Any) -> Any:
...
def effective_privacy_policy(self, *args: Any, **kwargs: Any) -> Any:
...
def parent_privacy_policy(self, *args: Any, **kwargs: Any) -> Any:
...
def effective_privacy_policy_sources(self, *args: Any, **kwargs: Any) -> Any:
...
def parent_privacy_policy_sources(self, *args: Any, **kwargs: Any) -> Any:
...
def get_privacy_policy_for_scope(self, *args: Any, **kwargs: Any) -> Any:
...
def set_privacy_policy_for_scope(self, *args: Any, **kwargs: Any) -> Any:
...
def sanitize_audit_details_for_policy(self, *args: Any, **kwargs: Any) -> Any:
...
def apply_retention_policy(self, *args: Any, **kwargs: Any) -> Any:
...

View File

@@ -8,6 +8,8 @@ from dataclasses import dataclass
from govoplan_core.core.modules import (
CapabilityFactory,
DeleteVetoProvider,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleContext,
ModuleManifest,
NavItem,
@@ -18,11 +20,13 @@ from govoplan_core.core.modules import (
SUPPORTED_MANIFEST_CONTRACT_VERSION,
TenantSummaryProvider,
)
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
_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_]*:\*$")
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
class RegistryError(ValueError):
@@ -177,6 +181,8 @@ class PlatformRegistry:
raise RegistryError(f"Duplicate permission scope: {permission.scope}")
seen_permissions[permission.scope] = permission
_validate_interface_closure(ordered)
known_scopes = set(seen_permissions)
for manifest in ordered:
for template in manifest.role_templates:
@@ -236,6 +242,8 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
_validate_dependency_list(manifest.id, "optional_dependencies", manifest.optional_dependencies)
_validate_capability_list(manifest.id, "required_capabilities", manifest.required_capabilities)
_validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities)
_validate_interface_providers(manifest.id, manifest.provides_interfaces)
_validate_interface_requirements(manifest.id, manifest.requires_interfaces)
overlap = set(manifest.dependencies) & set(manifest.optional_dependencies)
if overlap:
joined = ", ".join(sorted(overlap))
@@ -275,6 +283,47 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
_validate_nav_item(manifest.id, item)
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list)
for manifest in manifests:
for provided in manifest.provides_interfaces:
providers[provided.name].append((manifest, provided))
for manifest in manifests:
for requirement in manifest.requires_interfaces:
available = providers.get(requirement.name, [])
matching = [
(provider_manifest, provided)
for provider_manifest, provided in available
if version_satisfies_range(
provided.version,
version_min=requirement.version_min,
version_max_exclusive=requirement.version_max_exclusive,
)
]
if matching:
continue
version_range = format_version_range(
version_min=requirement.version_min,
version_max_exclusive=requirement.version_max_exclusive,
)
if not available:
if requirement.optional:
continue
raise RegistryError(
f"Module {manifest.id!r} requires unavailable interface "
f"{requirement.name!r} ({version_range})"
)
available_versions = ", ".join(
f"{provider_manifest.id}@{provided.version}"
for provider_manifest, provided in available
)
raise RegistryError(
f"Module {manifest.id!r} requires interface {requirement.name!r} "
f"({version_range}) but available providers are {available_versions}"
)
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}")
@@ -293,6 +342,42 @@ def _validate_capability_list(module_id: str, field_name: str, capabilities: tup
raise RegistryError(f"Module {module_id!r} has invalid capability name {capability!r}")
def _validate_interface_providers(module_id: str, providers: tuple[ModuleInterfaceProvider, ...]) -> None:
names = [provider.name for provider in providers]
if len(names) != len(set(names)):
raise RegistryError(f"Module {module_id!r} has duplicate provided interface names")
for provider in providers:
_validate_interface_name(module_id, provider.name)
if not provider.version.strip():
raise RegistryError(f"Module {module_id!r} provides interface {provider.name!r} without a version")
def _validate_interface_requirements(module_id: str, requirements: tuple[ModuleInterfaceRequirement, ...]) -> None:
names = [requirement.name for requirement in requirements]
if len(names) != len(set(names)):
raise RegistryError(f"Module {module_id!r} has duplicate required interface names")
for requirement in requirements:
_validate_interface_name(module_id, requirement.name)
if requirement.version_min is not None and not requirement.version_min.strip():
raise RegistryError(f"Module {module_id!r} has blank minimum version for interface {requirement.name!r}")
if requirement.version_max_exclusive is not None and not requirement.version_max_exclusive.strip():
raise RegistryError(f"Module {module_id!r} has blank maximum version for interface {requirement.name!r}")
if not version_range_is_valid(
version_min=requirement.version_min,
version_max_exclusive=requirement.version_max_exclusive,
):
version_range = format_version_range(
version_min=requirement.version_min,
version_max_exclusive=requirement.version_max_exclusive,
)
raise RegistryError(f"Module {module_id!r} has invalid interface range {version_range!r}")
def _validate_interface_name(module_id: str, name: str) -> None:
if not _INTERFACE_NAME_RE.match(name):
raise RegistryError(f"Module {module_id!r} has invalid interface name {name!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}")

View File

@@ -10,10 +10,14 @@ def configure_runtime(context: ModuleContext) -> None:
_context = context
def clear_runtime() -> None:
global _context
_context = None
def get_runtime_context() -> ModuleContext | None:
return _context
def get_registry() -> object | None:
return _context.registry if _context is not None else None

View File

@@ -0,0 +1,59 @@
from __future__ import annotations
import re
def compare_versions(left: str, right: str) -> int:
left_parts = version_tuple(left)
right_parts = version_tuple(right)
max_length = max(len(left_parts), len(right_parts))
padded_left = left_parts + (0,) * (max_length - len(left_parts))
padded_right = right_parts + (0,) * (max_length - len(right_parts))
if padded_left < padded_right:
return -1
if padded_left > padded_right:
return 1
return 0
def version_satisfies_range(
version: str,
*,
version_min: str | None = None,
version_max_exclusive: str | None = None,
) -> bool:
if version_min is not None and compare_versions(version, version_min) < 0:
return False
if version_max_exclusive is not None and compare_versions(version, version_max_exclusive) >= 0:
return False
return True
def version_range_is_valid(
*,
version_min: str | None = None,
version_max_exclusive: str | None = None,
) -> bool:
if version_min is None or version_max_exclusive is None:
return True
return compare_versions(version_min, version_max_exclusive) < 0
def format_version_range(
*,
version_min: str | None = None,
version_max_exclusive: str | None = None,
) -> str:
parts: list[str] = []
if version_min is not None:
parts.append(f">= {version_min}")
if version_max_exclusive is not None:
parts.append(f"< {version_max_exclusive}")
return ", ".join(parts) if parts else "any version"
def version_tuple(value: str) -> tuple[int, ...]:
match = re.match(r"^\s*v?(\d+(?:\.\d+)*)", value)
if not match:
return (0,)
return tuple(int(part) for part in match.group(1).split("."))

View File

@@ -4,15 +4,17 @@ from dataclasses import dataclass
from sqlalchemy.orm import Session
from govoplan_core.admin.common import AdminValidationError
from govoplan_core.core.access import CAPABILITY_ACCESS_TENANT_PROVISIONER, CreatedApiKeyRef, TenantAccessProvisioner, UserRef
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.runtime import configure_runtime, get_registry
from govoplan_core.db.base import Base
from govoplan_core.db.session import get_database
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.security.permissions import TENANT_SCOPES
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.settings import settings
from govoplan_access.backend.admin.service import ensure_default_roles, get_or_create_account
from govoplan_access.backend.db.models import Role, SystemRoleAssignment, Tenant, User, UserRoleAssignment
from govoplan_access.backend.security.api_keys import CreatedApiKey, create_api_key
from govoplan_core.tenancy.scope import Tenant
from govoplan_core.tenancy.scope import create_scope_tables
DEFAULT_SCOPES = sorted(TENANT_SCOPES)
@@ -21,8 +23,8 @@ DEFAULT_SCOPES = sorted(TENANT_SCOPES)
@dataclass(slots=True)
class BootstrapResult:
tenant: Tenant
user: User
created_api_key: CreatedApiKey | None
user: UserRef
created_api_key: CreatedApiKeyRef | None
def create_all_tables() -> None:
@@ -42,6 +44,21 @@ def create_all_tables() -> None:
Base.metadata.create_all(bind=engine)
def _access_provisioner() -> TenantAccessProvisioner:
registry = get_registry()
if registry is None:
registry = build_platform_registry(settings.enabled_modules)
context = ModuleContext(registry=registry, settings=settings)
registry.configure_capability_context(context)
configure_runtime(context)
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_ACCESS_TENANT_PROVISIONER):
raise RuntimeError("Development bootstrap requires the access tenant provisioner capability.")
capability = registry.require_capability(CAPABILITY_ACCESS_TENANT_PROVISIONER)
if not isinstance(capability, TenantAccessProvisioner):
raise RuntimeError("Access tenant provisioner capability is invalid.")
return capability
def bootstrap_dev_data(
session: Session,
*,
@@ -56,72 +73,16 @@ def bootstrap_dev_data(
session.add(tenant)
session.flush()
tenant_roles = ensure_default_roles(session, tenant)
system_roles = ensure_default_roles(session, None)
account, _, _ = get_or_create_account(
session,
email=user_email,
display_name="Development Admin",
password=user_password,
password_reset_required=False,
)
# Keep development credentials deterministic when an old create_all database
# is reused. This is guarded by the explicit dev bootstrap setting.
from govoplan_access.backend.security.passwords import hash_password
if not account.password_hash:
account.password_hash = hash_password(user_password)
account.is_active = True
session.add(account)
user = session.query(User).filter(User.tenant_id == tenant.id, User.account_id == account.id).one_or_none()
if user is None:
user = User(
tenant_id=tenant.id,
account_id=account.id,
email=account.email,
display_name="Development Admin",
is_tenant_admin=True, # legacy presentation flag; RBAC uses the owner role below.
auth_provider=account.auth_provider,
password_hash=account.password_hash,
try:
result = _access_provisioner().ensure_development_admin(
session,
tenant=tenant,
user_email=user_email,
user_password=user_password,
api_key_secret=api_key_secret,
scopes=DEFAULT_SCOPES,
)
session.add(user)
session.flush()
else:
user.email = account.email
user.password_hash = account.password_hash
user.is_active = True
session.add(user)
owner_role = tenant_roles["owner"]
existing_assignment = session.query(UserRoleAssignment).filter(
UserRoleAssignment.tenant_id == tenant.id,
UserRoleAssignment.user_id == user.id,
UserRoleAssignment.role_id == owner_role.id,
).one_or_none()
if existing_assignment is None:
session.add(UserRoleAssignment(tenant_id=tenant.id, user_id=user.id, role_id=owner_role.id))
system_owner = system_roles["system_owner"]
existing_system_assignment = session.query(SystemRoleAssignment).filter(
SystemRoleAssignment.account_id == account.id,
SystemRoleAssignment.role_id == system_owner.id,
).one_or_none()
if existing_system_assignment is None:
session.add(SystemRoleAssignment(account_id=account.id, role_id=system_owner.id))
created_api_key = None
if api_key_secret:
existing = [key for key in user.api_keys if key.name == "Development API key" and key.revoked_at is None]
if not existing:
created_api_key = create_api_key(
session,
user=user,
name="Development API key",
scopes=DEFAULT_SCOPES,
secret=api_key_secret,
)
except AdminValidationError as exc:
raise RuntimeError(str(exc)) from exc
session.commit()
return BootstrapResult(tenant=tenant, user=user, created_api_key=created_api_key)
return BootstrapResult(tenant=tenant, user=result.user, created_api_key=result.created_api_key)

View File

@@ -1,29 +1,18 @@
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Literal
"""Compatibility facade for privacy retention policy.
from pydantic import BaseModel, ConfigDict, Field, field_validator
from sqlalchemy.orm import Session
Policy-owned behavior is provided by ``govoplan-policy`` through the
``policy.privacyRetention`` capability. This module keeps legacy imports stable
without importing policy implementation code from core.
"""
from govoplan_core.admin.settings import get_system_settings
from govoplan_core.core.campaigns import (
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT,
CAPABILITY_CAMPAIGNS_RETENTION,
CampaignPolicyContext,
CampaignPolicyContextProvider,
CampaignRetentionProvider,
)
from govoplan_core.core.policy import policy_source_step
from dataclasses import dataclass
from typing import Any, Mapping
from govoplan_core.core.policy import CAPABILITY_POLICY_PRIVACY_RETENTION, PrivacyRetentionService
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
from govoplan_core.admin.models import SystemSettings
from govoplan_core.settings import settings
from govoplan_core.tenancy.scope import Tenant
PRIVACY_POLICY_SETTINGS_KEY = "privacy_retention_policy"
RETENTION_DAY_KEYS = (
@@ -38,17 +27,109 @@ RETENTION_POLICY_FIELD_KEYS = (
*RETENTION_DAY_KEYS,
"audit_detail_level",
)
AUDIT_DETAIL_LEVEL_ORDER = {"full": 0, "redacted": 1, "minimal": 2}
_DEFAULT_POLICY = {
"store_raw_campaign_json": True,
"raw_campaign_json_retention_days": None,
"generated_eml_retention_days": None,
"stored_report_detail_retention_days": None,
"mock_mailbox_retention_days": None,
"audit_detail_retention_days": None,
"audit_detail_level": "full",
}
_EXPORTS = (
"PRIVACY_POLICY_SETTINGS_KEY",
"RETENTION_DAY_KEYS",
"RETENTION_POLICY_FIELD_KEYS",
"PrivacyRetentionPolicy",
"PrivacyRetentionPolicyPatch",
"PrivacyPolicyError",
"default_allow_lower_level_limits",
"privacy_policy_from_settings",
"privacy_policy_from_session",
"set_privacy_policy",
"effective_privacy_policy",
"parent_privacy_policy",
"effective_privacy_policy_sources",
"parent_privacy_policy_sources",
"get_privacy_policy_for_scope",
"set_privacy_policy_for_scope",
"sanitize_audit_details_for_policy",
"apply_retention_policy",
)
def default_allow_lower_level_limits() -> dict[str, bool]:
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
class PrivacyPolicyError(RuntimeError):
pass
class _UnavailablePolicyModel:
def __init__(self, *args: Any, **kwargs: Any) -> None:
del args, kwargs
_raise_unavailable()
@classmethod
def model_validate(cls, value: Any) -> Any:
del value
_raise_unavailable()
PrivacyRetentionPolicy = _UnavailablePolicyModel
PrivacyRetentionPolicyPatch = _UnavailablePolicyModel
@dataclass(frozen=True, slots=True)
class _SystemPrivacyPolicy:
payload: Mapping[str, Any]
def model_dump(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
del args, kwargs
return dict(self.payload)
def _raise_unavailable() -> None:
raise PrivacyPolicyError("Privacy retention policy requires the active govoplan-policy module.")
def _runtime_service() -> PrivacyRetentionService | None:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_POLICY_PRIVACY_RETENTION):
return None
capability = registry.require_capability(CAPABILITY_POLICY_PRIVACY_RETENTION)
if not isinstance(capability, PrivacyRetentionService):
raise PrivacyPolicyError("Privacy retention policy capability is invalid")
return capability
def _service() -> PrivacyRetentionService:
service = _runtime_service()
if service is None:
_raise_unavailable()
return service
def _settings_payload(item: object) -> dict[str, Any]:
settings = getattr(item, "settings", None)
return dict(settings or {}) if isinstance(settings, Mapping) else {}
def _policy_data(settings_payload: Mapping[str, Any] | None) -> dict[str, Any]:
raw = settings_payload.get(PRIVACY_POLICY_SETTINGS_KEY) if isinstance(settings_payload, Mapping) else None
if not isinstance(raw, Mapping):
raw = {}
payload = dict(_DEFAULT_POLICY)
for key in RETENTION_POLICY_FIELD_KEYS:
if key in raw:
payload[key] = raw[key]
payload["allow_lower_level_limits"] = _normalize_allow_lower_level_limits(raw.get("allow_lower_level_limits"), fill_defaults=True)
return payload
def _normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
if value in (None, ""):
return default_allow_lower_level_limits() if fill_defaults else None
if not isinstance(value, dict):
if not isinstance(value, Mapping):
raise ValueError("allow_lower_level_limits must be an object")
normalized = default_allow_lower_level_limits() if fill_defaults else {}
for key, allowed in value.items():
@@ -58,612 +139,67 @@ def _normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> d
normalized[clean_key] = bool(allowed)
return normalized
AUDIT_DETAIL_REDACTION_KEYS = {
"attachments",
"bcc",
"body",
"campaign_json",
"cc",
"content",
"entries",
"html",
"imap",
"message",
"password",
"raw_eml",
"raw_json",
"recipients",
"secret",
"smtp",
"template",
"text",
"token",
"to",
}
def default_allow_lower_level_limits() -> dict[str, bool]:
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
class PrivacyRetentionPolicy(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool = True
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] = "full"
allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits)
def privacy_policy_from_settings(item: object) -> Any:
service = _runtime_service()
if service is not None:
return service.privacy_policy_from_settings(item)
return _SystemPrivacyPolicy(_policy_data(_settings_payload(item)))
@field_validator(
"raw_campaign_json_retention_days",
"generated_eml_retention_days",
"stored_report_detail_retention_days",
"mock_mailbox_retention_days",
"audit_detail_retention_days",
mode="before",
)
@classmethod
def _blank_string_is_none(cls, value: Any) -> Any:
if value == "":
return None
return value
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return _normalize_allow_lower_level_limits(value, fill_defaults=True)
def privacy_policy_from_session(*args: Any, **kwargs: Any) -> Any:
return _service().privacy_policy_from_session(*args, **kwargs)
class PrivacyRetentionPolicyPatch(BaseModel):
model_config = ConfigDict(extra="forbid")
def set_privacy_policy(item: object, policy: Any) -> Any:
service = _runtime_service()
if service is not None:
return service.set_privacy_policy(item, policy)
value = policy.model_dump(mode="json") if hasattr(policy, "model_dump") else dict(policy or {})
next_settings = _settings_payload(item)
next_settings[PRIVACY_POLICY_SETTINGS_KEY] = _policy_data({PRIVACY_POLICY_SETTINGS_KEY: value})
setattr(item, "settings", next_settings)
return _SystemPrivacyPolicy(next_settings[PRIVACY_POLICY_SETTINGS_KEY])
store_raw_campaign_json: bool | None = None
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] | None = None
allow_lower_level_limits: dict[str, bool] | None = None
@field_validator(*RETENTION_DAY_KEYS, mode="before")
@classmethod
def _blank_string_is_none(cls, value: Any) -> Any:
if value == "":
return None
return value
def effective_privacy_policy(*args: Any, **kwargs: Any) -> Any:
return _service().effective_privacy_policy(*args, **kwargs)
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return _normalize_allow_lower_level_limits(value, fill_defaults=False)
def parent_privacy_policy(*args: Any, **kwargs: Any) -> Any:
return _service().parent_privacy_policy(*args, **kwargs)
class PrivacyPolicyError(RuntimeError):
pass
def effective_privacy_policy_sources(*args: Any, **kwargs: Any) -> Any:
return _service().effective_privacy_policy_sources(*args, **kwargs)
def _campaign_policy_provider() -> CampaignPolicyContextProvider | None:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_CAMPAIGNS_POLICY_CONTEXT):
return None
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_POLICY_CONTEXT)
if not isinstance(capability, CampaignPolicyContextProvider):
raise PrivacyPolicyError("Campaign policy context capability is invalid")
return capability
def parent_privacy_policy_sources(*args: Any, **kwargs: Any) -> Any:
return _service().parent_privacy_policy_sources(*args, **kwargs)
def _campaign_retention_provider() -> CampaignRetentionProvider | None:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_CAMPAIGNS_RETENTION):
return None
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_RETENTION)
if not isinstance(capability, CampaignRetentionProvider):
raise PrivacyPolicyError("Campaign retention capability is invalid")
return capability
def get_privacy_policy_for_scope(*args: Any, **kwargs: Any) -> Any:
return _service().get_privacy_policy_for_scope(*args, **kwargs)
def _campaign_policy_context(session: Session, *, campaign_id: str, tenant_id: str | None = None) -> CampaignPolicyContext:
provider = _campaign_policy_provider()
if provider is None:
raise PrivacyPolicyError("Campaign module is not installed")
context = provider.get_campaign_policy_context(session, tenant_id=tenant_id, campaign_id=campaign_id)
if context is None:
raise PrivacyPolicyError("Campaign not found for privacy policy")
return context
def set_privacy_policy_for_scope(*args: Any, **kwargs: Any) -> Any:
return _service().set_privacy_policy_for_scope(*args, **kwargs)
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def sanitize_audit_details_for_policy(*args: Any, **kwargs: Any) -> dict[str, Any]:
service = _runtime_service()
if service is not None:
return dict(service.sanitize_audit_details_for_policy(*args, **kwargs))
details = args[1] if len(args) > 1 else kwargs.get("details")
return dict(details or {})
def _cutoff(days: int | None, *, now: datetime) -> datetime | None:
if days is None:
return None
return now - timedelta(days=days)
def apply_retention_policy(*args: Any, **kwargs: Any) -> Any:
return _service().apply_retention_policy(*args, **kwargs)
def _json_sha256(value: Any) -> str:
payload = json.dumps(value, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def _privacy_policy_data(settings_payload: dict[str, Any] | None) -> dict[str, Any]:
if not isinstance(settings_payload, dict):
return {}
data = settings_payload.get(PRIVACY_POLICY_SETTINGS_KEY, {})
return data if isinstance(data, dict) else {}
def _privacy_policy_patch_from_settings(settings_payload: dict[str, Any] | None) -> dict[str, Any]:
data = _privacy_policy_data(settings_payload)
if not data:
return {}
return PrivacyRetentionPolicyPatch.model_validate(data).model_dump(mode="json", exclude_none=True)
def _merge_privacy_policy(parent: PrivacyRetentionPolicy, patch: dict[str, Any]) -> PrivacyRetentionPolicy:
payload = parent.model_dump(mode="json")
parent_allow = {**default_allow_lower_level_limits(), **(payload.get("allow_lower_level_limits") or {})}
if parent_allow["store_raw_campaign_json"] and patch.get("store_raw_campaign_json") is False:
payload["store_raw_campaign_json"] = False
for key in RETENTION_DAY_KEYS:
value = patch.get(key)
if value is None or not parent_allow[key]:
continue
current = payload.get(key)
payload[key] = int(value) if current is None else min(int(current), int(value))
detail_level = patch.get("audit_detail_level")
if parent_allow["audit_detail_level"] and detail_level and AUDIT_DETAIL_LEVEL_ORDER[detail_level] > AUDIT_DETAIL_LEVEL_ORDER[payload["audit_detail_level"]]:
payload["audit_detail_level"] = detail_level
patch_allow = patch.get("allow_lower_level_limits") or {}
payload["allow_lower_level_limits"] = {
key: parent_allow[key] and bool(patch_allow.get(key, parent_allow[key]))
for key in RETENTION_POLICY_FIELD_KEYS
}
return PrivacyRetentionPolicy.model_validate(payload)
def _validate_privacy_patch_against_parent(parent: PrivacyRetentionPolicy, patch: dict[str, Any]) -> None:
parent_payload = parent.model_dump(mode="json")
parent_allow = {**default_allow_lower_level_limits(), **(parent_payload.get("allow_lower_level_limits") or {})}
for key in RETENTION_POLICY_FIELD_KEYS:
if key in patch and patch.get(key) is not None and not parent_allow[key]:
raise PrivacyPolicyError(f"{key} is locked by the parent retention policy.")
patch_allow = patch.get("allow_lower_level_limits") or {}
for key, allowed in patch_allow.items():
if allowed and not parent_allow[key]:
raise PrivacyPolicyError(f"{key} limiting cannot be re-enabled below a parent retention policy lock.")
if patch.get("store_raw_campaign_json") is True and not parent.store_raw_campaign_json:
raise PrivacyPolicyError("Raw campaign JSON storage cannot be re-enabled below a parent policy that disables it.")
for key in RETENTION_DAY_KEYS:
value = patch.get(key)
parent_value = parent_payload.get(key)
if value is not None and parent_value is not None and int(value) > int(parent_value):
raise PrivacyPolicyError(f"{key} cannot be less restrictive than the parent retention policy.")
detail_level = patch.get("audit_detail_level")
if detail_level and AUDIT_DETAIL_LEVEL_ORDER[detail_level] < AUDIT_DETAIL_LEVEL_ORDER[parent.audit_detail_level]:
raise PrivacyPolicyError("Audit detail level cannot be less restrictive than the parent retention policy.")
def _set_settings_privacy_policy(settings_payload: dict[str, Any] | None, policy: dict[str, Any]) -> dict[str, Any]:
payload = dict(settings_payload or {})
payload[PRIVACY_POLICY_SETTINGS_KEY] = policy
return payload
def privacy_policy_from_settings(item: SystemSettings) -> PrivacyRetentionPolicy:
return PrivacyRetentionPolicy.model_validate(_privacy_policy_data(item.settings or {}))
def privacy_policy_from_session(session: Session) -> PrivacyRetentionPolicy:
return privacy_policy_from_settings(get_system_settings(session))
def set_privacy_policy(item: SystemSettings, policy: PrivacyRetentionPolicy | dict[str, Any]) -> PrivacyRetentionPolicy:
validated = policy if isinstance(policy, PrivacyRetentionPolicy) else PrivacyRetentionPolicy.model_validate(policy)
item.settings = _set_settings_privacy_policy(item.settings, validated.model_dump(mode="json"))
return validated
def effective_privacy_policy(
session: Session,
*,
tenant_id: str | None = None,
owner_user_id: str | None = None,
owner_group_id: str | None = None,
campaign_id: str | None = None,
) -> PrivacyRetentionPolicy:
policy = privacy_policy_from_session(session)
campaign: CampaignPolicyContext | None = None
if campaign_id:
campaign = _campaign_policy_context(session, campaign_id=campaign_id)
tenant_id = campaign.tenant_id
owner_user_id = campaign.owner_user_id
owner_group_id = campaign.owner_group_id
if tenant_id:
tenant = session.get(Tenant, tenant_id)
if tenant is None:
raise PrivacyPolicyError("Tenant not found for privacy policy")
policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(tenant.settings or {}))
if owner_user_id:
user = session.get(User, owner_user_id)
if user and (not tenant_id or user.tenant_id == tenant_id):
policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(user.settings or {}))
if owner_group_id:
group = session.get(Group, owner_group_id)
if group and (not tenant_id or group.tenant_id == tenant_id):
policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(group.settings or {}))
if campaign is not None:
policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(dict(campaign.settings or {})))
return policy
def parent_privacy_policy(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None = None) -> PrivacyRetentionPolicy:
clean_scope = scope_type.strip().casefold()
policy = privacy_policy_from_session(session)
if clean_scope == "tenant":
return policy
tenant = session.get(Tenant, tenant_id)
if tenant is None:
raise PrivacyPolicyError("Tenant not found for privacy policy")
policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(tenant.settings or {}))
if clean_scope in {"user", "group"}:
return policy
if clean_scope != "campaign" or not scope_id:
return policy
campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id)
if campaign.owner_user_id:
user = session.get(User, campaign.owner_user_id)
if user and user.tenant_id == tenant_id:
policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(user.settings or {}))
if campaign.owner_group_id:
group = session.get(Group, campaign.owner_group_id)
if group and group.tenant_id == tenant_id:
policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(group.settings or {}))
return policy
def _retention_policy_source_fields(patch: dict[str, Any], *, baseline: bool = False) -> list[str]:
fields: list[str] = []
for key in RETENTION_POLICY_FIELD_KEYS:
if key in patch and patch.get(key) is not None:
fields.append(key)
if isinstance(patch.get("allow_lower_level_limits"), dict) and patch["allow_lower_level_limits"]:
fields.append("allow_lower_level_limits")
if baseline and not fields:
fields.append("defaults")
return fields
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 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(
session: Session,
*,
tenant_id: str | None = None,
owner_user_id: str | None = None,
owner_group_id: str | None = None,
campaign_id: str | None = None,
) -> list[dict[str, Any]]:
system_settings = get_system_settings(session)
sources = [_retention_policy_source_step("system", "System", None, _privacy_policy_patch_from_settings(system_settings.settings or {}), baseline=True)]
campaign: CampaignPolicyContext | None = None
if campaign_id:
campaign = _campaign_policy_context(session, campaign_id=campaign_id)
tenant_id = campaign.tenant_id
owner_user_id = campaign.owner_user_id
owner_group_id = campaign.owner_group_id
if tenant_id:
tenant = session.get(Tenant, tenant_id)
if tenant is None:
raise PrivacyPolicyError("Tenant not found for privacy policy")
sources.append(_retention_policy_source_step("tenant", "Tenant", tenant.id, _privacy_policy_patch_from_settings(tenant.settings or {})))
if owner_user_id:
user = session.get(User, owner_user_id)
if user and (not tenant_id or user.tenant_id == tenant_id):
sources.append(_retention_policy_source_step("user", "Owner user", user.id, _privacy_policy_patch_from_settings(user.settings or {})))
if owner_group_id:
group = session.get(Group, owner_group_id)
if group and (not tenant_id or group.tenant_id == tenant_id):
sources.append(_retention_policy_source_step("group", "Owner group", group.id, _privacy_policy_patch_from_settings(group.settings or {})))
if campaign is not None:
sources.append(_retention_policy_source_step("campaign", "Campaign", campaign.id, _privacy_policy_patch_from_settings(dict(campaign.settings or {}))))
return sources
def parent_privacy_policy_sources(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None = None) -> list[dict[str, Any]]:
clean_scope = scope_type.strip().casefold()
if clean_scope == "system":
return []
system_settings = get_system_settings(session)
sources = [_retention_policy_source_step("system", "System", None, _privacy_policy_patch_from_settings(system_settings.settings or {}), baseline=True)]
if clean_scope == "tenant":
return sources
tenant = session.get(Tenant, tenant_id)
if tenant is None:
raise PrivacyPolicyError("Tenant not found for privacy policy")
sources.append(_retention_policy_source_step("tenant", "Tenant", tenant.id, _privacy_policy_patch_from_settings(tenant.settings or {})))
if clean_scope in {"user", "group"}:
return sources
if clean_scope != "campaign" or not scope_id:
return sources
campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id)
if campaign.owner_user_id:
user = session.get(User, campaign.owner_user_id)
if user and user.tenant_id == tenant_id:
sources.append(_retention_policy_source_step("user", "Owner user", user.id, _privacy_policy_patch_from_settings(user.settings or {})))
if campaign.owner_group_id:
group = session.get(Group, campaign.owner_group_id)
if group and group.tenant_id == tenant_id:
sources.append(_retention_policy_source_step("group", "Owner group", group.id, _privacy_policy_patch_from_settings(group.settings or {})))
return sources
def get_privacy_policy_for_scope(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None = None) -> dict[str, Any]:
clean_scope = scope_type.strip().casefold()
if clean_scope == "system":
return privacy_policy_from_session(session).model_dump(mode="json")
if clean_scope == "tenant":
tenant = session.get(Tenant, scope_id or tenant_id)
if tenant is None or tenant.id != tenant_id:
raise PrivacyPolicyError("Tenant privacy policy not found")
return _privacy_policy_patch_from_settings(tenant.settings or {})
if not scope_id:
raise PrivacyPolicyError(f"{clean_scope.capitalize()} privacy policy requires scope_id")
if clean_scope == "user":
user = session.get(User, scope_id)
if user is None or user.tenant_id != tenant_id:
raise PrivacyPolicyError("User privacy policy not found")
return _privacy_policy_patch_from_settings(user.settings or {})
if clean_scope == "group":
group = session.get(Group, scope_id)
if group is None or group.tenant_id != tenant_id:
raise PrivacyPolicyError("Group privacy policy not found")
return _privacy_policy_patch_from_settings(group.settings or {})
if clean_scope == "campaign":
campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id)
return _privacy_policy_patch_from_settings(dict(campaign.settings or {}))
raise PrivacyPolicyError("Privacy policy scope must be system, tenant, user, group or campaign")
def set_privacy_policy_for_scope(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None = None,
policy: dict[str, Any] | None = None,
) -> dict[str, Any]:
clean_scope = scope_type.strip().casefold()
if clean_scope == "system":
item = get_system_settings(session)
validated = set_privacy_policy(item, PrivacyRetentionPolicy.model_validate(policy or {}))
session.add(item)
return validated.model_dump(mode="json")
patch = PrivacyRetentionPolicyPatch.model_validate(policy or {}).model_dump(mode="json", exclude_none=True)
_validate_privacy_patch_against_parent(parent_privacy_policy(session, tenant_id=tenant_id, scope_type=clean_scope, scope_id=scope_id or (tenant_id if clean_scope == "tenant" else None)), patch)
if clean_scope == "tenant":
tenant = session.get(Tenant, scope_id or tenant_id)
if tenant is None or tenant.id != tenant_id:
raise PrivacyPolicyError("Tenant privacy policy not found")
tenant.settings = _set_settings_privacy_policy(tenant.settings, patch)
session.add(tenant)
return patch
if not scope_id:
raise PrivacyPolicyError(f"{clean_scope.capitalize()} privacy policy requires scope_id")
if clean_scope == "user":
user = session.get(User, scope_id)
if user is None or user.tenant_id != tenant_id:
raise PrivacyPolicyError("User privacy policy not found")
user.settings = _set_settings_privacy_policy(user.settings, patch)
session.add(user)
return patch
if clean_scope == "group":
group = session.get(Group, scope_id)
if group is None or group.tenant_id != tenant_id:
raise PrivacyPolicyError("Group privacy policy not found")
group.settings = _set_settings_privacy_policy(group.settings, patch)
session.add(group)
return patch
if clean_scope == "campaign":
provider = _campaign_policy_provider()
if provider is None:
raise PrivacyPolicyError("Campaign module is not installed")
campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id)
settings_payload = _set_settings_privacy_policy(dict(campaign.settings or {}), patch)
try:
provider.set_campaign_settings(session, tenant_id=tenant_id, campaign_id=scope_id, settings=settings_payload)
except ValueError as exc:
raise PrivacyPolicyError("Campaign privacy policy not found") from exc
return patch
raise PrivacyPolicyError("Privacy policy scope must be system, tenant, user, group or campaign")
def sanitize_audit_details_for_policy(session: Session, details: dict[str, Any]) -> dict[str, Any]:
policy = privacy_policy_from_session(session)
if policy.audit_detail_level == "full":
return details
if policy.audit_detail_level == "minimal":
return {
"_privacy": {"detail_level": "minimal"},
"keys": sorted(str(key) for key in details.keys()),
}
return _redact_audit_value(details)
def _redact_audit_value(value: Any) -> Any:
if isinstance(value, dict):
redacted: dict[str, Any] = {}
for key, item in value.items():
if str(key).lower() in AUDIT_DETAIL_REDACTION_KEYS:
redacted[key] = "<redacted>"
else:
redacted[key] = _redact_audit_value(item)
return redacted
if isinstance(value, list):
return [_redact_audit_value(item) for item in value]
return value
def _is_before_cutoff(value: datetime | None, cutoff: datetime | None) -> bool:
if value is None or cutoff is None:
return False
candidate = value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value
return candidate < cutoff
def _system_cutoffs(policy: PrivacyRetentionPolicy, *, now: datetime) -> dict[str, datetime | None]:
return {
"raw_campaign_json": _cutoff(0 if not policy.store_raw_campaign_json else policy.raw_campaign_json_retention_days, now=now),
"generated_eml": _cutoff(policy.generated_eml_retention_days, now=now),
"stored_report_detail": _cutoff(policy.stored_report_detail_retention_days, now=now),
"mock_mailbox": _cutoff(policy.mock_mailbox_retention_days, now=now),
"audit_detail": _cutoff(policy.audit_detail_retention_days, now=now),
}
def apply_retention_policy(session: Session, *, dry_run: bool = True) -> dict[str, Any]:
now = _utcnow()
policy = privacy_policy_from_session(session)
cutoffs = _system_cutoffs(policy, now=now)
policy_cache: dict[str, PrivacyRetentionPolicy] = {}
campaign_retention = _campaign_retention_provider()
campaign_counts = {
"raw_campaign_json": {"eligible": 0, "redacted": 0, "skipped_not_final": 0, "already_redacted": 0},
"generated_eml": {"eligible": 0, "metadata_cleared": 0, "files_deleted": 0, "files_missing": 0, "skipped_not_final": 0},
"stored_report_detail": {"eligible_versions": 0, "summaries_redacted": 0, "already_redacted": 0},
}
if campaign_retention is not None:
campaign_counts.update(
{
key: dict(value)
for key, value in campaign_retention.apply_retention(
session,
dry_run=dry_run,
now=now,
policy_for_campaign_id=lambda campaign_id: _campaign_policy_for_id(session, campaign_id, policy_cache),
).items()
}
)
counts = {
"raw_campaign_json": campaign_counts["raw_campaign_json"],
"generated_eml": campaign_counts["generated_eml"],
"stored_report_detail": campaign_counts["stored_report_detail"],
"mock_mailbox": _apply_mock_mailbox_retention(cutoffs["mock_mailbox"], dry_run=dry_run),
"audit_detail": _apply_audit_detail_retention(session, dry_run=dry_run, now=now),
}
return {
"dry_run": dry_run,
"policy": policy.model_dump(mode="json"),
"cutoffs": {key: value.isoformat() if value else None for key, value in cutoffs.items()},
"effective_policy_scope": "per-object",
"counts": counts,
}
def _campaign_policy_for_id(session: Session, campaign_id: str | None, cache: dict[str, PrivacyRetentionPolicy]) -> PrivacyRetentionPolicy:
if not campaign_id:
return privacy_policy_from_session(session)
if campaign_id not in cache:
cache[campaign_id] = effective_privacy_policy(session, campaign_id=campaign_id)
return cache[campaign_id]
def _apply_mock_mailbox_retention(cutoff: datetime | None, *, dry_run: bool) -> dict[str, int]:
result = {"eligible_records": 0, "json_deleted": 0, "eml_deleted": 0}
if cutoff is None:
return result
message_dir = Path(settings.mock_mailbox_dir) / "messages"
if not message_dir.exists():
return result
for path in message_dir.glob("*.json"):
try:
record = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
continue
created_at = _parse_datetime(record.get("created_at"))
if created_at and created_at >= cutoff:
continue
result["eligible_records"] += 1
if dry_run:
continue
raw_filename = record.get("raw_filename")
if raw_filename:
raw_path = message_dir / str(raw_filename)
if raw_path.exists():
raw_path.unlink()
result["eml_deleted"] += 1
if path.exists():
path.unlink()
result["json_deleted"] += 1
return result
def _parse_datetime(value: Any) -> datetime | None:
if not value:
return None
try:
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def _privacy_policy_for_audit_item(session: Session, item: AuditLog, campaign_cache: dict[str, PrivacyRetentionPolicy], tenant_cache: dict[str, PrivacyRetentionPolicy]) -> PrivacyRetentionPolicy:
if item.object_type == "campaign" and item.object_id:
provider = _campaign_policy_provider()
if provider is not None:
context = provider.get_campaign_policy_context(session, campaign_id=str(item.object_id))
if context is not None:
return _campaign_policy_for_id(session, context.id, campaign_cache)
if item.tenant_id:
if item.tenant_id not in tenant_cache:
tenant_cache[item.tenant_id] = effective_privacy_policy(session, tenant_id=item.tenant_id)
return tenant_cache[item.tenant_id]
return privacy_policy_from_session(session)
def _apply_audit_detail_retention(session: Session, *, dry_run: bool, now: datetime) -> dict[str, int]:
result = {"eligible": 0, "redacted": 0, "already_redacted": 0}
campaign_cache: dict[str, PrivacyRetentionPolicy] = {}
tenant_cache: dict[str, PrivacyRetentionPolicy] = {}
items = (
session.query(AuditLog)
.order_by(AuditLog.created_at.asc())
.all()
)
for item in items:
policy = _privacy_policy_for_audit_item(session, item, campaign_cache, tenant_cache)
cutoff = _cutoff(policy.audit_detail_retention_days, now=now)
if not _is_before_cutoff(item.created_at, cutoff):
continue
details = item.details if isinstance(item.details, dict) else {}
if details.get("_retention", {}).get("audit_detail_redacted"):
result["already_redacted"] += 1
continue
result["eligible"] += 1
if dry_run:
continue
item.details = {
"_retention": {
"audit_detail_redacted": True,
"redacted_at": now.isoformat(),
"original_detail_sha256": _json_sha256(details),
}
}
session.add(item)
result["redacted"] += 1
return result
__all__ = list(_EXPORTS)

View File

@@ -6,8 +6,8 @@ from sqlalchemy.orm import Session
from govoplan_core.admin.common import AdminValidationError
from govoplan_core.admin.settings import get_system_settings
from govoplan_core.core.access import CAPABILITY_ACCESS_ADMINISTRATION, AccessAdministration
from govoplan_core.core.runtime import get_registry
from govoplan_access.backend.db.models import ApiKey, Group, User
from govoplan_core.tenancy.scope import Tenant
@@ -57,14 +57,27 @@ def _tenant_module_counts(session: Session, tenant_id: str) -> dict[str, int]:
return counts
def _access_administration() -> AccessAdministration | None:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_ACCESS_ADMINISTRATION):
return None
capability = registry.require_capability(CAPABILITY_ACCESS_ADMINISTRATION)
if not isinstance(capability, AccessAdministration):
raise TypeError("Access administration capability is invalid")
return capability
def tenant_counts(session: Session, tenant_id: str) -> dict[str, int]:
module_counts = _tenant_module_counts(session, tenant_id)
access_administration = _access_administration()
access_counts = access_administration.tenant_counts(session, tenant_id) if access_administration is not None else {}
return {
"users": session.query(User).filter(User.tenant_id == tenant_id).count(),
"active_users": session.query(User).filter(User.tenant_id == tenant_id, User.is_active.is_(True)).count(),
"groups": session.query(Group).filter(Group.tenant_id == tenant_id).count(),
"users": int(access_counts.get("users", 0)),
"active_users": int(access_counts.get("active_users", 0)),
"groups": int(access_counts.get("groups", 0)),
"campaigns": module_counts.get("campaigns", 0),
"files": module_counts.get("files", 0),
"api_keys": session.query(ApiKey).filter(ApiKey.tenant_id == tenant_id, ApiKey.revoked_at.is_(None)).count(),
"api_keys": int(access_counts.get("api_keys", 0)),
"active_api_keys": int(access_counts.get("active_api_keys", access_counts.get("api_keys", 0))),
}