Prepare GovOPlaN self-hosted release workflow
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
54
src/govoplan_core/commands/config.py
Normal file
54
src/govoplan_core/commands/config.py
Normal 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())
|
||||
@@ -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]:
|
||||
...
|
||||
|
||||
@@ -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",
|
||||
|
||||
52
src/govoplan_core/core/idm.py
Normal file
52
src/govoplan_core/core/idm.py
Normal 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]:
|
||||
...
|
||||
314
src/govoplan_core/core/install_config.py
Normal file
314
src/govoplan_core/core/install_config.py
Normal 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
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
...
|
||||
|
||||
@@ -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:
|
||||
...
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
59
src/govoplan_core/core/versioning.py
Normal file
59
src/govoplan_core/core/versioning.py
Normal 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("."))
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user