chore: consolidate platform split checks
This commit is contained in:
@@ -9,7 +9,7 @@ from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
class SystemSettings(Base, TimestampMixin):
|
||||
__tablename__ = "system_settings"
|
||||
__tablename__ = "core_system_settings"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default="global")
|
||||
default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False)
|
||||
|
||||
@@ -24,6 +24,21 @@ class AuditLogListResponse(BaseModel):
|
||||
items: list[AuditLogItemResponse]
|
||||
|
||||
|
||||
class DeltaDeletedItem(BaseModel):
|
||||
id: str
|
||||
resource_type: str | None = None
|
||||
revision: str | None = None
|
||||
deleted_at: datetime | None = None
|
||||
|
||||
|
||||
class DeltaCollectionResponse(BaseModel):
|
||||
items: list[dict[str, Any]] = Field(default_factory=list)
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -46,6 +61,7 @@ class TenantInfo(BaseModel):
|
||||
name: str
|
||||
is_active: bool = True
|
||||
default_locale: str = "en"
|
||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TenantMembershipInfo(TenantInfo):
|
||||
@@ -53,6 +69,16 @@ class TenantMembershipInfo(TenantInfo):
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class UserUiPreferences(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
compact_tables: bool = False
|
||||
show_inline_help_hints: bool = True
|
||||
reduce_motion: bool = False
|
||||
sticky_section_sidebars: bool = True
|
||||
theme: Literal["system", "light", "dark"] = "system"
|
||||
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
id: str
|
||||
account_id: str
|
||||
@@ -63,6 +89,9 @@ class UserInfo(BaseModel):
|
||||
tenant_display_name: str | None = None
|
||||
is_tenant_admin: bool = False
|
||||
password_reset_required: bool = False
|
||||
preferred_language: str | None = None
|
||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences)
|
||||
|
||||
|
||||
class ProfileUpdateRequest(BaseModel):
|
||||
@@ -70,6 +99,15 @@ class ProfileUpdateRequest(BaseModel):
|
||||
|
||||
display_name: str | None = Field(default=None, max_length=255)
|
||||
tenant_display_name: str | None = Field(default=None, max_length=255)
|
||||
preferred_language: str | None = Field(default=None, max_length=20)
|
||||
enabled_language_codes: list[str] | None = None
|
||||
ui_preferences: UserUiPreferences | None = None
|
||||
|
||||
|
||||
class LanguageInfo(BaseModel):
|
||||
code: str
|
||||
label: str
|
||||
native_label: str | None = None
|
||||
|
||||
|
||||
class RoleInfo(BaseModel):
|
||||
@@ -86,6 +124,25 @@ class GroupInfo(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class PrincipalContextInfo(BaseModel):
|
||||
account_id: str
|
||||
membership_id: str | None = None
|
||||
tenant_id: str | None = None
|
||||
identity_id: str | None = None
|
||||
scopes: list[str] = Field(default_factory=list)
|
||||
group_ids: list[str] = Field(default_factory=list)
|
||||
role_ids: list[str] = Field(default_factory=list)
|
||||
function_assignment_ids: list[str] = Field(default_factory=list)
|
||||
delegation_ids: list[str] = Field(default_factory=list)
|
||||
auth_method: Literal["session", "api_key", "service_account"] = "session"
|
||||
api_key_id: str | None = None
|
||||
session_id: str | None = None
|
||||
service_account_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
email: str | None = None
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
@@ -98,6 +155,10 @@ class LoginResponse(BaseModel):
|
||||
scopes: list[str]
|
||||
roles: list[RoleInfo] = Field(default_factory=list)
|
||||
groups: list[GroupInfo] = Field(default_factory=list)
|
||||
principal: PrincipalContextInfo | None = None
|
||||
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
default_language: str = "en"
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
@@ -109,3 +170,7 @@ class MeResponse(BaseModel):
|
||||
scopes: list[str]
|
||||
roles: list[RoleInfo] = Field(default_factory=list)
|
||||
groups: list[GroupInfo] = Field(default_factory=list)
|
||||
principal: PrincipalContextInfo | None = None
|
||||
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
default_language: str = "en"
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal
|
||||
from govoplan_access.auth import ApiPrincipal
|
||||
from govoplan_core.core.change_sequence import record_change
|
||||
from govoplan_core.core.events import current_event_trace, normalize_trace_id
|
||||
from govoplan_core.privacy.retention import sanitize_audit_details_for_policy
|
||||
from govoplan_audit.backend.db.models import AuditLog
|
||||
|
||||
|
||||
AUDIT_MODULE_ID = "audit"
|
||||
AUDIT_TENANT_EVENTS_COLLECTION = "audit.admin.tenant_events"
|
||||
AUDIT_SYSTEM_EVENTS_COLLECTION = "audit.admin.system_events"
|
||||
|
||||
SENSITIVE_DETAIL_KEYS = {
|
||||
"password",
|
||||
"smtp_password",
|
||||
@@ -16,9 +23,40 @@ SENSITIVE_DETAIL_KEYS = {
|
||||
"secret",
|
||||
"api_key",
|
||||
"token",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"authorization",
|
||||
"cookie",
|
||||
"credentials",
|
||||
"credential",
|
||||
"private_key",
|
||||
"claim_token",
|
||||
"build_token",
|
||||
}
|
||||
|
||||
|
||||
def _optional_text(value: object | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
candidate = str(value).strip()
|
||||
return candidate or None
|
||||
|
||||
|
||||
def _compact_trace(trace: Mapping[str, object] | None) -> dict[str, str]:
|
||||
if not isinstance(trace, Mapping):
|
||||
return {}
|
||||
compact: dict[str, str] = {}
|
||||
correlation_id = _optional_text(trace.get("correlation_id"))
|
||||
causation_id = _optional_text(trace.get("causation_id"))
|
||||
clean_correlation_id = normalize_trace_id(correlation_id)
|
||||
clean_causation_id = normalize_trace_id(causation_id)
|
||||
if clean_correlation_id:
|
||||
compact["correlation_id"] = clean_correlation_id
|
||||
if clean_causation_id:
|
||||
compact["causation_id"] = clean_causation_id
|
||||
return compact
|
||||
|
||||
|
||||
def _sanitize_details(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
sanitized: dict[str, Any] = {}
|
||||
@@ -33,6 +71,70 @@ def _sanitize_details(value: Any) -> Any:
|
||||
return value
|
||||
|
||||
|
||||
def audit_operation_context(
|
||||
*,
|
||||
module_id: object | None = None,
|
||||
request_id: object | None = None,
|
||||
run_id: object | None = None,
|
||||
outcome: object | None = None,
|
||||
trace: Mapping[str, object] | None = None,
|
||||
**details: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Build concise operational audit details for admin and lifecycle actions."""
|
||||
|
||||
payload: dict[str, Any] = {}
|
||||
for key, value in (
|
||||
("module_id", module_id),
|
||||
("request_id", request_id),
|
||||
("run_id", run_id),
|
||||
("outcome", outcome),
|
||||
):
|
||||
text = _optional_text(value)
|
||||
if text is not None:
|
||||
payload[key] = text
|
||||
for key, value in details.items():
|
||||
if value is not None:
|
||||
payload[key] = _sanitize_details(value)
|
||||
compact_trace = _compact_trace(trace)
|
||||
if compact_trace:
|
||||
existing = payload.get("_trace")
|
||||
payload["_trace"] = {**existing, **compact_trace} if isinstance(existing, dict) else compact_trace
|
||||
return payload
|
||||
|
||||
|
||||
def _trace_details(
|
||||
details: dict[str, Any],
|
||||
*,
|
||||
correlation_id: str | None = None,
|
||||
causation_id: str | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, str]]:
|
||||
active_trace = current_event_trace()
|
||||
trace: dict[str, str] = {}
|
||||
clean_correlation_id = normalize_trace_id(correlation_id)
|
||||
clean_causation_id = normalize_trace_id(causation_id)
|
||||
if clean_correlation_id or active_trace:
|
||||
trace["correlation_id"] = clean_correlation_id or active_trace.correlation_id
|
||||
if clean_causation_id or (active_trace and active_trace.causation_id):
|
||||
trace["causation_id"] = clean_causation_id or str(active_trace.causation_id)
|
||||
if not trace:
|
||||
return details, {}
|
||||
merged = dict(details)
|
||||
existing = merged.get("_trace")
|
||||
merged["_trace"] = {**existing, **trace} if isinstance(existing, dict) else trace
|
||||
return merged, trace
|
||||
|
||||
|
||||
def _restore_trace_after_policy(details: dict[str, Any], trace: dict[str, str]) -> dict[str, Any]:
|
||||
if not trace:
|
||||
return details
|
||||
if details.get("_trace") == trace:
|
||||
return details
|
||||
restored = dict(details)
|
||||
existing = restored.get("_trace")
|
||||
restored["_trace"] = {**existing, **trace} if isinstance(existing, dict) else trace
|
||||
return restored
|
||||
|
||||
|
||||
def audit_event(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -44,6 +146,8 @@ def audit_event(
|
||||
object_type: str | None = None,
|
||||
object_id: str | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
correlation_id: str | None = None,
|
||||
causation_id: str | None = None,
|
||||
commit: bool = False,
|
||||
) -> AuditLog:
|
||||
"""Persist one audit event.
|
||||
@@ -56,6 +160,16 @@ def audit_event(
|
||||
if scope not in {"tenant", "system"}:
|
||||
raise ValueError(f"Unsupported audit scope: {scope}")
|
||||
|
||||
traced_details, trace = _trace_details(
|
||||
_sanitize_details(details or {}),
|
||||
correlation_id=correlation_id,
|
||||
causation_id=causation_id,
|
||||
)
|
||||
stored_details = _restore_trace_after_policy(
|
||||
sanitize_audit_details_for_policy(session, traced_details),
|
||||
trace,
|
||||
)
|
||||
|
||||
item = AuditLog(
|
||||
scope=scope,
|
||||
tenant_id=tenant_id,
|
||||
@@ -64,13 +178,24 @@ def audit_event(
|
||||
action=action,
|
||||
object_type=object_type,
|
||||
object_id=object_id,
|
||||
details=sanitize_audit_details_for_policy(session, _sanitize_details(details or {})),
|
||||
details=stored_details,
|
||||
)
|
||||
session.add(item)
|
||||
session.flush()
|
||||
record_change(
|
||||
session,
|
||||
module_id=AUDIT_MODULE_ID,
|
||||
collection=AUDIT_SYSTEM_EVENTS_COLLECTION if scope == "system" else AUDIT_TENANT_EVENTS_COLLECTION,
|
||||
resource_type="audit_log",
|
||||
resource_id=item.id,
|
||||
operation="created",
|
||||
tenant_id=None if scope == "system" else tenant_id,
|
||||
actor_type="user" if user_id else ("api_key" if api_key_id else None),
|
||||
actor_id=user_id or api_key_id,
|
||||
payload={"scope": scope, "action": action, "object_type": object_type, "object_id": object_id},
|
||||
)
|
||||
if commit:
|
||||
session.commit()
|
||||
else:
|
||||
session.flush()
|
||||
return item
|
||||
|
||||
|
||||
@@ -83,6 +208,8 @@ def audit_from_principal(
|
||||
object_type: str | None = None,
|
||||
object_id: str | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
correlation_id: str | None = None,
|
||||
causation_id: str | None = None,
|
||||
commit: bool = False,
|
||||
) -> AuditLog:
|
||||
return audit_event(
|
||||
@@ -95,5 +222,7 @@ def audit_from_principal(
|
||||
object_type=object_type,
|
||||
object_id=object_id,
|
||||
details=details,
|
||||
correlation_id=correlation_id,
|
||||
causation_id=causation_id,
|
||||
commit=commit,
|
||||
)
|
||||
|
||||
@@ -27,6 +27,7 @@ from govoplan_core.core.module_installer import (
|
||||
update_module_installer_request,
|
||||
update_module_installer_daemon_status,
|
||||
)
|
||||
from govoplan_core.core.module_license import issue_module_license, module_license_diagnostics
|
||||
from govoplan_core.core.module_package_catalog import sign_module_package_catalog, validate_module_package_catalog
|
||||
from govoplan_core.core.module_management import (
|
||||
configured_enabled_modules,
|
||||
@@ -80,10 +81,69 @@ def main() -> int:
|
||||
parser.add_argument("--catalog-signing-key-id", help="Key id to record when signing a package catalog.")
|
||||
parser.add_argument("--catalog-signing-private-key", type=Path, help="PEM Ed25519 private key used by --sign-package-catalog.")
|
||||
parser.add_argument("--catalog-output", type=Path, help="Output path for --sign-package-catalog. Defaults to overwriting the input file.")
|
||||
parser.add_argument("--validate-license", nargs="?", const="", metavar="PATH", help="Validate a license JSON file, or the configured license when PATH is omitted.")
|
||||
parser.add_argument("--require-trusted-license", action="store_true", help="Require license validation to trust an Ed25519 signature.")
|
||||
parser.add_argument("--license-trusted-key", action="append", default=[], metavar="KEY_ID=BASE64_PUBLIC_KEY", help="Trusted Ed25519 license public key; may be repeated.")
|
||||
parser.add_argument("--license-required-feature", action="append", default=[], help="License feature to check; may be repeated.")
|
||||
parser.add_argument("--issue-license", type=Path, metavar="PATH", help="Write a signed offline license JSON file.")
|
||||
parser.add_argument("--license-id", help="License id for --issue-license.")
|
||||
parser.add_argument("--license-subject", help="License subject/customer for --issue-license.")
|
||||
parser.add_argument("--license-feature", action="append", default=[], help="Feature entitlement to include in --issue-license; may be repeated.")
|
||||
parser.add_argument("--license-valid-from", help="License valid_from timestamp. Defaults to now.")
|
||||
parser.add_argument("--license-valid-until", help="License valid_until timestamp.")
|
||||
parser.add_argument("--license-signing-key-id", help="Signing key id for --issue-license.")
|
||||
parser.add_argument("--license-signing-private-key", type=Path, help="PEM Ed25519 private key for --issue-license.")
|
||||
parser.add_argument("--license-issuer", help="Optional issuer string for --issue-license.")
|
||||
parser.add_argument("--license-notes", help="Optional operator note for --issue-license.")
|
||||
args = parser.parse_args()
|
||||
|
||||
runtime_dir = args.runtime_dir or default_installer_runtime_dir(args.database_url)
|
||||
try:
|
||||
if args.issue_license:
|
||||
if not args.license_id or not args.license_subject or not args.license_valid_until:
|
||||
raise ModuleInstallerError("--issue-license requires --license-id, --license-subject, and --license-valid-until.")
|
||||
if not args.license_signing_key_id or not args.license_signing_private_key:
|
||||
raise ModuleInstallerError("--issue-license requires --license-signing-key-id and --license-signing-private-key.")
|
||||
if not [item for item in args.license_feature if item.strip()]:
|
||||
raise ModuleInstallerError("--issue-license requires at least one --license-feature.")
|
||||
try:
|
||||
path = issue_module_license(
|
||||
path=args.issue_license,
|
||||
license_id=args.license_id,
|
||||
subject=args.license_subject,
|
||||
features=args.license_feature,
|
||||
valid_from=args.license_valid_from,
|
||||
valid_until=args.license_valid_until,
|
||||
signing_key_id=args.license_signing_key_id,
|
||||
signing_private_key_path=args.license_signing_private_key,
|
||||
issuer=args.license_issuer,
|
||||
notes=args.license_notes,
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise ModuleInstallerError(str(exc)) from exc
|
||||
_print_result(
|
||||
{
|
||||
"issued": True,
|
||||
"path": str(path),
|
||||
"license_id": args.license_id,
|
||||
"subject": args.license_subject,
|
||||
"features": list(dict.fromkeys(item.strip() for item in args.license_feature if item.strip())),
|
||||
"valid_until": args.license_valid_until,
|
||||
"key_id": args.license_signing_key_id,
|
||||
},
|
||||
output_format=args.format,
|
||||
)
|
||||
return 0
|
||||
if args.validate_license is not None:
|
||||
path = Path(args.validate_license).expanduser() if args.validate_license else None
|
||||
result = module_license_diagnostics(
|
||||
path,
|
||||
require_trusted=args.require_trusted_license,
|
||||
trusted_keys=_trusted_keys_from_args(args.license_trusted_key, flag_name="--license-trusted-key"),
|
||||
required_features=args.license_required_feature,
|
||||
)
|
||||
_print_result(result, output_format=args.format)
|
||||
return 0 if result.get("valid") and not result.get("missing_features") else 1
|
||||
if args.sign_package_catalog:
|
||||
if not args.catalog_signing_key_id or not args.catalog_signing_private_key:
|
||||
raise ModuleInstallerError("--sign-package-catalog requires --catalog-signing-key-id and --catalog-signing-private-key.")
|
||||
@@ -101,7 +161,7 @@ def main() -> int:
|
||||
path,
|
||||
require_trusted=args.require_signed_catalog,
|
||||
approved_channels=tuple(args.approved_catalog_channel),
|
||||
trusted_keys=_trusted_catalog_keys_from_args(args.catalog_trusted_key),
|
||||
trusted_keys=_trusted_keys_from_args(args.catalog_trusted_key, flag_name="--catalog-trusted-key"),
|
||||
)
|
||||
_print_result(result, output_format=args.format)
|
||||
return 0 if result.get("valid") else 1
|
||||
@@ -322,6 +382,7 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
|
||||
health_urls=_list_option(options, "health_urls") or _list_option(options, "health_url") or ([args.health_url] if args.health_url else []),
|
||||
health_timeout_seconds=_float_option(options, "health_timeout_seconds", args.health_timeout_seconds),
|
||||
health_interval_seconds=_float_option(options, "health_interval_seconds", args.health_interval_seconds),
|
||||
request_context=_request_context(request),
|
||||
)
|
||||
update_module_installer_request(
|
||||
runtime_dir=runtime_dir,
|
||||
@@ -362,6 +423,18 @@ def _request_options_from_args(args: argparse.Namespace) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
def _request_context(request: dict[str, object]) -> dict[str, object]:
|
||||
context: dict[str, object] = {
|
||||
"request_id": request.get("request_id"),
|
||||
"requested_by": request.get("requested_by"),
|
||||
}
|
||||
if request.get("retry_of"):
|
||||
context["retry_of"] = request["retry_of"]
|
||||
if isinstance(request.get("trace"), dict):
|
||||
context["trace"] = request["trace"]
|
||||
return {key: value for key, value in context.items() if value is not None}
|
||||
|
||||
|
||||
def _str_option(options: object, key: str) -> str | None:
|
||||
if not isinstance(options, dict):
|
||||
return None
|
||||
@@ -403,14 +476,14 @@ def _list_option(options: object, key: str) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
def _trusted_catalog_keys_from_args(values: list[str]) -> dict[str, str] | None:
|
||||
def _trusted_keys_from_args(values: list[str], *, flag_name: str) -> dict[str, str] | None:
|
||||
if not values:
|
||||
return None
|
||||
keys: dict[str, str] = {}
|
||||
for value in values:
|
||||
key_id, separator, public_key = value.partition("=")
|
||||
if not separator or not key_id.strip() or not public_key.strip():
|
||||
raise ModuleInstallerError("--catalog-trusted-key must use KEY_ID=BASE64_PUBLIC_KEY.")
|
||||
raise ModuleInstallerError(f"{flag_name} must use KEY_ID=BASE64_PUBLIC_KEY.")
|
||||
keys[key_id.strip()] = public_key.strip()
|
||||
return keys
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
from typing import Literal, Protocol, cast, runtime_checkable
|
||||
|
||||
from govoplan_core.core.modules import AccessDecision
|
||||
|
||||
@@ -17,11 +17,12 @@ CAPABILITY_ACCESS_PRINCIPAL_RESOLVER = "access.principalResolver"
|
||||
CAPABILITY_ACCESS_DIRECTORY = "access.directory"
|
||||
CAPABILITY_ACCESS_PERMISSION_EVALUATOR = "access.permissionEvaluator"
|
||||
CAPABILITY_ACCESS_RESOURCE_ACCESS = "access.resourceAccess"
|
||||
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY = "access.semanticDirectory"
|
||||
CAPABILITY_ACCESS_EXPLANATION = "access.explanation"
|
||||
CAPABILITY_ACCESS_TENANT_PROVISIONER = "access.tenantProvisioner"
|
||||
CAPABILITY_ACCESS_ADMINISTRATION = "access.administration"
|
||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER = "access.governanceMaterializer"
|
||||
CAPABILITY_TENANCY_TENANT_RESOLVER = "tenancy.tenantResolver"
|
||||
CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider"
|
||||
CAPABILITY_AUDIT_SINK = "audit.sink"
|
||||
|
||||
ACCESS_CAPABILITY_NAMES = frozenset(
|
||||
@@ -30,20 +31,47 @@ ACCESS_CAPABILITY_NAMES = frozenset(
|
||||
CAPABILITY_ACCESS_DIRECTORY,
|
||||
CAPABILITY_ACCESS_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_ACCESS_RESOURCE_ACCESS,
|
||||
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
|
||||
CAPABILITY_ACCESS_EXPLANATION,
|
||||
CAPABILITY_ACCESS_TENANT_PROVISIONER,
|
||||
CAPABILITY_ACCESS_ADMINISTRATION,
|
||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
|
||||
CAPABILITY_TENANCY_TENANT_RESOLVER,
|
||||
CAPABILITY_SECURITY_SECRET_PROVIDER,
|
||||
CAPABILITY_AUDIT_SINK,
|
||||
}
|
||||
)
|
||||
|
||||
AuthMethod = Literal["session", "api_key", "service_account"]
|
||||
AccessSubjectKind = Literal["account", "user", "group", "role", "tenant", "api_key", "service_account"]
|
||||
AccessSubjectKind = Literal[
|
||||
"identity",
|
||||
"account",
|
||||
"user",
|
||||
"group",
|
||||
"role",
|
||||
"tenant",
|
||||
"organization_unit",
|
||||
"function",
|
||||
"api_key",
|
||||
"service_account",
|
||||
]
|
||||
AccessStatus = Literal["active", "inactive", "suspended"]
|
||||
PermissionLevel = Literal["system", "tenant"]
|
||||
AuditOutcome = Literal["success", "failure", "denied", "unknown"]
|
||||
FunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
|
||||
FunctionDelegationMode = Literal["delegate", "act_in_place"]
|
||||
AccessProvenanceKind = Literal[
|
||||
"identity",
|
||||
"account",
|
||||
"tenant_membership",
|
||||
"organization_unit",
|
||||
"function",
|
||||
"group",
|
||||
"role",
|
||||
"right",
|
||||
"delegation",
|
||||
"policy",
|
||||
"system_actor",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -51,15 +79,75 @@ class PrincipalRef:
|
||||
account_id: str
|
||||
membership_id: str | None
|
||||
tenant_id: str | None
|
||||
identity_id: str | None = None
|
||||
scopes: frozenset[str] = field(default_factory=frozenset)
|
||||
group_ids: frozenset[str] = field(default_factory=frozenset)
|
||||
role_ids: frozenset[str] = field(default_factory=frozenset)
|
||||
function_assignment_ids: frozenset[str] = field(default_factory=frozenset)
|
||||
delegation_ids: frozenset[str] = field(default_factory=frozenset)
|
||||
auth_method: AuthMethod = "session"
|
||||
api_key_id: str | None = None
|
||||
session_id: str | None = None
|
||||
service_account_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
email: str | None = None
|
||||
display_name: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"account_id": self.account_id,
|
||||
"membership_id": self.membership_id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"identity_id": self.identity_id,
|
||||
"scopes": sorted(self.scopes),
|
||||
"group_ids": sorted(self.group_ids),
|
||||
"role_ids": sorted(self.role_ids),
|
||||
"function_assignment_ids": sorted(self.function_assignment_ids),
|
||||
"delegation_ids": sorted(self.delegation_ids),
|
||||
"auth_method": self.auth_method,
|
||||
"api_key_id": self.api_key_id,
|
||||
"session_id": self.session_id,
|
||||
"service_account_id": self.service_account_id,
|
||||
"acting_for_account_id": self.acting_for_account_id,
|
||||
"email": self.email,
|
||||
"display_name": self.display_name,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, object]) -> PrincipalRef:
|
||||
return cls(
|
||||
account_id=str(value["account_id"]),
|
||||
membership_id=_optional_str(value.get("membership_id")),
|
||||
tenant_id=_optional_str(value.get("tenant_id")),
|
||||
identity_id=_optional_str(value.get("identity_id")),
|
||||
scopes=_string_frozenset(value.get("scopes")),
|
||||
group_ids=_string_frozenset(value.get("group_ids")),
|
||||
role_ids=_string_frozenset(value.get("role_ids")),
|
||||
function_assignment_ids=_string_frozenset(value.get("function_assignment_ids")),
|
||||
delegation_ids=_string_frozenset(value.get("delegation_ids")),
|
||||
auth_method=cast(AuthMethod, str(value.get("auth_method") or "session")),
|
||||
api_key_id=_optional_str(value.get("api_key_id")),
|
||||
session_id=_optional_str(value.get("session_id")),
|
||||
service_account_id=_optional_str(value.get("service_account_id")),
|
||||
acting_for_account_id=_optional_str(value.get("acting_for_account_id")),
|
||||
email=_optional_str(value.get("email")),
|
||||
display_name=_optional_str(value.get("display_name")),
|
||||
)
|
||||
|
||||
|
||||
def _optional_str(value: object | None) -> str | None:
|
||||
return str(value) if value is not None else None
|
||||
|
||||
|
||||
def _string_frozenset(value: object | None) -> frozenset[str]:
|
||||
if value is None:
|
||||
return frozenset()
|
||||
if isinstance(value, str):
|
||||
return frozenset({value})
|
||||
if isinstance(value, Iterable):
|
||||
return frozenset(str(item) for item in value)
|
||||
raise TypeError("PrincipalRef collection fields must be strings or iterables")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccountRef:
|
||||
@@ -87,6 +175,15 @@ class UserRef:
|
||||
status: AccessStatus = "active"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IdentityRef:
|
||||
id: str
|
||||
display_name: str | None = None
|
||||
primary_account_id: str | None = None
|
||||
account_ids: tuple[str, ...] = ()
|
||||
status: AccessStatus = "active"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GroupRef:
|
||||
id: str
|
||||
@@ -105,6 +202,79 @@ class RoleRef:
|
||||
protected: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationUnitRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
name: str
|
||||
parent_id: str | None = None
|
||||
status: AccessStatus = "active"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FunctionRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
organization_unit_id: str
|
||||
slug: str
|
||||
name: str
|
||||
role_ids: tuple[str, ...] = ()
|
||||
delegable: bool = False
|
||||
act_in_place_allowed: bool = False
|
||||
status: AccessStatus = "active"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FunctionAssignmentRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
account_id: str
|
||||
function_id: str
|
||||
organization_unit_id: str
|
||||
identity_id: str | None = None
|
||||
applies_to_subunits: bool = False
|
||||
source: FunctionAssignmentSource = "direct"
|
||||
delegated_from_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
status: AccessStatus = "active"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FunctionDelegationRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
function_assignment_id: str
|
||||
delegator_account_id: str
|
||||
delegate_account_id: str
|
||||
mode: FunctionDelegationMode = "delegate"
|
||||
reason: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
status: AccessStatus = "active"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccessDecisionProvenance:
|
||||
kind: AccessProvenanceKind
|
||||
id: str | None = None
|
||||
label: str | None = None
|
||||
tenant_id: str | None = None
|
||||
source: str | None = None
|
||||
details: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"kind": self.kind,
|
||||
"id": self.id,
|
||||
"label": self.label,
|
||||
"tenant_id": self.tenant_id,
|
||||
"source": self.source,
|
||||
"details": dict(self.details),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccessSubjectRef:
|
||||
kind: AccessSubjectKind
|
||||
@@ -143,6 +313,8 @@ class AuditEvent:
|
||||
resource_type: str | None = None
|
||||
resource_id: str | None = None
|
||||
occurred_at: datetime | None = None
|
||||
correlation_id: str | None = None
|
||||
causation_id: str | None = None
|
||||
details: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -191,6 +363,43 @@ class AccessDirectory(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AccessSemanticDirectory(AccessDirectory, Protocol):
|
||||
def get_identity(self, identity_id: str) -> IdentityRef | None:
|
||||
...
|
||||
|
||||
def accounts_for_identity(self, identity_id: str) -> Sequence[AccountRef]:
|
||||
...
|
||||
|
||||
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
|
||||
...
|
||||
|
||||
def organization_units_for_tenant(self, tenant_id: str) -> Sequence[OrganizationUnitRef]:
|
||||
...
|
||||
|
||||
def get_function(self, function_id: str) -> FunctionRef | None:
|
||||
...
|
||||
|
||||
def functions_for_organization_unit(
|
||||
self,
|
||||
organization_unit_id: str,
|
||||
*,
|
||||
include_subunits: bool = False,
|
||||
) -> Sequence[FunctionRef]:
|
||||
...
|
||||
|
||||
def get_function_assignment(self, assignment_id: str) -> FunctionAssignmentRef | None:
|
||||
...
|
||||
|
||||
def function_assignments_for_account(
|
||||
self,
|
||||
account_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
) -> Sequence[FunctionAssignmentRef]:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PermissionEvaluator(Protocol):
|
||||
def scopes_grant(self, scopes: Iterable[str], required_scope: str) -> bool:
|
||||
@@ -209,6 +418,26 @@ class ResourceAccessService(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AccessExplanationService(Protocol):
|
||||
def explain_scope_provenance(
|
||||
self,
|
||||
principal: PrincipalRef,
|
||||
required_scope: str,
|
||||
) -> Sequence[AccessDecisionProvenance]:
|
||||
...
|
||||
|
||||
def explain_resource_provenance(
|
||||
self,
|
||||
principal: PrincipalRef,
|
||||
*,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
action: str,
|
||||
) -> Sequence[AccessDecisionProvenance]:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TenantAccessProvisioner(Protocol):
|
||||
def ensure_default_roles(self, session: object, tenant: object | None = None) -> Mapping[str, object]:
|
||||
@@ -254,18 +483,6 @@ class AccessGovernanceMaterializer(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SecretProvider(Protocol):
|
||||
def store_secret(self, *, scope: str, name: str, value: str) -> str:
|
||||
...
|
||||
|
||||
def read_secret(self, secret_ref: str) -> str | None:
|
||||
...
|
||||
|
||||
def delete_secret(self, secret_ref: str) -> None:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AuditSink(Protocol):
|
||||
def record(self, event: AuditEvent) -> None:
|
||||
|
||||
253
src/govoplan_core/core/change_sequence.py
Normal file
253
src/govoplan_core/core/change_sequence.py
Normal file
@@ -0,0 +1,253 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Iterable, Sequence
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, Index, Integer, JSON, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from govoplan_core.db.base import Base, utcnow
|
||||
|
||||
WATERMARK_PREFIX = "seq:"
|
||||
|
||||
|
||||
class ChangeSequenceEntry(Base):
|
||||
__tablename__ = "core_change_sequence"
|
||||
__table_args__ = (
|
||||
Index("ix_core_change_sequence_tenant_module_id", "tenant_id", "module_id", "id"),
|
||||
Index("ix_core_change_sequence_collection_id", "collection", "id"),
|
||||
Index("ix_core_change_sequence_resource", "module_id", "resource_type", "resource_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
module_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
collection: Mapped[str] = mapped_column(String(150), nullable=False, index=True)
|
||||
resource_type: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
resource_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
operation: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
actor_type: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
|
||||
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False, index=True)
|
||||
|
||||
|
||||
class ChangeSequenceRetentionFloor(Base):
|
||||
__tablename__ = "core_change_sequence_retention_floor"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_key", "module_id", "collection", name="uq_core_change_sequence_retention_scope"),
|
||||
Index("ix_core_change_sequence_retention_scope", "tenant_key", "module_id", "collection"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True)
|
||||
tenant_key: Mapped[str] = mapped_column(String(36), nullable=False, default="")
|
||||
module_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
collection: Mapped[str] = mapped_column(String(150), nullable=False)
|
||||
min_valid_sequence: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), nullable=False, default=0)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False)
|
||||
|
||||
|
||||
def _tenant_key(tenant_id: str | None) -> str:
|
||||
return tenant_id or ""
|
||||
|
||||
|
||||
def encode_sequence_watermark(sequence_id: int | None) -> str:
|
||||
return f"{WATERMARK_PREFIX}{int(sequence_id or 0)}"
|
||||
|
||||
|
||||
def decode_sequence_watermark(value: str | None) -> int:
|
||||
if value is None or not value.strip():
|
||||
return 0
|
||||
raw = value.strip()
|
||||
if raw.startswith(WATERMARK_PREFIX):
|
||||
raw = raw[len(WATERMARK_PREFIX):]
|
||||
try:
|
||||
sequence_id = int(raw)
|
||||
except ValueError as exc:
|
||||
raise ValueError("Invalid delta watermark") from exc
|
||||
if sequence_id < 0:
|
||||
raise ValueError("Invalid delta watermark")
|
||||
return sequence_id
|
||||
|
||||
|
||||
def record_change(
|
||||
session: Session,
|
||||
*,
|
||||
module_id: str,
|
||||
collection: str,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
operation: str,
|
||||
tenant_id: str | None = None,
|
||||
actor_type: str | None = None,
|
||||
actor_id: str | None = None,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> ChangeSequenceEntry:
|
||||
entry = ChangeSequenceEntry(
|
||||
tenant_id=tenant_id,
|
||||
module_id=module_id,
|
||||
collection=collection,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
operation=operation,
|
||||
actor_type=actor_type,
|
||||
actor_id=actor_id,
|
||||
payload=payload or {},
|
||||
)
|
||||
session.add(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def max_sequence_id(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
module_id: str | None = None,
|
||||
collections: Sequence[str] | None = None,
|
||||
) -> int:
|
||||
query = session.query(func.max(ChangeSequenceEntry.id))
|
||||
if tenant_id is not None:
|
||||
query = query.filter(ChangeSequenceEntry.tenant_id == tenant_id)
|
||||
if module_id is not None:
|
||||
query = query.filter(ChangeSequenceEntry.module_id == module_id)
|
||||
if collections:
|
||||
query = query.filter(ChangeSequenceEntry.collection.in_(tuple(collections)))
|
||||
return int(query.scalar() or 0)
|
||||
|
||||
|
||||
def min_sequence_id(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
module_id: str | None = None,
|
||||
collections: Sequence[str] | None = None,
|
||||
) -> int:
|
||||
query = session.query(func.min(ChangeSequenceEntry.id))
|
||||
if tenant_id is not None:
|
||||
query = query.filter(ChangeSequenceEntry.tenant_id == tenant_id)
|
||||
if module_id is not None:
|
||||
query = query.filter(ChangeSequenceEntry.module_id == module_id)
|
||||
if collections:
|
||||
query = query.filter(ChangeSequenceEntry.collection.in_(tuple(collections)))
|
||||
return int(query.scalar() or 0)
|
||||
|
||||
|
||||
def sequence_watermark_is_expired(
|
||||
session: Session,
|
||||
*,
|
||||
since: int,
|
||||
tenant_id: str | None = None,
|
||||
module_id: str | None = None,
|
||||
collections: Sequence[str] | None = None,
|
||||
) -> bool:
|
||||
floor = retained_sequence_floor(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
module_id=module_id,
|
||||
collections=collections,
|
||||
)
|
||||
return floor > 0 and since < floor
|
||||
|
||||
|
||||
def retained_sequence_floor(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
module_id: str | None = None,
|
||||
collections: Sequence[str] | None = None,
|
||||
) -> int:
|
||||
query = session.query(func.max(ChangeSequenceRetentionFloor.min_valid_sequence))
|
||||
query = query.filter(ChangeSequenceRetentionFloor.tenant_key == _tenant_key(tenant_id))
|
||||
if module_id is not None:
|
||||
query = query.filter(ChangeSequenceRetentionFloor.module_id == module_id)
|
||||
if collections:
|
||||
query = query.filter(ChangeSequenceRetentionFloor.collection.in_(tuple(collections)))
|
||||
return int(query.scalar() or 0)
|
||||
|
||||
|
||||
def prune_sequence_entries(
|
||||
session: Session,
|
||||
*,
|
||||
before_or_equal_id: int,
|
||||
tenant_id: str | None = None,
|
||||
module_id: str | None = None,
|
||||
collections: Sequence[str] | None = None,
|
||||
) -> int:
|
||||
"""Delete old sequence rows and record the new minimum safe watermark.
|
||||
|
||||
After pruning rows up to `before_or_equal_id`, clients with watermarks below
|
||||
that value cannot be replayed safely and should receive a full snapshot.
|
||||
"""
|
||||
|
||||
cutoff = int(before_or_equal_id)
|
||||
if cutoff <= 0:
|
||||
return 0
|
||||
query = session.query(ChangeSequenceEntry).filter(ChangeSequenceEntry.id <= cutoff)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(ChangeSequenceEntry.tenant_id == tenant_id)
|
||||
if module_id is not None:
|
||||
query = query.filter(ChangeSequenceEntry.module_id == module_id)
|
||||
collection_values = tuple(collections or ())
|
||||
if collection_values:
|
||||
query = query.filter(ChangeSequenceEntry.collection.in_(collection_values))
|
||||
scopes = {
|
||||
(_tenant_key(entry.tenant_id), entry.module_id, entry.collection)
|
||||
for entry in query.with_entities(
|
||||
ChangeSequenceEntry.tenant_id,
|
||||
ChangeSequenceEntry.module_id,
|
||||
ChangeSequenceEntry.collection,
|
||||
).distinct()
|
||||
}
|
||||
deleted = query.delete(synchronize_session=False)
|
||||
for tenant_key, scoped_module_id, collection in scopes:
|
||||
floor = session.query(ChangeSequenceRetentionFloor).filter(
|
||||
ChangeSequenceRetentionFloor.tenant_key == tenant_key,
|
||||
ChangeSequenceRetentionFloor.module_id == scoped_module_id,
|
||||
ChangeSequenceRetentionFloor.collection == collection,
|
||||
).one_or_none()
|
||||
if floor is None:
|
||||
floor = ChangeSequenceRetentionFloor(
|
||||
tenant_key=tenant_key,
|
||||
module_id=scoped_module_id,
|
||||
collection=collection,
|
||||
min_valid_sequence=cutoff,
|
||||
)
|
||||
else:
|
||||
floor.min_valid_sequence = max(int(floor.min_valid_sequence or 0), cutoff)
|
||||
session.add(floor)
|
||||
return int(deleted)
|
||||
|
||||
|
||||
def sequence_entries_since(
|
||||
session: Session,
|
||||
*,
|
||||
since: int,
|
||||
tenant_id: str | None = None,
|
||||
module_id: str | None = None,
|
||||
collections: Iterable[str] | None = None,
|
||||
limit: int = 500,
|
||||
) -> list[ChangeSequenceEntry]:
|
||||
query = session.query(ChangeSequenceEntry).filter(ChangeSequenceEntry.id > since)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(ChangeSequenceEntry.tenant_id == tenant_id)
|
||||
if module_id is not None:
|
||||
query = query.filter(ChangeSequenceEntry.module_id == module_id)
|
||||
collection_values = tuple(collections or ())
|
||||
if collection_values:
|
||||
query = query.filter(ChangeSequenceEntry.collection.in_(collection_values))
|
||||
return query.order_by(ChangeSequenceEntry.id.asc()).limit(max(1, limit)).all()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ChangeSequenceEntry",
|
||||
"ChangeSequenceRetentionFloor",
|
||||
"decode_sequence_watermark",
|
||||
"encode_sequence_watermark",
|
||||
"max_sequence_id",
|
||||
"min_sequence_id",
|
||||
"prune_sequence_entries",
|
||||
"record_change",
|
||||
"retained_sequence_floor",
|
||||
"sequence_entries_since",
|
||||
"sequence_watermark_is_expired",
|
||||
]
|
||||
418
src/govoplan_core/core/configuration_control.py
Normal file
418
src/govoplan_core/core/configuration_control.py
Normal file
@@ -0,0 +1,418 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.admin.settings import get_system_settings
|
||||
from govoplan_core.core.change_sequence import record_change
|
||||
from govoplan_core.core.configuration_safety import classify_configuration_field, plan_configuration_change
|
||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
||||
from govoplan_core.security.time import utc_now
|
||||
|
||||
|
||||
CONFIGURATION_CONTROL_SETTINGS_KEY = "_configuration_control"
|
||||
CONFIGURATION_CONTROL_MODULE_ID = "core"
|
||||
CONFIGURATION_CHANGES_COLLECTION = "core.configuration_changes"
|
||||
CONFIGURATION_CHANGE_REQUEST_RESOURCE = "configuration_change_request"
|
||||
CONFIGURATION_CHANGE_RECORD_RESOURCE = "configuration_change_record"
|
||||
MAX_CONFIGURATION_CHANGE_HISTORY = 200
|
||||
MAX_CONFIGURATION_CHANGE_REQUESTS = 200
|
||||
|
||||
|
||||
class ConfigurationControlError(Exception):
|
||||
def __init__(self, message: str, *, code: str = "configuration_change_blocked", plan: dict[str, Any] | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.plan = plan or {}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationChangeApproval:
|
||||
request: dict[str, Any] | None
|
||||
plan: dict[str, Any]
|
||||
|
||||
|
||||
def configuration_value_digest(value: object) -> str:
|
||||
payload = json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def configuration_control_snapshot(session: Session) -> dict[str, list[dict[str, Any]]]:
|
||||
item = get_system_settings(session)
|
||||
control = _control_payload(item.settings or {})
|
||||
return {
|
||||
"requests": list(control.get("requests", [])),
|
||||
"history": list(control.get("history", [])),
|
||||
}
|
||||
|
||||
|
||||
def create_configuration_change_request(
|
||||
session: Session,
|
||||
*,
|
||||
key: str,
|
||||
value: object,
|
||||
actor_user_id: str,
|
||||
actor_scopes: tuple[str, ...] | list[str],
|
||||
dry_run: bool,
|
||||
target: dict[str, Any] | None = None,
|
||||
reason: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
field = classify_configuration_field(key)
|
||||
if field is None:
|
||||
raise ConfigurationControlError(
|
||||
"This setting is not in the configuration safety catalog.",
|
||||
code="unknown_configuration_field",
|
||||
plan=plan_configuration_change(key, actor_scopes=actor_scopes, value=value).to_dict(),
|
||||
)
|
||||
if not field.ui_managed:
|
||||
raise ConfigurationControlError(
|
||||
"This setting is deployment-managed and cannot be edited through the UI.",
|
||||
code="deployment_managed",
|
||||
plan=plan_configuration_change(key, actor_scopes=actor_scopes, value=value).to_dict(),
|
||||
)
|
||||
|
||||
item = get_system_settings(session)
|
||||
settings = dict(item.settings or {})
|
||||
control = _control_payload(settings)
|
||||
now = _now()
|
||||
request = {
|
||||
"id": f"cfgreq-{uuid.uuid4().hex}",
|
||||
"key": field.key,
|
||||
"label": field.label,
|
||||
"target": dict(target or {}),
|
||||
"value_hash": configuration_value_digest(value),
|
||||
"value_preview": _sanitize_value(field.key, value),
|
||||
"dry_run": bool(dry_run),
|
||||
"requested_by": actor_user_id,
|
||||
"requested_at": now,
|
||||
"updated_at": now,
|
||||
"reason": reason.strip() if isinstance(reason, str) and reason.strip() else None,
|
||||
"status": "pending_approval" if field.two_person_approval_required else "approved",
|
||||
"approvals": [
|
||||
{
|
||||
"user_id": actor_user_id,
|
||||
"approved_at": now,
|
||||
"role": "requester",
|
||||
}
|
||||
],
|
||||
"plan": plan_configuration_change(
|
||||
field.key,
|
||||
actor_scopes=actor_scopes,
|
||||
value=value,
|
||||
dry_run=bool(dry_run),
|
||||
maintenance_mode=saved_maintenance_mode(session).enabled,
|
||||
approval_count=1,
|
||||
include_env_only=False,
|
||||
).to_dict(),
|
||||
}
|
||||
control["requests"] = _trim_requests([request, *list(control.get("requests", []))])
|
||||
settings[CONFIGURATION_CONTROL_SETTINGS_KEY] = control
|
||||
item.settings = settings
|
||||
session.add(item)
|
||||
session.flush()
|
||||
_record_configuration_control_change(
|
||||
session,
|
||||
resource_type=CONFIGURATION_CHANGE_REQUEST_RESOURCE,
|
||||
resource_id=str(request["id"]),
|
||||
operation="created",
|
||||
actor_user_id=actor_user_id,
|
||||
payload={"key": field.key, "status": request["status"]},
|
||||
)
|
||||
return dict(request)
|
||||
|
||||
|
||||
def approve_configuration_change_request(
|
||||
session: Session,
|
||||
*,
|
||||
request_id: str,
|
||||
actor_user_id: str,
|
||||
actor_scopes: tuple[str, ...] | list[str],
|
||||
reason: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
item = get_system_settings(session)
|
||||
settings = dict(item.settings or {})
|
||||
control = _control_payload(settings)
|
||||
requests = list(control.get("requests", []))
|
||||
index, request = _find_request(requests, request_id)
|
||||
if request is None:
|
||||
raise ConfigurationControlError("Configuration change request not found.", code="configuration_request_not_found")
|
||||
if request.get("status") in {"applied", "rejected"}:
|
||||
raise ConfigurationControlError("Configuration change request is no longer approvable.", code="configuration_request_closed")
|
||||
if request.get("requested_by") == actor_user_id:
|
||||
raise ConfigurationControlError("The requester cannot be the second approver.", code="second_approver_required")
|
||||
approvals = list(request.get("approvals", []))
|
||||
if not any(item.get("user_id") == actor_user_id for item in approvals if isinstance(item, dict)):
|
||||
approvals.append(
|
||||
{
|
||||
"user_id": actor_user_id,
|
||||
"approved_at": _now(),
|
||||
"role": "approver",
|
||||
"reason": reason.strip() if isinstance(reason, str) and reason.strip() else None,
|
||||
}
|
||||
)
|
||||
request = dict(request)
|
||||
request["approvals"] = approvals
|
||||
request["updated_at"] = _now()
|
||||
request["status"] = "approved" if _approval_count(request) >= 2 else "pending_approval"
|
||||
request["plan"] = plan_configuration_change(
|
||||
str(request.get("key") or ""),
|
||||
actor_scopes=actor_scopes,
|
||||
value=request.get("value_preview"),
|
||||
dry_run=bool(request.get("dry_run")),
|
||||
maintenance_mode=saved_maintenance_mode(session).enabled,
|
||||
approval_count=_approval_count(request),
|
||||
include_env_only=False,
|
||||
).to_dict()
|
||||
requests[index] = request
|
||||
control["requests"] = _trim_requests(requests)
|
||||
settings[CONFIGURATION_CONTROL_SETTINGS_KEY] = control
|
||||
item.settings = settings
|
||||
session.add(item)
|
||||
session.flush()
|
||||
_record_configuration_control_change(
|
||||
session,
|
||||
resource_type=CONFIGURATION_CHANGE_REQUEST_RESOURCE,
|
||||
resource_id=str(request["id"]),
|
||||
operation="updated",
|
||||
actor_user_id=actor_user_id,
|
||||
payload={"key": request.get("key"), "status": request.get("status")},
|
||||
)
|
||||
return dict(request)
|
||||
|
||||
|
||||
def ensure_configuration_change_allowed(
|
||||
session: Session,
|
||||
*,
|
||||
key: str,
|
||||
value: object,
|
||||
actor_user_id: str,
|
||||
actor_scopes: tuple[str, ...] | list[str],
|
||||
change_request_id: str | None = None,
|
||||
target: dict[str, Any] | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> ConfigurationChangeApproval:
|
||||
field = classify_configuration_field(key)
|
||||
if field is None:
|
||||
plan = plan_configuration_change(key, actor_scopes=actor_scopes, value=value).to_dict()
|
||||
raise ConfigurationControlError("This setting is not in the configuration safety catalog.", code="unknown_configuration_field", plan=plan)
|
||||
|
||||
request: dict[str, Any] | None = None
|
||||
approval_count = 0
|
||||
dry_run_satisfied = dry_run
|
||||
requires_control = field.ui_managed and (field.dry_run_required or field.two_person_approval_required or field.maintenance_required)
|
||||
if change_request_id:
|
||||
request = _request_by_id(session, change_request_id)
|
||||
if request is None:
|
||||
raise ConfigurationControlError("Configuration change request not found.", code="configuration_request_not_found")
|
||||
if request.get("status") in {"applied", "rejected"}:
|
||||
raise ConfigurationControlError("Configuration change request is closed.", code="configuration_request_closed")
|
||||
if request.get("key") != field.key:
|
||||
raise ConfigurationControlError("Configuration change request does not match this setting.", code="configuration_request_mismatch")
|
||||
if request.get("value_hash") != configuration_value_digest(value):
|
||||
raise ConfigurationControlError("Configuration change request value does not match this apply payload.", code="configuration_value_mismatch")
|
||||
approval_count = _approval_count(request)
|
||||
dry_run_satisfied = bool(request.get("dry_run")) or dry_run_satisfied
|
||||
elif requires_control:
|
||||
plan = plan_configuration_change(
|
||||
field.key,
|
||||
actor_scopes=actor_scopes,
|
||||
value=value,
|
||||
dry_run=dry_run_satisfied,
|
||||
maintenance_mode=saved_maintenance_mode(session).enabled,
|
||||
approval_count=approval_count,
|
||||
include_env_only=False,
|
||||
).to_dict()
|
||||
raise ConfigurationControlError(
|
||||
"This configuration change requires a recorded change request.",
|
||||
code="configuration_request_required",
|
||||
plan=plan,
|
||||
)
|
||||
|
||||
plan = plan_configuration_change(
|
||||
field.key,
|
||||
actor_scopes=actor_scopes,
|
||||
value=value,
|
||||
dry_run=dry_run_satisfied,
|
||||
maintenance_mode=saved_maintenance_mode(session).enabled,
|
||||
approval_count=approval_count,
|
||||
include_env_only=False,
|
||||
).to_dict()
|
||||
if plan.get("blockers"):
|
||||
raise ConfigurationControlError("Configuration change is blocked by safety guardrails.", plan=plan)
|
||||
return ConfigurationChangeApproval(request=request, plan=plan)
|
||||
|
||||
|
||||
def record_configuration_change_applied(
|
||||
session: Session,
|
||||
*,
|
||||
key: str,
|
||||
before_value: object,
|
||||
after_value: object,
|
||||
actor_user_id: str,
|
||||
approval: ConfigurationChangeApproval | None = None,
|
||||
target: dict[str, Any] | None = None,
|
||||
audit_event: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
item = get_system_settings(session)
|
||||
settings = dict(item.settings or {})
|
||||
control = _control_payload(settings)
|
||||
sequence = int(control.get("sequence") or 0) + 1
|
||||
request = approval.request if approval else None
|
||||
record = {
|
||||
"id": f"cfgchg-{sequence:06d}-{uuid.uuid4().hex[:8]}",
|
||||
"version": sequence,
|
||||
"key": key,
|
||||
"target": dict(target or {}),
|
||||
"actor_user_id": actor_user_id,
|
||||
"approval_request_id": request.get("id") if request else None,
|
||||
"approval_user_ids": [item.get("user_id") for item in request.get("approvals", []) if isinstance(item, dict)] if request else [],
|
||||
"audit_event": audit_event,
|
||||
"before": _sanitize_value(key, before_value),
|
||||
"after": _sanitize_value(key, after_value),
|
||||
"rollback_value": _sanitize_value(key, before_value),
|
||||
"plan": approval.plan if approval else plan_configuration_change(key, actor_scopes=(), value=after_value).to_dict(),
|
||||
"created_at": _now(),
|
||||
"status": "applied",
|
||||
}
|
||||
control["sequence"] = sequence
|
||||
control["history"] = _trim_history([record, *list(control.get("history", []))])
|
||||
if request:
|
||||
requests = list(control.get("requests", []))
|
||||
index, stored = _find_request(requests, str(request.get("id")))
|
||||
if stored is not None:
|
||||
stored = dict(stored)
|
||||
stored["status"] = "applied"
|
||||
stored["applied_change_id"] = record["id"]
|
||||
stored["updated_at"] = _now()
|
||||
requests[index] = stored
|
||||
control["requests"] = _trim_requests(requests)
|
||||
settings[CONFIGURATION_CONTROL_SETTINGS_KEY] = control
|
||||
item.settings = settings
|
||||
session.add(item)
|
||||
session.flush()
|
||||
_record_configuration_control_change(
|
||||
session,
|
||||
resource_type=CONFIGURATION_CHANGE_RECORD_RESOURCE,
|
||||
resource_id=str(record["id"]),
|
||||
operation="created",
|
||||
actor_user_id=actor_user_id,
|
||||
payload={"key": key, "status": record["status"], "version": record["version"]},
|
||||
)
|
||||
if request:
|
||||
_record_configuration_control_change(
|
||||
session,
|
||||
resource_type=CONFIGURATION_CHANGE_REQUEST_RESOURCE,
|
||||
resource_id=str(request.get("id")),
|
||||
operation="updated",
|
||||
actor_user_id=actor_user_id,
|
||||
payload={"key": key, "status": "applied", "applied_change_id": record["id"]},
|
||||
)
|
||||
return dict(record)
|
||||
|
||||
|
||||
def _record_configuration_control_change(
|
||||
session: Session,
|
||||
*,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
operation: str,
|
||||
actor_user_id: str,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
record_change(
|
||||
session,
|
||||
module_id=CONFIGURATION_CONTROL_MODULE_ID,
|
||||
collection=CONFIGURATION_CHANGES_COLLECTION,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
operation=operation,
|
||||
actor_type="user",
|
||||
actor_id=actor_user_id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def _request_by_id(session: Session, request_id: str) -> dict[str, Any] | None:
|
||||
item = get_system_settings(session)
|
||||
_index, request = _find_request(list(_control_payload(item.settings or {}).get("requests", [])), request_id)
|
||||
return dict(request) if request is not None else None
|
||||
|
||||
|
||||
def _find_request(requests: list[dict[str, Any]], request_id: str) -> tuple[int, dict[str, Any] | None]:
|
||||
for index, request in enumerate(requests):
|
||||
if request.get("id") == request_id:
|
||||
return index, request
|
||||
return -1, None
|
||||
|
||||
|
||||
def _control_payload(settings: dict[str, Any]) -> dict[str, Any]:
|
||||
raw = settings.get(CONFIGURATION_CONTROL_SETTINGS_KEY)
|
||||
if not isinstance(raw, dict):
|
||||
return {"sequence": 0, "requests": [], "history": []}
|
||||
return {
|
||||
"sequence": int(raw.get("sequence") or 0),
|
||||
"requests": [dict(item) for item in raw.get("requests", []) if isinstance(item, dict)],
|
||||
"history": [dict(item) for item in raw.get("history", []) if isinstance(item, dict)],
|
||||
}
|
||||
|
||||
|
||||
def _approval_count(request: dict[str, Any]) -> int:
|
||||
user_ids = []
|
||||
for item in request.get("approvals", []):
|
||||
if isinstance(item, dict) and item.get("user_id"):
|
||||
user_ids.append(str(item["user_id"]))
|
||||
return len(set(user_ids))
|
||||
|
||||
|
||||
def _sanitize_value(key: str, value: object) -> object:
|
||||
field = classify_configuration_field(key)
|
||||
if field is not None and field.secret_handling in {"reference_only", "env_only"}:
|
||||
return _redact_secrets(value)
|
||||
return _redact_secrets(value)
|
||||
|
||||
|
||||
def _redact_secrets(value: object) -> object:
|
||||
if isinstance(value, dict):
|
||||
result: dict[str, object] = {}
|
||||
for key, item in value.items():
|
||||
if str(key).strip().casefold() in {"password", "token", "secret", "api_key", "access_key", "secret_key"}:
|
||||
result[str(key)] = "<redacted>"
|
||||
else:
|
||||
result[str(key)] = _redact_secrets(item)
|
||||
return result
|
||||
if isinstance(value, list):
|
||||
return [_redact_secrets(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _trim_requests(requests: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return requests[:MAX_CONFIGURATION_CHANGE_REQUESTS]
|
||||
|
||||
|
||||
def _trim_history(history: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return history[:MAX_CONFIGURATION_CHANGE_HISTORY]
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return utc_now().isoformat()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ConfigurationChangeApproval",
|
||||
"ConfigurationControlError",
|
||||
"CONFIGURATION_CHANGE_RECORD_RESOURCE",
|
||||
"CONFIGURATION_CHANGE_REQUEST_RESOURCE",
|
||||
"CONFIGURATION_CHANGES_COLLECTION",
|
||||
"CONFIGURATION_CONTROL_MODULE_ID",
|
||||
"approve_configuration_change_request",
|
||||
"configuration_control_snapshot",
|
||||
"configuration_value_digest",
|
||||
"create_configuration_change_request",
|
||||
"ensure_configuration_change_allowed",
|
||||
"record_configuration_change_applied",
|
||||
]
|
||||
883
src/govoplan_core/core/configuration_packages.py
Normal file
883
src/govoplan_core/core/configuration_packages.py
Normal file
@@ -0,0 +1,883 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Literal, Protocol, runtime_checkable
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from govoplan_core.core.module_package_catalog import (
|
||||
_canonical_catalog_bytes,
|
||||
_catalog_freshness_state,
|
||||
_catalog_optional_text,
|
||||
_catalog_sequence,
|
||||
_catalog_signature_state,
|
||||
_catalog_source_exists,
|
||||
_catalog_source_type,
|
||||
_is_http_url,
|
||||
_load_private_key,
|
||||
_parse_trusted_keys,
|
||||
)
|
||||
|
||||
|
||||
CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider"
|
||||
|
||||
DiagnosticSeverity = Literal["blocker", "warning", "info"]
|
||||
PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationModuleRequirement:
|
||||
module_id: str
|
||||
version: str | None = None
|
||||
optional: bool = False
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {"module_id": self.module_id, "version": self.version, "optional": self.optional}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationPackageFragment:
|
||||
module_id: str
|
||||
fragment_type: str
|
||||
fragment_id: str | None = None
|
||||
payload: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"module_id": self.module_id,
|
||||
"fragment_type": self.fragment_type,
|
||||
"fragment_id": self.fragment_id,
|
||||
"payload": dict(self.payload),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationPackageManifest:
|
||||
package_id: str
|
||||
name: str
|
||||
version: str
|
||||
description: str | None = None
|
||||
publisher: str | None = None
|
||||
category: str | None = None
|
||||
license: str | None = None
|
||||
required_modules: tuple[ConfigurationModuleRequirement, ...] = ()
|
||||
required_capabilities: tuple[str, ...] = ()
|
||||
optional_modules: tuple[ConfigurationModuleRequirement, ...] = ()
|
||||
fragments: tuple[ConfigurationPackageFragment, ...] = ()
|
||||
data_requirements: tuple[dict[str, object], ...] = ()
|
||||
tags: tuple[str, ...] = ()
|
||||
artifact_ref: str | None = None
|
||||
artifact_sha256: str | None = None
|
||||
signature: Mapping[str, Any] | None = None
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationPackageManifest":
|
||||
return cls(
|
||||
package_id=_required_str(value, "package_id"),
|
||||
name=_required_str(value, "name"),
|
||||
version=_required_str(value, "version"),
|
||||
description=_optional_str(value, "description"),
|
||||
publisher=_optional_str(value, "publisher"),
|
||||
category=_optional_str(value, "category"),
|
||||
license=_optional_str(value, "license"),
|
||||
required_modules=tuple(_module_requirements(value.get("required_modules"), optional=False)),
|
||||
required_capabilities=tuple(_string_list(value.get("required_capabilities"))),
|
||||
optional_modules=tuple(_module_requirements(value.get("optional_modules"), optional=True)),
|
||||
fragments=tuple(_fragments(value.get("fragments"))),
|
||||
data_requirements=tuple(_object_list(value.get("data_requirements"), field_name="data_requirements")),
|
||||
tags=tuple(_string_list(value.get("tags"))),
|
||||
artifact_ref=_optional_str(value, "artifact_ref"),
|
||||
artifact_sha256=_optional_str(value, "artifact_sha256"),
|
||||
signature=value.get("signature") if isinstance(value.get("signature"), Mapping) else None,
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"package_id": self.package_id,
|
||||
"name": self.name,
|
||||
"version": self.version,
|
||||
"required_modules": [item.to_dict() for item in self.required_modules],
|
||||
"required_capabilities": list(self.required_capabilities),
|
||||
"optional_modules": [item.to_dict() for item in self.optional_modules],
|
||||
"fragments": [item.to_dict() for item in self.fragments],
|
||||
"data_requirements": [dict(item) for item in self.data_requirements],
|
||||
"tags": list(self.tags),
|
||||
}
|
||||
for key, value in (
|
||||
("description", self.description),
|
||||
("publisher", self.publisher),
|
||||
("category", self.category),
|
||||
("license", self.license),
|
||||
("artifact_ref", self.artifact_ref),
|
||||
("artifact_sha256", self.artifact_sha256),
|
||||
):
|
||||
if value is not None:
|
||||
payload[key] = value
|
||||
if self.signature is not None:
|
||||
payload["signature"] = dict(self.signature)
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationDiagnostic:
|
||||
severity: DiagnosticSeverity
|
||||
code: str
|
||||
message: str
|
||||
module_id: str | None = None
|
||||
object_ref: str | None = None
|
||||
resolution: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"severity": self.severity,
|
||||
"code": self.code,
|
||||
"message": self.message,
|
||||
"module_id": self.module_id,
|
||||
"object_ref": self.object_ref,
|
||||
"resolution": self.resolution,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationPlanItem:
|
||||
action: PlanAction
|
||||
module_id: str
|
||||
fragment_type: str
|
||||
fragment_id: str | None = None
|
||||
summary: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"action": self.action,
|
||||
"module_id": self.module_id,
|
||||
"fragment_type": self.fragment_type,
|
||||
"fragment_id": self.fragment_id,
|
||||
"summary": self.summary,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationRequiredData:
|
||||
key: str
|
||||
label: str
|
||||
data_type: str = "string"
|
||||
required: bool = True
|
||||
secret: bool = False
|
||||
description: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationRequiredData":
|
||||
return cls(
|
||||
key=_required_str(value, "key"),
|
||||
label=_optional_str(value, "label") or _required_str(value, "key"),
|
||||
data_type=_optional_str(value, "data_type") or _optional_str(value, "type") or "string",
|
||||
required=_bool(value.get("required"), default=True),
|
||||
secret=_bool(value.get("secret"), default=False),
|
||||
description=_optional_str(value, "description"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"key": self.key,
|
||||
"label": self.label,
|
||||
"data_type": self.data_type,
|
||||
"required": self.required,
|
||||
"secret": self.secret,
|
||||
"description": self.description,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationProviderDescription:
|
||||
module_id: str
|
||||
fragment_types: tuple[str, ...] = ()
|
||||
schema_refs: Mapping[str, str] = field(default_factory=dict)
|
||||
exported_scopes: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationPreflightContext:
|
||||
tenant_id: str | None = None
|
||||
operator_user_id: str | None = None
|
||||
supplied_data: Mapping[str, Any] = field(default_factory=dict)
|
||||
installed_modules: Mapping[str, str] = field(default_factory=dict)
|
||||
capabilities: frozenset[str] = frozenset()
|
||||
dry_run: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationPreflightResult:
|
||||
diagnostics: tuple[ConfigurationDiagnostic, ...] = ()
|
||||
required_data: tuple[ConfigurationRequiredData, ...] = ()
|
||||
plan: tuple[ConfigurationPlanItem, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationApplyResult:
|
||||
diagnostics: tuple[ConfigurationDiagnostic, ...] = ()
|
||||
created_refs: Mapping[str, str] = field(default_factory=dict)
|
||||
updated_refs: Mapping[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationExportSelection:
|
||||
tenant_id: str | None = None
|
||||
scopes: tuple[str, ...] = ()
|
||||
module_ids: tuple[str, ...] = ()
|
||||
object_refs: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationExportResult:
|
||||
fragments: tuple[ConfigurationPackageFragment, ...] = ()
|
||||
data_requirements: tuple[ConfigurationRequiredData, ...] = ()
|
||||
diagnostics: tuple[ConfigurationDiagnostic, ...] = ()
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ConfigurationProvider(Protocol):
|
||||
module_id: str
|
||||
|
||||
def describe(self) -> ConfigurationProviderDescription:
|
||||
...
|
||||
|
||||
def preflight(self, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
|
||||
...
|
||||
|
||||
def apply(self, fragment: ConfigurationPackageFragment, supplied_data: Mapping[str, Any], context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
|
||||
...
|
||||
|
||||
def export(self, selection: ConfigurationExportSelection, context: ConfigurationPreflightContext) -> ConfigurationExportResult:
|
||||
...
|
||||
|
||||
def health(self, import_result: ConfigurationApplyResult, context: ConfigurationPreflightContext) -> tuple[ConfigurationDiagnostic, ...]:
|
||||
...
|
||||
|
||||
|
||||
def dry_run_configuration_package(
|
||||
package: ConfigurationPackageManifest | Mapping[str, Any],
|
||||
providers: Sequence[ConfigurationProvider] | Mapping[str, ConfigurationProvider],
|
||||
context: ConfigurationPreflightContext,
|
||||
) -> ConfigurationPreflightResult:
|
||||
manifest = _configuration_package_manifest(package)
|
||||
provider_map = _configuration_provider_map(providers)
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
required_data: list[ConfigurationRequiredData] = []
|
||||
plan: list[ConfigurationPlanItem] = []
|
||||
|
||||
diagnostics.extend(_module_requirement_diagnostics(manifest, context))
|
||||
diagnostics.extend(_capability_requirement_diagnostics(manifest, context))
|
||||
for item in manifest.data_requirements:
|
||||
requirement = ConfigurationRequiredData.from_mapping(item)
|
||||
required_data.append(requirement)
|
||||
if requirement.required and requirement.key not in context.supplied_data:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="required_data_missing",
|
||||
message=f"Required operator data is missing: {requirement.label}.",
|
||||
object_ref=requirement.key,
|
||||
resolution="Collect this value in the configuration package wizard before applying.",
|
||||
))
|
||||
|
||||
for fragment in manifest.fragments:
|
||||
provider = provider_map.get(fragment.module_id)
|
||||
if provider is None:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="configuration_provider_missing",
|
||||
message=f"No configuration provider is registered for module {fragment.module_id!r}.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=fragment.fragment_id or fragment.fragment_type,
|
||||
resolution="Install and enable the module version that provides this configuration provider.",
|
||||
))
|
||||
plan.append(ConfigurationPlanItem(action="blocked", module_id=fragment.module_id, fragment_type=fragment.fragment_type, fragment_id=fragment.fragment_id, summary="Provider is missing."))
|
||||
continue
|
||||
description = provider.describe()
|
||||
if description.fragment_types and fragment.fragment_type not in description.fragment_types:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="fragment_type_unsupported",
|
||||
message=f"Provider {fragment.module_id!r} does not support fragment type {fragment.fragment_type!r}.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=fragment.fragment_id or fragment.fragment_type,
|
||||
resolution="Use a package version compatible with the installed module provider.",
|
||||
))
|
||||
plan.append(ConfigurationPlanItem(action="blocked", module_id=fragment.module_id, fragment_type=fragment.fragment_type, fragment_id=fragment.fragment_id, summary="Fragment type is unsupported."))
|
||||
continue
|
||||
try:
|
||||
result = provider.preflight(fragment, context)
|
||||
except Exception as exc:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="provider_preflight_failed",
|
||||
message=f"Configuration provider {fragment.module_id!r} preflight failed: {exc}",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=fragment.fragment_id or fragment.fragment_type,
|
||||
resolution="Review provider diagnostics and package fragment payload.",
|
||||
))
|
||||
plan.append(ConfigurationPlanItem(action="blocked", module_id=fragment.module_id, fragment_type=fragment.fragment_type, fragment_id=fragment.fragment_id, summary="Provider preflight failed."))
|
||||
continue
|
||||
diagnostics.extend(result.diagnostics)
|
||||
required_data.extend(result.required_data)
|
||||
for requirement in result.required_data:
|
||||
if requirement.required and requirement.key not in context.supplied_data:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="required_data_missing",
|
||||
message=f"Required operator data is missing: {requirement.label}.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=requirement.key,
|
||||
resolution="Collect this value in the configuration package wizard before applying.",
|
||||
))
|
||||
plan.extend(result.plan or (ConfigurationPlanItem(action="noop", module_id=fragment.module_id, fragment_type=fragment.fragment_type, fragment_id=fragment.fragment_id, summary="Provider reported no changes."),))
|
||||
return ConfigurationPreflightResult(
|
||||
diagnostics=tuple(_dedupe_diagnostics(diagnostics)),
|
||||
required_data=tuple(_dedupe_required_data(required_data)),
|
||||
plan=tuple(plan),
|
||||
)
|
||||
|
||||
|
||||
def apply_configuration_package(
|
||||
package: ConfigurationPackageManifest | Mapping[str, Any],
|
||||
providers: Sequence[ConfigurationProvider] | Mapping[str, ConfigurationProvider],
|
||||
context: ConfigurationPreflightContext,
|
||||
supplied_data: Mapping[str, Any] | None = None,
|
||||
) -> ConfigurationApplyResult:
|
||||
manifest = _configuration_package_manifest(package)
|
||||
apply_context = ConfigurationPreflightContext(
|
||||
tenant_id=context.tenant_id,
|
||||
operator_user_id=context.operator_user_id,
|
||||
supplied_data=supplied_data if supplied_data is not None else context.supplied_data,
|
||||
installed_modules=context.installed_modules,
|
||||
capabilities=context.capabilities,
|
||||
dry_run=False,
|
||||
)
|
||||
preflight = dry_run_configuration_package(manifest, providers, apply_context)
|
||||
blockers = [item for item in preflight.diagnostics if item.severity == "blocker"]
|
||||
if blockers:
|
||||
return ConfigurationApplyResult(diagnostics=tuple(blockers))
|
||||
provider_map = _configuration_provider_map(providers)
|
||||
diagnostics: list[ConfigurationDiagnostic] = list(preflight.diagnostics)
|
||||
created_refs: dict[str, str] = {}
|
||||
updated_refs: dict[str, str] = {}
|
||||
for fragment in manifest.fragments:
|
||||
provider = provider_map[fragment.module_id]
|
||||
try:
|
||||
result = provider.apply(fragment, apply_context.supplied_data, apply_context)
|
||||
diagnostics.extend(result.diagnostics)
|
||||
created_refs.update(result.created_refs)
|
||||
updated_refs.update(result.updated_refs)
|
||||
diagnostics.extend(provider.health(result, apply_context))
|
||||
except Exception as exc:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="provider_apply_failed",
|
||||
message=f"Configuration provider {fragment.module_id!r} apply failed: {exc}",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=fragment.fragment_id or fragment.fragment_type,
|
||||
resolution="Stop the import, keep previous configuration, and inspect provider logs.",
|
||||
))
|
||||
return ConfigurationApplyResult(
|
||||
diagnostics=tuple(_dedupe_diagnostics(diagnostics)),
|
||||
created_refs=created_refs,
|
||||
updated_refs=updated_refs,
|
||||
)
|
||||
|
||||
|
||||
def export_configuration_package(
|
||||
providers: Sequence[ConfigurationProvider] | Mapping[str, ConfigurationProvider],
|
||||
selection: ConfigurationExportSelection,
|
||||
context: ConfigurationPreflightContext,
|
||||
) -> ConfigurationExportResult:
|
||||
provider_map = _configuration_provider_map(providers)
|
||||
module_ids = selection.module_ids or tuple(provider_map)
|
||||
fragments: list[ConfigurationPackageFragment] = []
|
||||
data_requirements: list[ConfigurationRequiredData] = []
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
for module_id in module_ids:
|
||||
provider = provider_map.get(module_id)
|
||||
if provider is None:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="warning",
|
||||
code="configuration_provider_missing",
|
||||
message=f"No configuration provider is registered for module {module_id!r}; skipping export.",
|
||||
module_id=module_id,
|
||||
resolution="Enable the module provider before exporting its configuration.",
|
||||
))
|
||||
continue
|
||||
try:
|
||||
result = provider.export(selection, context)
|
||||
except Exception as exc:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="provider_export_failed",
|
||||
message=f"Configuration provider {module_id!r} export failed: {exc}",
|
||||
module_id=module_id,
|
||||
resolution="Review provider diagnostics before reusing this package export.",
|
||||
))
|
||||
continue
|
||||
fragments.extend(result.fragments)
|
||||
data_requirements.extend(result.data_requirements)
|
||||
diagnostics.extend(result.diagnostics)
|
||||
return ConfigurationExportResult(
|
||||
fragments=tuple(fragments),
|
||||
data_requirements=tuple(_dedupe_required_data(data_requirements)),
|
||||
diagnostics=tuple(_dedupe_diagnostics(diagnostics)),
|
||||
)
|
||||
|
||||
|
||||
def configuration_package_catalog(
|
||||
path: Path | str | None = None,
|
||||
*,
|
||||
require_trusted: bool | None = None,
|
||||
approved_channels: tuple[str, ...] | None = None,
|
||||
trusted_keys: dict[str, str] | None = None,
|
||||
) -> tuple[dict[str, object], ...]:
|
||||
validation = validate_configuration_package_catalog(
|
||||
path,
|
||||
require_trusted=require_trusted,
|
||||
approved_channels=approved_channels,
|
||||
trusted_keys=trusted_keys,
|
||||
)
|
||||
if not validation["valid"]:
|
||||
raise ValueError(str(validation["error"] or "Configuration package catalog is invalid."))
|
||||
return tuple(item for item in validation["packages"] if isinstance(item, dict))
|
||||
|
||||
|
||||
def validate_configuration_package_catalog(
|
||||
path: Path | str | None = None,
|
||||
*,
|
||||
require_trusted: bool | None = None,
|
||||
approved_channels: tuple[str, ...] | None = None,
|
||||
trusted_keys: dict[str, str] | None = None,
|
||||
) -> dict[str, object]:
|
||||
source = _catalog_source(path)
|
||||
configured = source is not None and _catalog_source_exists(source)
|
||||
if source is not None and not _catalog_source_exists(source):
|
||||
return _validation_result(source, configured=True, packages=(), error=f"Configuration package catalog does not exist: {source}")
|
||||
read_state = {"cache_used": False, "cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() else None}
|
||||
try:
|
||||
payload, read_state = _read_catalog_payload_with_metadata(source)
|
||||
packages = _normalize_catalog_packages(payload)
|
||||
channel = _catalog_channel(payload)
|
||||
sequence = _catalog_sequence(payload)
|
||||
generated_at = _catalog_optional_text(payload, "generated_at")
|
||||
not_before = _catalog_optional_text(payload, "not_before")
|
||||
expires_at = _catalog_optional_text(payload, "expires_at")
|
||||
effective_require_trusted = _configured_require_signature() if require_trusted is None else require_trusted
|
||||
effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels
|
||||
effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys()
|
||||
signature_state = _catalog_signature_state(payload, trusted_keys=effective_trusted_keys)
|
||||
freshness = _catalog_freshness_state(payload)
|
||||
replay = _catalog_replay_state(channel=channel, sequence=sequence)
|
||||
except Exception as exc:
|
||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), error=str(exc))
|
||||
if signature_state.get("fatal"):
|
||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(signature_state["error"]))
|
||||
if effective_approved_channels and channel not in effective_approved_channels:
|
||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=f"Configuration package catalog channel {channel!r} is not approved. Approved channels: {', '.join(effective_approved_channels)}.")
|
||||
if effective_require_trusted and not signature_state["trusted"]:
|
||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(signature_state["error"] or "Configuration package catalog must be signed by a trusted key."))
|
||||
if not freshness["valid"]:
|
||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(freshness["error"]))
|
||||
if not replay["valid"]:
|
||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(replay["error"]))
|
||||
warnings = [str(item) for item in freshness.get("warnings", ()) if item]
|
||||
warnings.extend(str(item) for item in replay.get("warnings", ()) if item)
|
||||
if not signature_state["signed"]:
|
||||
warnings.append("Configuration package catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
|
||||
elif not signature_state["trusted"]:
|
||||
warnings.append(str(signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
|
||||
return _validation_result(
|
||||
source,
|
||||
valid=True,
|
||||
configured=configured,
|
||||
read_state=read_state,
|
||||
packages=packages,
|
||||
channel=channel,
|
||||
sequence=sequence,
|
||||
generated_at=generated_at,
|
||||
not_before=not_before,
|
||||
expires_at=expires_at,
|
||||
signature_state=signature_state,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def sign_configuration_package_catalog(*, path: Path, key_id: str, private_key_path: Path, output_path: Path | None = None) -> Path:
|
||||
payload = _read_catalog_payload(path)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Only object-style configuration package catalogs can be signed.")
|
||||
signed_payload = dict(payload)
|
||||
signed_payload.pop("signature", None)
|
||||
signed_payload.pop("signatures", None)
|
||||
private_key = _load_private_key(private_key_path)
|
||||
signature = private_key.sign(_canonical_catalog_bytes(signed_payload))
|
||||
signed_payload["signature"] = {
|
||||
"algorithm": "ed25519",
|
||||
"key_id": key_id,
|
||||
"value": base64.b64encode(signature).decode("ascii"),
|
||||
}
|
||||
target = output_path or path
|
||||
target.write_text(json.dumps(signed_payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
return target
|
||||
|
||||
|
||||
def record_configuration_package_catalog_acceptance(validation: dict[str, object]) -> None:
|
||||
state_path = _configured_sequence_state_path()
|
||||
if state_path is None or validation.get("valid") is not True:
|
||||
return
|
||||
channel = validation.get("channel")
|
||||
sequence = validation.get("sequence")
|
||||
if not isinstance(channel, str) or not isinstance(sequence, int):
|
||||
return
|
||||
try:
|
||||
state = json.loads(state_path.read_text(encoding="utf-8")) if state_path.exists() else {}
|
||||
except json.JSONDecodeError:
|
||||
state = {}
|
||||
if not isinstance(state, dict):
|
||||
state = {}
|
||||
channels = state.get("channels")
|
||||
if not isinstance(channels, dict):
|
||||
channels = {}
|
||||
channel_state = channels.get(channel)
|
||||
if not isinstance(channel_state, dict):
|
||||
channel_state = {}
|
||||
channel_state["last_sequence"] = max(int(channel_state.get("last_sequence") or 0), sequence)
|
||||
channel_state["accepted_at"] = datetime.now(tz=UTC).isoformat().replace("+00:00", "Z")
|
||||
channel_state["key_id"] = validation.get("key_id")
|
||||
channel_state["source"] = validation.get("source") or validation.get("path")
|
||||
channels[channel] = channel_state
|
||||
state["channels"] = channels
|
||||
state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _configuration_package_manifest(package: ConfigurationPackageManifest | Mapping[str, Any]) -> ConfigurationPackageManifest:
|
||||
if isinstance(package, ConfigurationPackageManifest):
|
||||
return package
|
||||
return ConfigurationPackageManifest.from_mapping(package)
|
||||
|
||||
|
||||
def _configuration_provider_map(providers: Sequence[ConfigurationProvider] | Mapping[str, ConfigurationProvider]) -> dict[str, ConfigurationProvider]:
|
||||
if isinstance(providers, Mapping):
|
||||
return {str(key): provider for key, provider in providers.items()}
|
||||
return {provider.module_id: provider for provider in providers}
|
||||
|
||||
|
||||
def _module_requirement_diagnostics(manifest: ConfigurationPackageManifest, context: ConfigurationPreflightContext) -> list[ConfigurationDiagnostic]:
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
installed = context.installed_modules
|
||||
for requirement in manifest.required_modules:
|
||||
installed_version = installed.get(requirement.module_id)
|
||||
if installed_version is None:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="required_module_missing",
|
||||
message=f"Required module {requirement.module_id!r} is not installed.",
|
||||
module_id=requirement.module_id,
|
||||
resolution="Install and enable the required module before applying this configuration package.",
|
||||
))
|
||||
elif requirement.version and installed_version != requirement.version:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="required_module_version_mismatch",
|
||||
message=f"Required module {requirement.module_id!r} needs version {requirement.version}, but {installed_version} is installed.",
|
||||
module_id=requirement.module_id,
|
||||
resolution="Install a compatible module version or use a matching configuration package.",
|
||||
))
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _capability_requirement_diagnostics(manifest: ConfigurationPackageManifest, context: ConfigurationPreflightContext) -> list[ConfigurationDiagnostic]:
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
capabilities = set(context.capabilities)
|
||||
for capability in manifest.required_capabilities:
|
||||
if capability not in capabilities:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="required_capability_missing",
|
||||
message=f"Required capability {capability!r} is not available.",
|
||||
object_ref=capability,
|
||||
resolution="Enable the module or platform capability before applying this configuration package.",
|
||||
))
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _dedupe_diagnostics(items: Sequence[ConfigurationDiagnostic]) -> list[ConfigurationDiagnostic]:
|
||||
seen: set[tuple[object, ...]] = set()
|
||||
result: list[ConfigurationDiagnostic] = []
|
||||
for item in items:
|
||||
key = (item.severity, item.code, item.module_id, item.object_ref, item.message)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def _dedupe_required_data(items: Sequence[ConfigurationRequiredData]) -> list[ConfigurationRequiredData]:
|
||||
seen: set[str] = set()
|
||||
result: list[ConfigurationRequiredData] = []
|
||||
for item in items:
|
||||
if item.key in seen:
|
||||
continue
|
||||
seen.add(item.key)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def _catalog_source(path: Path | str | None) -> Path | str | None:
|
||||
if path is not None:
|
||||
return path if isinstance(path, str) and _is_http_url(path) else Path(path).expanduser()
|
||||
url = os.getenv("GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_URL", "").strip()
|
||||
if url:
|
||||
return url
|
||||
value = os.getenv("GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG", "").strip()
|
||||
return Path(value).expanduser() if value else None
|
||||
|
||||
|
||||
def _configured_catalog_cache_path() -> Path | None:
|
||||
value = os.getenv("GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_CACHE", "").strip()
|
||||
return Path(value).expanduser() if value else None
|
||||
|
||||
|
||||
def _configured_sequence_state_path() -> Path | None:
|
||||
value = os.getenv("GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_SEQUENCE_STATE", "").strip()
|
||||
return Path(value).expanduser() if value else None
|
||||
|
||||
|
||||
def _configured_enforce_sequence() -> bool:
|
||||
return os.getenv("GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_ENFORCE_SEQUENCE", "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _configured_require_signature() -> bool:
|
||||
return os.getenv("GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_REQUIRE_SIGNATURE", "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _configured_approved_channels() -> tuple[str, ...]:
|
||||
value = os.getenv("GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_APPROVED_CHANNELS", "").strip()
|
||||
return tuple(item.strip() for item in value.split(",") if item.strip()) if value else ()
|
||||
|
||||
|
||||
def _configured_trusted_keys() -> dict[str, str]:
|
||||
file_value = os.getenv("GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "").strip()
|
||||
if file_value:
|
||||
path = Path(file_value).expanduser()
|
||||
if not path.exists():
|
||||
raise ValueError(f"Trusted configuration catalog key file does not exist: {path}")
|
||||
return _parse_trusted_keys(path.read_text(encoding="utf-8"))
|
||||
url_value = os.getenv("GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_TRUSTED_KEYS_URL", "").strip()
|
||||
if url_value:
|
||||
return _parse_trusted_keys(_read_trusted_keys_url(url_value))
|
||||
value = os.getenv("GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_TRUSTED_KEYS", "").strip()
|
||||
return _parse_trusted_keys(value) if value else {}
|
||||
|
||||
|
||||
def _configured_trusted_keys_cache_path() -> Path | None:
|
||||
value = os.getenv("GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_TRUSTED_KEYS_CACHE", "").strip()
|
||||
return Path(value).expanduser() if value else None
|
||||
|
||||
|
||||
def _read_trusted_keys_url(url: str) -> str:
|
||||
if not _is_http_url(url):
|
||||
raise ValueError("Trusted configuration catalog key URL must use http:// or https://.")
|
||||
cache_path = _configured_trusted_keys_cache_path()
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=15) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
if cache_path is not None:
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_path.write_text(body, encoding="utf-8")
|
||||
return body
|
||||
except (OSError, urllib.error.URLError):
|
||||
if cache_path is not None and cache_path.exists():
|
||||
return cache_path.read_text(encoding="utf-8")
|
||||
raise
|
||||
|
||||
|
||||
def _read_catalog_payload(source: Path | str | None) -> object:
|
||||
payload, _metadata = _read_catalog_payload_with_metadata(source)
|
||||
return payload
|
||||
|
||||
|
||||
def _read_catalog_payload_with_metadata(source: Path | str | None) -> tuple[object, dict[str, object]]:
|
||||
cache_path = _configured_catalog_cache_path()
|
||||
metadata: dict[str, object] = {"cache_used": False, "cache_path": str(cache_path) if cache_path else None}
|
||||
if source is None:
|
||||
return {"packages": []}, metadata
|
||||
if isinstance(source, str) and _is_http_url(source):
|
||||
try:
|
||||
with urllib.request.urlopen(source, timeout=15) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
if cache_path is not None:
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_path.write_text(body, encoding="utf-8")
|
||||
return json.loads(body), metadata
|
||||
except (OSError, urllib.error.URLError):
|
||||
if cache_path is not None and cache_path.exists():
|
||||
metadata["cache_used"] = True
|
||||
return json.loads(cache_path.read_text(encoding="utf-8")), metadata
|
||||
raise
|
||||
return json.loads(Path(source).read_text(encoding="utf-8")), metadata
|
||||
|
||||
|
||||
def _normalize_catalog_packages(payload: object) -> tuple[dict[str, object], ...]:
|
||||
raw_items = payload.get("packages") if isinstance(payload, dict) else payload
|
||||
if not isinstance(raw_items, list):
|
||||
raise ValueError("Configuration package catalog must be a list or an object with a 'packages' list.")
|
||||
return tuple(ConfigurationPackageManifest.from_mapping(item).to_dict() for item in raw_items if isinstance(item, Mapping))
|
||||
|
||||
|
||||
def _catalog_channel(payload: object) -> str | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
value = payload.get("channel")
|
||||
return str(value).strip() if value is not None and str(value).strip() else None
|
||||
|
||||
|
||||
def _catalog_replay_state(*, channel: str | None, sequence: int | None) -> dict[str, object]:
|
||||
state_path = _configured_sequence_state_path()
|
||||
if state_path is None:
|
||||
return {"valid": True, "warnings": ()}
|
||||
if not channel:
|
||||
return {"valid": False, "error": "Configuration package catalog sequence replay protection needs a channel."}
|
||||
if sequence is None:
|
||||
return {"valid": False, "error": "Configuration package catalog sequence replay protection needs a sequence."}
|
||||
if not state_path.exists():
|
||||
return {"valid": True, "warnings": ()}
|
||||
try:
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return {"valid": False, "error": f"Configuration catalog sequence state file is not valid JSON: {state_path}"}
|
||||
channels = state.get("channels") if isinstance(state, dict) else None
|
||||
channel_state = channels.get(channel) if isinstance(channels, dict) else None
|
||||
last_sequence = channel_state.get("last_sequence") if isinstance(channel_state, dict) else None
|
||||
if last_sequence is None:
|
||||
return {"valid": True, "warnings": ()}
|
||||
last = int(last_sequence)
|
||||
if sequence < last:
|
||||
return {"valid": False, "error": f"Configuration package catalog sequence {sequence} is older than accepted sequence {last} for channel {channel!r}."}
|
||||
if sequence == last and _configured_enforce_sequence():
|
||||
return {"valid": False, "error": f"Configuration package catalog sequence {sequence} was already accepted for channel {channel!r}."}
|
||||
return {"valid": True, "warnings": ()}
|
||||
|
||||
|
||||
def _validation_result(
|
||||
source: Path | str | None,
|
||||
*,
|
||||
valid: bool = False,
|
||||
configured: bool = False,
|
||||
read_state: Mapping[str, object] | None = None,
|
||||
packages: Sequence[Mapping[str, object]] = (),
|
||||
channel: str | None = None,
|
||||
sequence: int | None = None,
|
||||
generated_at: str | None = None,
|
||||
not_before: str | None = None,
|
||||
expires_at: str | None = None,
|
||||
signature_state: Mapping[str, object] | None = None,
|
||||
warnings: Sequence[str] = (),
|
||||
error: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
state = signature_state or {"signed": False, "trusted": False, "key_id": None}
|
||||
read_state = read_state or {}
|
||||
return {
|
||||
"valid": valid,
|
||||
"configured": configured,
|
||||
"path": str(source) if source is not None else None,
|
||||
"source": str(source) if source is not None else None,
|
||||
"source_type": _catalog_source_type(source),
|
||||
"cache_used": bool(read_state.get("cache_used")),
|
||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
||||
"packages": [dict(item) for item in packages],
|
||||
"channel": channel,
|
||||
"sequence": sequence,
|
||||
"generated_at": generated_at,
|
||||
"not_before": not_before,
|
||||
"expires_at": expires_at,
|
||||
"signed": bool(state.get("signed")),
|
||||
"trusted": bool(state.get("trusted")),
|
||||
"key_id": state.get("key_id"),
|
||||
"warnings": list(warnings),
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
def _module_requirements(value: object, *, optional: bool) -> list[ConfigurationModuleRequirement]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("Configuration package module requirements must be lists.")
|
||||
result: list[ConfigurationModuleRequirement] = []
|
||||
for item in value:
|
||||
if isinstance(item, str):
|
||||
result.append(ConfigurationModuleRequirement(module_id=item, optional=optional))
|
||||
elif isinstance(item, Mapping):
|
||||
result.append(ConfigurationModuleRequirement(module_id=_required_str(item, "module_id"), version=_optional_str(item, "version"), optional=optional))
|
||||
else:
|
||||
raise ValueError("Configuration package module requirements must be strings or objects.")
|
||||
return result
|
||||
|
||||
|
||||
def _fragments(value: object) -> list[ConfigurationPackageFragment]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("Configuration package fragments must be a list.")
|
||||
result: list[ConfigurationPackageFragment] = []
|
||||
for item in value:
|
||||
if not isinstance(item, Mapping):
|
||||
raise ValueError("Configuration package fragments must be objects.")
|
||||
payload = item.get("payload")
|
||||
result.append(ConfigurationPackageFragment(
|
||||
module_id=_required_str(item, "module_id"),
|
||||
fragment_type=_required_str(item, "fragment_type"),
|
||||
fragment_id=_optional_str(item, "fragment_id"),
|
||||
payload=payload if isinstance(payload, Mapping) else {},
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
def _object_list(value: object, *, field_name: str) -> list[dict[str, object]]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise ValueError(f"Configuration package {field_name} must be a list.")
|
||||
return [dict(item) for item in value if isinstance(item, Mapping)]
|
||||
|
||||
|
||||
def _string_list(value: object) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("Configuration package list fields must be lists.")
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
|
||||
|
||||
def _bool(value: object, *, default: bool) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().casefold() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _required_str(value: Mapping[str, Any], key: str) -> str:
|
||||
item = _optional_str(value, key)
|
||||
if not item:
|
||||
raise ValueError(f"Configuration package entry is missing {key!r}.")
|
||||
return item
|
||||
|
||||
|
||||
def _optional_str(value: Mapping[str, Any], key: str) -> str | None:
|
||||
item = value.get(key)
|
||||
if item is None:
|
||||
return None
|
||||
text = str(item).strip()
|
||||
return text or None
|
||||
395
src/govoplan_core/core/configuration_safety.py
Normal file
395
src/govoplan_core/core/configuration_safety.py
Normal file
@@ -0,0 +1,395 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Literal
|
||||
|
||||
from govoplan_core.security.permissions import scopes_grant
|
||||
|
||||
|
||||
ConfigurationScope = Literal["system", "tenant", "user", "group", "campaign"]
|
||||
ConfigurationRisk = Literal["low", "medium", "high", "destructive"]
|
||||
SecretHandling = Literal["none", "reference_only", "env_only"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationFieldSafety:
|
||||
key: str
|
||||
label: str
|
||||
owner_module: str
|
||||
scope: ConfigurationScope
|
||||
storage: str
|
||||
ui_managed: bool
|
||||
risk: ConfigurationRisk
|
||||
secret_handling: SecretHandling = "none"
|
||||
required_scopes: tuple[str, ...] = ()
|
||||
dry_run_required: bool = False
|
||||
validation_required: bool = True
|
||||
policy_explanation_required: bool = False
|
||||
audit_event: str | None = None
|
||||
maintenance_required: bool = False
|
||||
two_person_approval_required: bool = False
|
||||
rollback_history_required: bool = False
|
||||
notes: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"key": self.key,
|
||||
"label": self.label,
|
||||
"owner_module": self.owner_module,
|
||||
"scope": self.scope,
|
||||
"storage": self.storage,
|
||||
"ui_managed": self.ui_managed,
|
||||
"risk": self.risk,
|
||||
"secret_handling": self.secret_handling,
|
||||
"required_scopes": list(self.required_scopes),
|
||||
"dry_run_required": self.dry_run_required,
|
||||
"validation_required": self.validation_required,
|
||||
"policy_explanation_required": self.policy_explanation_required,
|
||||
"audit_event": self.audit_event,
|
||||
"maintenance_required": self.maintenance_required,
|
||||
"two_person_approval_required": self.two_person_approval_required,
|
||||
"rollback_history_required": self.rollback_history_required,
|
||||
"notes": self.notes,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationChangeSafetyPlan:
|
||||
key: str
|
||||
allowed: bool
|
||||
field: ConfigurationFieldSafety | None
|
||||
risk: ConfigurationRisk | None = None
|
||||
missing_scopes: tuple[str, ...] = ()
|
||||
dry_run_required: bool = False
|
||||
dry_run_satisfied: bool = False
|
||||
approval_required: bool = False
|
||||
approval_satisfied: bool = False
|
||||
maintenance_required: bool = False
|
||||
maintenance_satisfied: bool = False
|
||||
rollback_history_required: bool = False
|
||||
secret_handling: SecretHandling = "none"
|
||||
audit_event: str | None = None
|
||||
policy_explanation: str | None = None
|
||||
blockers: tuple[str, ...] = ()
|
||||
warnings: tuple[str, ...] = ()
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"key": self.key,
|
||||
"allowed": self.allowed,
|
||||
"field": self.field.to_dict() if self.field else None,
|
||||
"risk": self.risk,
|
||||
"missing_scopes": list(self.missing_scopes),
|
||||
"dry_run_required": self.dry_run_required,
|
||||
"dry_run_satisfied": self.dry_run_satisfied,
|
||||
"approval_required": self.approval_required,
|
||||
"approval_satisfied": self.approval_satisfied,
|
||||
"maintenance_required": self.maintenance_required,
|
||||
"maintenance_satisfied": self.maintenance_satisfied,
|
||||
"rollback_history_required": self.rollback_history_required,
|
||||
"secret_handling": self.secret_handling,
|
||||
"audit_event": self.audit_event,
|
||||
"policy_explanation": self.policy_explanation,
|
||||
"blockers": list(self.blockers),
|
||||
"warnings": list(self.warnings),
|
||||
}
|
||||
|
||||
|
||||
_CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||
ConfigurationFieldSafety(
|
||||
key="module_management.desired_enabled",
|
||||
label="Enabled modules",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="system_settings",
|
||||
ui_managed=True,
|
||||
risk="high",
|
||||
required_scopes=("system:settings:write",),
|
||||
dry_run_required=True,
|
||||
policy_explanation_required=True,
|
||||
audit_event="module_management.updated",
|
||||
maintenance_required=True,
|
||||
rollback_history_required=True,
|
||||
notes="Changing enabled modules must use module preflight and installer rollback records.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="module_management.install_plan",
|
||||
label="Module package install plan",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="system_settings",
|
||||
ui_managed=True,
|
||||
risk="destructive",
|
||||
required_scopes=("system:settings:write",),
|
||||
dry_run_required=True,
|
||||
policy_explanation_required=True,
|
||||
audit_event="module_install.requested",
|
||||
maintenance_required=True,
|
||||
two_person_approval_required=True,
|
||||
rollback_history_required=True,
|
||||
notes="Package mutation needs maintenance mode, dry-run preflight, approval, and run records.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="configuration_packages.apply",
|
||||
label="Configuration package apply",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="configuration_packages",
|
||||
ui_managed=True,
|
||||
risk="high",
|
||||
required_scopes=("system:settings:write",),
|
||||
dry_run_required=True,
|
||||
policy_explanation_required=True,
|
||||
audit_event="configuration_package.applied",
|
||||
maintenance_required=True,
|
||||
two_person_approval_required=True,
|
||||
rollback_history_required=True,
|
||||
notes="Configuration package apply needs a dry-run, maintenance mode, approval, and version history.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="maintenance_mode",
|
||||
label="Maintenance mode",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="system_settings",
|
||||
ui_managed=True,
|
||||
risk="high",
|
||||
required_scopes=("system:settings:write",),
|
||||
validation_required=True,
|
||||
audit_event="system_settings.updated",
|
||||
rollback_history_required=True,
|
||||
notes="Maintenance mode controls platform availability and gates dangerous operations.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="privacy_retention_policy",
|
||||
label="Privacy retention policy",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="system_settings",
|
||||
ui_managed=True,
|
||||
risk="high",
|
||||
required_scopes=("system:settings:write", "admin:policies:write"),
|
||||
dry_run_required=True,
|
||||
policy_explanation_required=True,
|
||||
audit_event="privacy_retention.policy_updated",
|
||||
two_person_approval_required=True,
|
||||
rollback_history_required=True,
|
||||
notes="Retention reductions and destructive cleanup require explainable policy decisions and dry-run counts.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="governance_templates",
|
||||
label="Governance templates",
|
||||
owner_module="access",
|
||||
scope="system",
|
||||
storage="governance_templates",
|
||||
ui_managed=True,
|
||||
risk="high",
|
||||
required_scopes=("system:governance:write",),
|
||||
dry_run_required=True,
|
||||
policy_explanation_required=True,
|
||||
audit_event="governance_template.updated",
|
||||
two_person_approval_required=True,
|
||||
rollback_history_required=True,
|
||||
notes="Central groups, roles, and assignments need previews before materialization.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="mail_profiles.credentials",
|
||||
label="Mail profile credentials",
|
||||
owner_module="mail",
|
||||
scope="tenant",
|
||||
storage="module_settings",
|
||||
ui_managed=True,
|
||||
risk="high",
|
||||
secret_handling="reference_only",
|
||||
required_scopes=("mail_servers:manage_credentials",),
|
||||
validation_required=True,
|
||||
audit_event="mail_server_profile.credential_updated",
|
||||
two_person_approval_required=True,
|
||||
rollback_history_required=True,
|
||||
notes="UI must store secret references/placeholders only; secret values are never echoed.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="files.connector_profiles",
|
||||
label="File connector profiles",
|
||||
owner_module="files",
|
||||
scope="tenant",
|
||||
storage="module_settings",
|
||||
ui_managed=True,
|
||||
risk="high",
|
||||
secret_handling="reference_only",
|
||||
required_scopes=("files:file:admin",),
|
||||
dry_run_required=True,
|
||||
policy_explanation_required=True,
|
||||
audit_event="files.connector_profile.updated",
|
||||
two_person_approval_required=True,
|
||||
rollback_history_required=True,
|
||||
notes="Connector endpoints are UI-manageable, but passwords/tokens remain env or secret refs.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="DATABASE_URL",
|
||||
label="Database URL",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="destructive",
|
||||
secret_handling="env_only",
|
||||
maintenance_required=True,
|
||||
notes="Database connectivity remains deployment-managed and must not be changed from the running UI.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="MASTER_KEY_B64",
|
||||
label="Master encryption key",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="destructive",
|
||||
secret_handling="env_only",
|
||||
maintenance_required=True,
|
||||
two_person_approval_required=True,
|
||||
notes="Encryption roots remain out of band; UI may only report missing/rotated state.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS",
|
||||
label="Module package catalog trusted keys",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only",
|
||||
notes="Trust roots are deployment-managed; UI can validate catalogs but should not edit key material.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="GOVOPLAN_CONFIGURATION_PACKAGE_CATALOG_TRUSTED_KEYS",
|
||||
label="Configuration package catalog trusted keys",
|
||||
owner_module="core",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only",
|
||||
notes="Configuration package trust roots are deployment-managed.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def configuration_safety_catalog(*, include_env_only: bool = True) -> tuple[ConfigurationFieldSafety, ...]:
|
||||
if include_env_only:
|
||||
return _CONFIGURATION_FIELD_SAFETY
|
||||
return tuple(item for item in _CONFIGURATION_FIELD_SAFETY if item.ui_managed)
|
||||
|
||||
|
||||
def classify_configuration_field(key: str) -> ConfigurationFieldSafety | None:
|
||||
clean = key.strip()
|
||||
for item in _CONFIGURATION_FIELD_SAFETY:
|
||||
if item.key == clean:
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def high_impact_configuration_fields() -> tuple[ConfigurationFieldSafety, ...]:
|
||||
return tuple(item for item in _CONFIGURATION_FIELD_SAFETY if item.risk in {"high", "destructive"})
|
||||
|
||||
|
||||
def ui_managed_configuration_fields_requiring_approval() -> tuple[ConfigurationFieldSafety, ...]:
|
||||
return tuple(item for item in _CONFIGURATION_FIELD_SAFETY if item.ui_managed and item.two_person_approval_required)
|
||||
|
||||
|
||||
def plan_configuration_change(
|
||||
key: str,
|
||||
*,
|
||||
actor_scopes: tuple[str, ...] | list[str] = (),
|
||||
value: object = None,
|
||||
dry_run: bool = False,
|
||||
maintenance_mode: bool = False,
|
||||
approval_count: int = 0,
|
||||
include_env_only: bool = True,
|
||||
) -> ConfigurationChangeSafetyPlan:
|
||||
field = classify_configuration_field(key)
|
||||
if field is None:
|
||||
return ConfigurationChangeSafetyPlan(
|
||||
key=key,
|
||||
allowed=False,
|
||||
field=None,
|
||||
blockers=("unknown_configuration_field",),
|
||||
policy_explanation="This setting is not in the configuration safety catalog.",
|
||||
)
|
||||
if not include_env_only and not field.ui_managed:
|
||||
return ConfigurationChangeSafetyPlan(
|
||||
key=key,
|
||||
allowed=False,
|
||||
field=field,
|
||||
risk=field.risk,
|
||||
secret_handling=field.secret_handling,
|
||||
blockers=("deployment_managed",),
|
||||
policy_explanation="This setting is deployment-managed and cannot be edited through the UI.",
|
||||
)
|
||||
blockers: list[str] = []
|
||||
warnings: list[str] = []
|
||||
missing_scopes = tuple(scope for scope in field.required_scopes if not scopes_grant(actor_scopes, scope))
|
||||
if missing_scopes:
|
||||
blockers.append("missing_required_scope")
|
||||
if not field.ui_managed:
|
||||
blockers.append("deployment_managed")
|
||||
if field.dry_run_required and not dry_run:
|
||||
blockers.append("dry_run_required")
|
||||
if field.maintenance_required and not maintenance_mode:
|
||||
blockers.append("maintenance_mode_required")
|
||||
approval_required = field.two_person_approval_required
|
||||
approval_satisfied = not approval_required or approval_count >= 2
|
||||
if approval_required and not approval_satisfied:
|
||||
blockers.append("two_person_approval_required")
|
||||
if field.secret_handling == "reference_only" and _contains_plain_secret(value):
|
||||
blockers.append("secret_reference_required")
|
||||
if field.secret_handling == "env_only" and value is not None:
|
||||
blockers.append("env_only_secret")
|
||||
if field.rollback_history_required:
|
||||
warnings.append("rollback_history_required")
|
||||
return ConfigurationChangeSafetyPlan(
|
||||
key=field.key,
|
||||
allowed=not blockers,
|
||||
field=field,
|
||||
risk=field.risk,
|
||||
missing_scopes=missing_scopes,
|
||||
dry_run_required=field.dry_run_required,
|
||||
dry_run_satisfied=not field.dry_run_required or dry_run,
|
||||
approval_required=approval_required,
|
||||
approval_satisfied=approval_satisfied,
|
||||
maintenance_required=field.maintenance_required,
|
||||
maintenance_satisfied=not field.maintenance_required or maintenance_mode,
|
||||
rollback_history_required=field.rollback_history_required,
|
||||
secret_handling=field.secret_handling,
|
||||
audit_event=field.audit_event,
|
||||
policy_explanation=_policy_explanation(field),
|
||||
blockers=tuple(dict.fromkeys(blockers)),
|
||||
warnings=tuple(dict.fromkeys(warnings)),
|
||||
)
|
||||
|
||||
|
||||
def _policy_explanation(field: ConfigurationFieldSafety) -> str:
|
||||
parts = [f"{field.label} is {field.risk}-risk"]
|
||||
if field.dry_run_required:
|
||||
parts.append("requires dry-run preview")
|
||||
if field.two_person_approval_required:
|
||||
parts.append("requires two-person approval")
|
||||
if field.maintenance_required:
|
||||
parts.append("requires maintenance mode")
|
||||
if field.secret_handling != "none":
|
||||
parts.append(f"uses {field.secret_handling} secret handling")
|
||||
return "; ".join(parts) + "."
|
||||
|
||||
|
||||
def _contains_plain_secret(value: object) -> bool:
|
||||
if not isinstance(value, Mapping):
|
||||
return False
|
||||
secret_keys = {"password", "token", "secret", "api_key", "access_key", "secret_key"}
|
||||
for key, item in value.items():
|
||||
name = str(key).strip().casefold()
|
||||
if name in secret_keys and item not in (None, "", {"secret_ref": ""}):
|
||||
return True
|
||||
if isinstance(item, Mapping) and _contains_plain_secret(item):
|
||||
return True
|
||||
return False
|
||||
@@ -2,9 +2,36 @@ from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Mapping
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
|
||||
_TRACE_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
|
||||
|
||||
|
||||
def new_event_id() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def normalize_trace_id(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
candidate = value.strip()
|
||||
return candidate if _TRACE_ID_RE.fullmatch(candidate) else None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EventTrace:
|
||||
correlation_id: str
|
||||
causation_id: str | None = None
|
||||
|
||||
|
||||
_current_trace: ContextVar[EventTrace | None] = ContextVar("govoplan_event_trace", default=None)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -13,11 +40,59 @@ class PlatformEvent:
|
||||
module_id: str
|
||||
payload: Mapping[str, Any] = field(default_factory=dict)
|
||||
occurred_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
event_id: str = field(default_factory=new_event_id)
|
||||
correlation_id: str | None = None
|
||||
causation_id: str | None = None
|
||||
|
||||
|
||||
EventHandler = Callable[[PlatformEvent], None]
|
||||
|
||||
|
||||
def current_event_trace() -> EventTrace | None:
|
||||
return _current_trace.get()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def event_context(
|
||||
*,
|
||||
correlation_id: str | None = None,
|
||||
causation_id: str | None = None,
|
||||
):
|
||||
parent = current_event_trace()
|
||||
trace = EventTrace(
|
||||
correlation_id=normalize_trace_id(correlation_id) or (parent.correlation_id if parent else new_event_id()),
|
||||
causation_id=normalize_trace_id(causation_id) or (parent.causation_id if parent else None),
|
||||
)
|
||||
token = _current_trace.set(trace)
|
||||
try:
|
||||
yield trace
|
||||
finally:
|
||||
_current_trace.reset(token)
|
||||
|
||||
|
||||
def event_trace_for(event: PlatformEvent) -> EventTrace:
|
||||
active = current_event_trace()
|
||||
return EventTrace(
|
||||
correlation_id=normalize_trace_id(event.correlation_id) or (active.correlation_id if active else event.event_id),
|
||||
causation_id=normalize_trace_id(event.causation_id) or (active.causation_id if active else None),
|
||||
)
|
||||
|
||||
|
||||
def ensure_event_trace(event: PlatformEvent) -> PlatformEvent:
|
||||
trace = event_trace_for(event)
|
||||
if event.correlation_id == trace.correlation_id and event.causation_id == trace.causation_id:
|
||||
return event
|
||||
return PlatformEvent(
|
||||
type=event.type,
|
||||
module_id=event.module_id,
|
||||
payload=event.payload,
|
||||
occurred_at=event.occurred_at,
|
||||
event_id=event.event_id,
|
||||
correlation_id=trace.correlation_id,
|
||||
causation_id=trace.causation_id,
|
||||
)
|
||||
|
||||
|
||||
class EventBus:
|
||||
def __init__(self) -> None:
|
||||
self._subscribers: dict[str, list[EventHandler]] = defaultdict(list)
|
||||
@@ -26,8 +101,9 @@ class EventBus:
|
||||
self._subscribers[event_type].append(handler)
|
||||
|
||||
def publish(self, event: PlatformEvent) -> None:
|
||||
for handler in self._subscribers.get(event.type, ()):
|
||||
handler(event)
|
||||
for handler in self._subscribers.get("*", ()):
|
||||
handler(event)
|
||||
|
||||
traced = ensure_event_trace(event)
|
||||
with event_context(correlation_id=traced.correlation_id, causation_id=traced.event_id):
|
||||
for handler in self._subscribers.get(traced.type, ()):
|
||||
handler(traced)
|
||||
for handler in self._subscribers.get("*", ()):
|
||||
handler(traced)
|
||||
|
||||
46
src/govoplan_core/core/identity.py
Normal file
46
src/govoplan_core/core/identity.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
IDENTITY_MODULE_ID = "identity"
|
||||
CAPABILITY_IDENTITY_DIRECTORY = "identity.directory"
|
||||
|
||||
IdentityStatus = Literal["active", "inactive", "suspended"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IdentityRef:
|
||||
id: str
|
||||
display_name: str | None = None
|
||||
external_subject: str | None = None
|
||||
source: str = "local"
|
||||
primary_account_id: str | None = None
|
||||
account_ids: tuple[str, ...] = ()
|
||||
status: IdentityStatus = "active"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IdentityAccountLinkRef:
|
||||
id: str
|
||||
identity_id: str
|
||||
account_id: str
|
||||
is_primary: bool = False
|
||||
source: str = "local"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IdentityDirectory(Protocol):
|
||||
def get_identity(self, identity_id: str) -> IdentityRef | None:
|
||||
...
|
||||
|
||||
def identity_for_account(self, account_id: str) -> IdentityRef | None:
|
||||
...
|
||||
|
||||
def identities_for_accounts(self, account_ids: Sequence[str]) -> Sequence[IdentityRef]:
|
||||
...
|
||||
|
||||
def accounts_for_identity(self, identity_id: str) -> Sequence[IdentityAccountLinkRef]:
|
||||
...
|
||||
@@ -10,6 +10,7 @@ from govoplan_core.core.module_management import ModuleManagementError, REQUIRED
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.runtime import configure_runtime
|
||||
from govoplan_core.server.route_validation import validate_router_can_mount
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -129,6 +130,7 @@ class ModuleLifecycleManager:
|
||||
module_router = manifest.route_factory(self.context)
|
||||
guarded = APIRouter(dependencies=[Depends(require_module_active(module_id))])
|
||||
guarded.include_router(module_router)
|
||||
validate_router_can_mount(app, guarded, prefix=self.api_prefix, owner=f"module {module_id!r}")
|
||||
app.include_router(guarded, prefix=self.api_prefix)
|
||||
app.openapi_schema = None
|
||||
self._mounted_modules.add(module_id)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
from contextlib import AbstractContextManager
|
||||
from contextlib import AbstractContextManager, closing
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from importlib import metadata
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -22,6 +23,7 @@ import urllib.request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
||||
from govoplan_core.core.events import current_event_trace
|
||||
from govoplan_core.core.module_management import (
|
||||
PROTECTED_MODULES,
|
||||
ModuleInstallPlan,
|
||||
@@ -85,6 +87,7 @@ class ModuleInstallerPreflight:
|
||||
rollback_commands: tuple[str, ...]
|
||||
issues: tuple[ModuleInstallerIssue, ...]
|
||||
checklist: tuple[ModuleInstallChecklistItem, ...] = ()
|
||||
artifact_integrity: tuple[dict[str, object], ...] = ()
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
@@ -96,6 +99,7 @@ class ModuleInstallerPreflight:
|
||||
"rollback_commands": list(self.rollback_commands),
|
||||
"issues": [issue.as_dict() for issue in self.issues],
|
||||
"checklist": [item.as_dict() for item in self.checklist],
|
||||
"artifact_integrity": list(self.artifact_integrity),
|
||||
}
|
||||
|
||||
|
||||
@@ -203,6 +207,12 @@ def module_install_preflight(
|
||||
if item.webui_package or item.webui_ref:
|
||||
frontend_rebuild_required = True
|
||||
|
||||
artifact_integrity, artifact_integrity_issues = _verify_artifact_integrity(
|
||||
planned_items,
|
||||
require_verified=_configured_require_artifact_integrity(),
|
||||
)
|
||||
issues.extend(artifact_integrity_issues)
|
||||
|
||||
if frontend_rebuild_required:
|
||||
if webui_root is None:
|
||||
issues.append(ModuleInstallerIssue("blocker", "webui_root_missing", "WebUI package changes need a webui root for npm install/build."))
|
||||
@@ -229,6 +239,7 @@ def module_install_preflight(
|
||||
rollback_commands=rollback_commands,
|
||||
issues=tuple(issues),
|
||||
checklist=checklist,
|
||||
artifact_integrity=artifact_integrity,
|
||||
)
|
||||
|
||||
|
||||
@@ -251,6 +262,7 @@ def run_module_install_plan(
|
||||
activate_installed_modules: bool = True,
|
||||
remove_uninstalled_modules_from_desired: bool = True,
|
||||
dry_run: bool = False,
|
||||
request_context: Mapping[str, object] | None = None,
|
||||
) -> ModuleInstallerRunResult:
|
||||
maintenance_mode = saved_maintenance_mode(session)
|
||||
effective_runtime_dir = runtime_dir or default_installer_runtime_dir(database_url)
|
||||
@@ -302,6 +314,8 @@ def run_module_install_plan(
|
||||
"activate_installed_modules": activate_installed_modules,
|
||||
"remove_uninstalled_modules_from_desired": remove_uninstalled_modules_from_desired,
|
||||
}
|
||||
if request_context:
|
||||
record["request"] = dict(request_context)
|
||||
_write_json(record_path, record)
|
||||
|
||||
if dry_run:
|
||||
@@ -409,6 +423,7 @@ def supervise_module_install_plan(
|
||||
health_urls: Iterable[str] | None = None,
|
||||
health_timeout_seconds: float = 60.0,
|
||||
health_interval_seconds: float = 2.0,
|
||||
request_context: Mapping[str, object] | None = None,
|
||||
) -> ModuleInstallerRunResult:
|
||||
effective_runtime_dir = runtime_dir or default_installer_runtime_dir(database_url)
|
||||
effective_restart_commands = _command_list(restart_command, restart_commands)
|
||||
@@ -431,6 +446,7 @@ def supervise_module_install_plan(
|
||||
activate_installed_modules=activate_installed_modules,
|
||||
remove_uninstalled_modules_from_desired=remove_uninstalled_modules_from_desired,
|
||||
dry_run=False,
|
||||
request_context=request_context,
|
||||
)
|
||||
supervisor: dict[str, object] = {
|
||||
"started_at": datetime.now(tz=UTC).isoformat(),
|
||||
@@ -647,8 +663,10 @@ def queue_module_installer_request(
|
||||
options: Mapping[str, object] | None = None,
|
||||
requested_by: str | None = None,
|
||||
retry_of: str | None = None,
|
||||
trace: Mapping[str, object] | None = None,
|
||||
) -> dict[str, object]:
|
||||
request_id = _run_id()
|
||||
effective_trace = dict(trace) if trace is not None else _current_trace_payload()
|
||||
record = {
|
||||
"request_id": request_id,
|
||||
"status": "queued",
|
||||
@@ -656,6 +674,8 @@ def queue_module_installer_request(
|
||||
"requested_by": requested_by,
|
||||
"options": dict(options or {}),
|
||||
}
|
||||
if effective_trace:
|
||||
record["trace"] = effective_trace
|
||||
if retry_of:
|
||||
record["retry_of"] = retry_of
|
||||
with _request_queue_lock(runtime_dir):
|
||||
@@ -1273,6 +1293,7 @@ def _run_summary(path: Path, record: Mapping[str, object]) -> dict[str, object]:
|
||||
supervisor = record.get("supervisor") if isinstance(record.get("supervisor"), Mapping) else {}
|
||||
commands = record.get("commands") if isinstance(record.get("commands"), list) else []
|
||||
plan = record.get("plan") if isinstance(record.get("plan"), list) else []
|
||||
request = record.get("request") if isinstance(record.get("request"), Mapping) else {}
|
||||
return {
|
||||
"run_id": str(record.get("run_id") or path.parent.name),
|
||||
"status": str(record.get("status") or "unknown"),
|
||||
@@ -1284,9 +1305,22 @@ def _run_summary(path: Path, record: Mapping[str, object]) -> dict[str, object]:
|
||||
"record_path": str(path),
|
||||
"commands_count": len(commands),
|
||||
"planned_modules": [str(item.get("module_id")) for item in plan if isinstance(item, Mapping) and item.get("module_id")],
|
||||
"request_id": request.get("request_id") if isinstance(request, Mapping) else None,
|
||||
"requested_by": request.get("requested_by") if isinstance(request, Mapping) else None,
|
||||
"trace": request.get("trace") if isinstance(request.get("trace"), Mapping) else None,
|
||||
}
|
||||
|
||||
|
||||
def _current_trace_payload() -> dict[str, object]:
|
||||
trace = current_event_trace()
|
||||
if trace is None:
|
||||
return {}
|
||||
payload: dict[str, object] = {"correlation_id": trace.correlation_id}
|
||||
if trace.causation_id:
|
||||
payload["causation_id"] = trace.causation_id
|
||||
return payload
|
||||
|
||||
|
||||
def _database_restore_was_applied(record: Mapping[str, object]) -> bool:
|
||||
restore = record.get("rollback_database_restore")
|
||||
return isinstance(restore, Mapping) and restore.get("restored") is True
|
||||
@@ -1305,6 +1339,156 @@ def _looks_pinned_dependency_ref(value: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _configured_require_artifact_integrity() -> bool:
|
||||
return os.getenv("GOVOPLAN_MODULE_INSTALLER_REQUIRE_ARTIFACT_INTEGRITY", "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _verify_artifact_integrity(
|
||||
planned_items: tuple[ModuleInstallPlanItem, ...],
|
||||
*,
|
||||
require_verified: bool,
|
||||
) -> tuple[tuple[dict[str, object], ...], tuple[ModuleInstallerIssue, ...]]:
|
||||
records: list[dict[str, object]] = []
|
||||
issues: list[ModuleInstallerIssue] = []
|
||||
for item in planned_items:
|
||||
if item.action != "install":
|
||||
continue
|
||||
for kind, package_name, package_ref in (
|
||||
("python", item.python_package, item.python_ref),
|
||||
("webui", item.webui_package, item.webui_ref),
|
||||
):
|
||||
if not package_ref:
|
||||
continue
|
||||
raw_metadata = _artifact_metadata(item.artifact_integrity, kind)
|
||||
if raw_metadata is None:
|
||||
if require_verified:
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"blocker",
|
||||
"artifact_integrity_required",
|
||||
f"{kind.capitalize()} artifact integrity metadata is required in production mode.",
|
||||
item.module_id,
|
||||
))
|
||||
continue
|
||||
record, record_issues = _verify_artifact_metadata(
|
||||
item,
|
||||
kind=kind,
|
||||
package_name=package_name,
|
||||
package_ref=package_ref,
|
||||
metadata=raw_metadata,
|
||||
require_verified=require_verified,
|
||||
)
|
||||
records.append(record)
|
||||
issues.extend(record_issues)
|
||||
return tuple(records), tuple(issues)
|
||||
|
||||
|
||||
def _artifact_metadata(value: Mapping[str, object] | None, kind: str) -> Mapping[str, object] | None:
|
||||
if not isinstance(value, Mapping):
|
||||
return None
|
||||
raw = value.get(kind)
|
||||
return raw if isinstance(raw, Mapping) else None
|
||||
|
||||
|
||||
def _verify_artifact_metadata(
|
||||
item: ModuleInstallPlanItem,
|
||||
*,
|
||||
kind: str,
|
||||
package_name: str | None,
|
||||
package_ref: str,
|
||||
metadata: Mapping[str, object],
|
||||
require_verified: bool,
|
||||
) -> tuple[dict[str, object], tuple[ModuleInstallerIssue, ...]]:
|
||||
issues: list[ModuleInstallerIssue] = []
|
||||
expected_sha256 = _artifact_text(metadata, "sha256")
|
||||
artifact_path = _artifact_path(metadata)
|
||||
expected_ref = _artifact_text(metadata, "ref") or _artifact_text(metadata, "expected_ref")
|
||||
record: dict[str, object] = {
|
||||
"module_id": item.module_id,
|
||||
"kind": kind,
|
||||
"package_ref": package_ref,
|
||||
"verified": False,
|
||||
}
|
||||
if package_name:
|
||||
record["package"] = package_name
|
||||
for key in ("sha256", "sbom_url", "provenance_url", "registry_identity", "git_ref"):
|
||||
value = _artifact_text(metadata, key)
|
||||
if value:
|
||||
record[key] = value
|
||||
if expected_ref:
|
||||
record["expected_ref"] = expected_ref
|
||||
if expected_ref != package_ref:
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"blocker",
|
||||
"artifact_ref_mismatch",
|
||||
f"{kind.capitalize()} artifact metadata expected ref {expected_ref!r} but the install plan uses {package_ref!r}.",
|
||||
item.module_id,
|
||||
))
|
||||
if not expected_sha256:
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"blocker" if require_verified else "warning",
|
||||
"artifact_digest_missing",
|
||||
f"{kind.capitalize()} artifact integrity metadata has no sha256 digest.",
|
||||
item.module_id,
|
||||
))
|
||||
return record, tuple(issues)
|
||||
if artifact_path is None:
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"blocker" if require_verified else "warning",
|
||||
"artifact_not_verified",
|
||||
f"{kind.capitalize()} artifact digest is declared but no local artifact path is available for verification.",
|
||||
item.module_id,
|
||||
))
|
||||
return record, tuple(issues)
|
||||
record["artifact_path"] = str(artifact_path)
|
||||
if not artifact_path.exists():
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"blocker",
|
||||
"artifact_missing",
|
||||
f"{kind.capitalize()} artifact does not exist: {artifact_path}",
|
||||
item.module_id,
|
||||
))
|
||||
return record, tuple(issues)
|
||||
actual_sha256 = _sha256_file(artifact_path)
|
||||
record["actual_sha256"] = actual_sha256
|
||||
if actual_sha256 != expected_sha256.lower():
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"blocker",
|
||||
"artifact_digest_mismatch",
|
||||
f"{kind.capitalize()} artifact digest mismatch for {artifact_path}.",
|
||||
item.module_id,
|
||||
))
|
||||
return record, tuple(issues)
|
||||
record["verified"] = True
|
||||
record["verified_at"] = datetime.now(tz=UTC).isoformat()
|
||||
return record, tuple(issues)
|
||||
|
||||
|
||||
def _artifact_text(metadata: Mapping[str, object], key: str) -> str | None:
|
||||
value = metadata.get(key)
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _artifact_path(metadata: Mapping[str, object]) -> Path | None:
|
||||
value = _artifact_text(metadata, "path") or _artifact_text(metadata, "artifact_path")
|
||||
if not value:
|
||||
return None
|
||||
if value.startswith("file://"):
|
||||
value = value[len("file://"):]
|
||||
path = Path(value).expanduser()
|
||||
return path if path.is_absolute() else Path.cwd() / path
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _snapshot_environment(
|
||||
run_dir: Path,
|
||||
*,
|
||||
@@ -1368,7 +1552,7 @@ def _snapshot_sqlite_database(run_dir: Path, database_url: str | None) -> dict[s
|
||||
raise ModuleInstallerError("Automatic database backup for --migrate currently supports sqlite:/// URLs only.")
|
||||
backup_path = run_dir / "database.sqlite.before"
|
||||
if db_path.exists():
|
||||
with sqlite3.connect(str(db_path)) as source, sqlite3.connect(str(backup_path)) as target:
|
||||
with closing(sqlite3.connect(str(db_path))) as source, closing(sqlite3.connect(str(backup_path))) as target:
|
||||
source.backup(target)
|
||||
else:
|
||||
backup_path.touch()
|
||||
@@ -1467,7 +1651,7 @@ def _restore_database_snapshot(
|
||||
if backup_path.stat().st_size == 0:
|
||||
target_path.unlink(missing_ok=True)
|
||||
return {"restored": True, "type": "sqlite", "source": str(target_path), "empty": True}
|
||||
with sqlite3.connect(str(backup_path)) as source, sqlite3.connect(str(target_path)) as target:
|
||||
with closing(sqlite3.connect(str(backup_path))) as source, closing(sqlite3.connect(str(target_path))) as target:
|
||||
source.backup(target)
|
||||
return {"restored": True, "type": "sqlite", "source": str(target_path)}
|
||||
|
||||
@@ -1523,6 +1707,9 @@ def _run_database_hook(
|
||||
})
|
||||
if database_url:
|
||||
env["GOVOPLAN_DATABASE_URL"] = database_url
|
||||
pgtools_url = _database_url_for_pgtools(database_url)
|
||||
if pgtools_url:
|
||||
env["GOVOPLAN_DATABASE_URL_PGTOOLS"] = pgtools_url
|
||||
env.setdefault("DATABASE_URL", database_url)
|
||||
return subprocess.run(
|
||||
command,
|
||||
@@ -1535,6 +1722,14 @@ def _run_database_hook(
|
||||
)
|
||||
|
||||
|
||||
def _database_url_for_pgtools(database_url: str) -> str | None:
|
||||
if database_url.startswith(("postgresql://", "postgres://")):
|
||||
return database_url
|
||||
if database_url.startswith("postgresql+"):
|
||||
return re.sub(r"^postgresql\+[^:]+://", "postgresql://", database_url)
|
||||
return None
|
||||
|
||||
|
||||
def _sqlite_database_path(database_url: str | None) -> Path | None:
|
||||
if not database_url or not database_url.startswith("sqlite:///"):
|
||||
return None
|
||||
@@ -1572,6 +1767,7 @@ def _mark_applied(item: ModuleInstallPlanItem) -> ModuleInstallPlanItem:
|
||||
python_ref=item.python_ref,
|
||||
webui_package=item.webui_package,
|
||||
webui_ref=item.webui_ref,
|
||||
artifact_integrity=item.artifact_integrity,
|
||||
destroy_data=item.destroy_data,
|
||||
status="applied",
|
||||
notes=item.notes,
|
||||
|
||||
@@ -11,14 +11,103 @@ import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
||||
|
||||
|
||||
def module_license_decision(required_features: list[str] | tuple[str, ...]) -> dict[str, object]:
|
||||
return _module_license_decision_from_validation(validate_module_license(), required_features)
|
||||
|
||||
|
||||
def module_license_diagnostics(
|
||||
path: Path | None = None,
|
||||
*,
|
||||
required_features: list[str] | tuple[str, ...] = (),
|
||||
trusted_keys: dict[str, str] | None = None,
|
||||
require_trusted: bool | None = None,
|
||||
) -> dict[str, object]:
|
||||
validation = validate_module_license(path, trusted_keys=trusted_keys, require_trusted=require_trusted)
|
||||
decision = _module_license_decision_from_validation(validation, required_features)
|
||||
guidance = _license_guidance(validation, decision)
|
||||
return {
|
||||
"configured": validation["configured"],
|
||||
"valid": validation["valid"],
|
||||
"path": validation["path"],
|
||||
"license_id": validation["license_id"],
|
||||
"subject": validation["subject"],
|
||||
"features": validation["features"],
|
||||
"valid_from": validation["valid_from"],
|
||||
"valid_until": validation["valid_until"],
|
||||
"signed": validation["signed"],
|
||||
"trusted": validation["trusted"],
|
||||
"key_id": validation["key_id"],
|
||||
"enforced": _license_enforcement_enabled(),
|
||||
"allowed": decision["allowed"],
|
||||
"required_features": decision["required_features"],
|
||||
"missing_features": decision["missing_features"],
|
||||
"expires_in_days": _expires_in_days(validation.get("valid_until")),
|
||||
"reason": decision.get("reason") or validation.get("error"),
|
||||
"guidance": guidance,
|
||||
}
|
||||
|
||||
|
||||
def issue_module_license(
|
||||
*,
|
||||
path: Path,
|
||||
license_id: str,
|
||||
subject: str,
|
||||
features: list[str] | tuple[str, ...],
|
||||
valid_until: str | datetime,
|
||||
signing_key_id: str,
|
||||
signing_private_key_path: Path,
|
||||
valid_from: str | datetime | None = None,
|
||||
issuer: str | None = None,
|
||||
notes: str | None = None,
|
||||
) -> Path:
|
||||
clean_license_id = str(license_id).strip()
|
||||
clean_subject = str(subject).strip()
|
||||
clean_key_id = str(signing_key_id).strip()
|
||||
clean_features = _normalize_required_features(features)
|
||||
if not clean_license_id:
|
||||
raise ValueError("License id is required.")
|
||||
if not clean_subject:
|
||||
raise ValueError("License subject is required.")
|
||||
if not clean_key_id:
|
||||
raise ValueError("License signing key id is required.")
|
||||
if not clean_features:
|
||||
raise ValueError("At least one license feature is required.")
|
||||
payload: dict[str, object] = {
|
||||
"license_id": clean_license_id,
|
||||
"subject": clean_subject,
|
||||
"features": list(clean_features),
|
||||
"valid_from": _datetime_text(valid_from or datetime.now(tz=UTC)),
|
||||
"valid_until": _datetime_text(valid_until),
|
||||
}
|
||||
clean_issuer = str(issuer).strip() if issuer else ""
|
||||
if clean_issuer:
|
||||
payload["issuer"] = clean_issuer
|
||||
clean_notes = str(notes).strip() if notes else ""
|
||||
if clean_notes:
|
||||
payload["notes"] = clean_notes
|
||||
private_key = _load_private_key(signing_private_key_path)
|
||||
signature = private_key.sign(_canonical_bytes(payload))
|
||||
payload["signature"] = {
|
||||
"algorithm": "ed25519",
|
||||
"key_id": clean_key_id,
|
||||
"value": base64.b64encode(signature).decode("ascii"),
|
||||
}
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _module_license_decision_from_validation(
|
||||
validation: dict[str, object],
|
||||
required_features: list[str] | tuple[str, ...],
|
||||
) -> dict[str, object]:
|
||||
required = tuple(dict.fromkeys(str(item).strip() for item in required_features if str(item).strip()))
|
||||
if not required:
|
||||
return {"allowed": True, "required_features": [], "missing_features": [], "enforced": False}
|
||||
validation = validate_module_license()
|
||||
enforced = _license_enforcement_enabled()
|
||||
if not validation["valid"]:
|
||||
return {
|
||||
@@ -102,6 +191,43 @@ def _license_result(
|
||||
}
|
||||
|
||||
|
||||
def _license_guidance(validation: dict[str, object], decision: dict[str, object]) -> list[str]:
|
||||
guidance: list[str] = []
|
||||
if not validation.get("configured"):
|
||||
guidance.append("Configure GOVOPLAN_LICENSE_FILE with the current signed license file.")
|
||||
elif validation.get("path") and not validation.get("valid") and validation.get("error"):
|
||||
guidance.append("Import a renewed license file or correct the configured license path before enforcing installs.")
|
||||
if validation.get("configured") and not validation.get("signed"):
|
||||
guidance.append("Use a signed license for production and configure GOVOPLAN_LICENSE_TRUSTED_KEYS_FILE.")
|
||||
elif validation.get("signed") and not validation.get("trusted"):
|
||||
guidance.append("Add the signing public key to the trusted license keyring, or rotate to a trusted issuer key.")
|
||||
expires_in_days = _expires_in_days(validation.get("valid_until"))
|
||||
if expires_in_days is not None:
|
||||
if expires_in_days < 0:
|
||||
guidance.append("Renew the license; the validity window has ended.")
|
||||
elif expires_in_days <= 30:
|
||||
guidance.append(f"Renew the license soon; it expires in {expires_in_days} day(s).")
|
||||
missing = decision.get("missing_features")
|
||||
if isinstance(missing, list) and missing:
|
||||
guidance.append("Request a renewal or entitlement update for: " + ", ".join(str(item) for item in missing))
|
||||
if not _license_enforcement_enabled():
|
||||
guidance.append("License enforcement is observe-only until GOVOPLAN_LICENSE_ENFORCEMENT=true is set.")
|
||||
return guidance
|
||||
|
||||
|
||||
def _expires_in_days(value: object) -> int | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
expires_at = _parse_datetime(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if expires_at is None:
|
||||
return None
|
||||
seconds = (expires_at - datetime.now(tz=UTC)).total_seconds()
|
||||
return int(seconds // 86400)
|
||||
|
||||
|
||||
def _configured_license_path() -> Path | None:
|
||||
value = os.getenv("GOVOPLAN_LICENSE_FILE", "").strip()
|
||||
return Path(value).expanduser() if value else None
|
||||
@@ -203,6 +329,13 @@ def _canonical_bytes(payload: object) -> bytes:
|
||||
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def _datetime_text(value: str | datetime) -> str:
|
||||
parsed = _parse_datetime(value)
|
||||
if parsed is None:
|
||||
raise ValueError("License validity timestamp is required.")
|
||||
return parsed.isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _parse_datetime(value: object) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
@@ -223,3 +356,14 @@ def _string_list(value: object) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("License features must be a list.")
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
|
||||
|
||||
def _normalize_required_features(value: list[str] | tuple[str, ...]) -> tuple[str, ...]:
|
||||
return tuple(dict.fromkeys(str(item).strip() for item in value if str(item).strip()))
|
||||
|
||||
|
||||
def _load_private_key(path: Path) -> Ed25519PrivateKey:
|
||||
private_key = serialization.load_pem_private_key(path.expanduser().read_bytes(), password=None)
|
||||
if not isinstance(private_key, Ed25519PrivateKey):
|
||||
raise ValueError("License signing key must be an Ed25519 private key.")
|
||||
return private_key
|
||||
|
||||
@@ -42,6 +42,7 @@ class ModuleInstallPlanItem:
|
||||
python_ref: str | None = None
|
||||
webui_package: str | None = None
|
||||
webui_ref: str | None = None
|
||||
artifact_integrity: Mapping[str, object] | None = None
|
||||
destroy_data: bool = False
|
||||
status: str = "planned"
|
||||
notes: str | None = None
|
||||
@@ -58,6 +59,8 @@ class ModuleInstallPlanItem:
|
||||
value = getattr(self, key)
|
||||
if value:
|
||||
payload[key] = value
|
||||
if self.artifact_integrity:
|
||||
payload["artifact_integrity"] = dict(self.artifact_integrity)
|
||||
return payload
|
||||
|
||||
|
||||
@@ -113,7 +116,7 @@ def load_startup_enabled_modules(
|
||||
with get_database().session() as session:
|
||||
desired = saved_desired_enabled_modules(session, fallback)
|
||||
except (RuntimeError, SQLAlchemyError):
|
||||
return fallback
|
||||
desired = fallback
|
||||
if available is None:
|
||||
return desired
|
||||
|
||||
@@ -293,6 +296,7 @@ def normalize_module_install_plan_item(
|
||||
python_ref = _clean_optional_string(raw.get("python_ref"))
|
||||
webui_package = _clean_optional_string(raw.get("webui_package"))
|
||||
webui_ref = _clean_optional_string(raw.get("webui_ref"))
|
||||
artifact_integrity = _clean_optional_mapping(raw.get("artifact_integrity"), field="artifact_integrity", module_id=module_id)
|
||||
destroy_data = _clean_bool(raw.get("destroy_data"))
|
||||
notes = _clean_optional_string(raw.get("notes"))
|
||||
|
||||
@@ -322,6 +326,7 @@ def normalize_module_install_plan_item(
|
||||
python_ref=python_ref,
|
||||
webui_package=webui_package,
|
||||
webui_ref=webui_ref,
|
||||
artifact_integrity=artifact_integrity,
|
||||
destroy_data=destroy_data,
|
||||
status=status,
|
||||
notes=notes,
|
||||
@@ -413,6 +418,14 @@ def _clean_bool(value: object) -> bool:
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _clean_optional_mapping(value: object, *, field: str, module_id: str) -> dict[str, object] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, Mapping):
|
||||
raise ModuleManagementError(f"Install plan item {module_id!r} field {field} must be an object.")
|
||||
return {str(key): item for key, item in value.items() if str(key).strip()}
|
||||
|
||||
|
||||
def _validate_dependency_ref(value: str, *, field: str, module_id: str) -> None:
|
||||
lowered = value.lower()
|
||||
if lowered.startswith(LOCAL_DEPENDENCY_REF_PREFIXES) or value.startswith(("/", "./", "../", "~")):
|
||||
|
||||
@@ -54,10 +54,13 @@ def validate_module_package_catalog(
|
||||
"path": str(catalog_source),
|
||||
"source": str(catalog_source),
|
||||
"source_type": _catalog_source_type(catalog_source),
|
||||
"cache_used": False,
|
||||
"cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() is not None else None,
|
||||
"modules": [],
|
||||
"channel": None,
|
||||
"sequence": None,
|
||||
"generated_at": None,
|
||||
"not_before": None,
|
||||
"expires_at": None,
|
||||
"signed": False,
|
||||
"trusted": False,
|
||||
@@ -66,12 +69,14 @@ def validate_module_package_catalog(
|
||||
"error": f"Module package catalog does not exist: {catalog_source}",
|
||||
}
|
||||
warnings: list[str] = []
|
||||
read_state = {"cache_used": False, "cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() is not None else None}
|
||||
try:
|
||||
payload = _read_catalog_payload(catalog_source)
|
||||
payload, read_state = _read_catalog_payload_with_metadata(catalog_source)
|
||||
modules = _normalize_catalog_modules(payload)
|
||||
channel = _catalog_channel(payload)
|
||||
sequence = _catalog_sequence(payload)
|
||||
generated_at = _catalog_optional_text(payload, "generated_at")
|
||||
not_before = _catalog_optional_text(payload, "not_before")
|
||||
expires_at = _catalog_optional_text(payload, "expires_at")
|
||||
signature_state = _catalog_signature_state(payload, trusted_keys=effective_trusted_keys)
|
||||
freshness = _catalog_freshness_state(payload)
|
||||
@@ -83,10 +88,13 @@ def validate_module_package_catalog(
|
||||
"path": str(catalog_source) if catalog_source is not None else None,
|
||||
"source": str(catalog_source) if catalog_source is not None else None,
|
||||
"source_type": _catalog_source_type(catalog_source),
|
||||
"cache_used": bool(read_state.get("cache_used")),
|
||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
||||
"modules": [],
|
||||
"channel": None,
|
||||
"sequence": None,
|
||||
"generated_at": None,
|
||||
"not_before": None,
|
||||
"expires_at": None,
|
||||
"signed": False,
|
||||
"trusted": False,
|
||||
@@ -101,10 +109,13 @@ def validate_module_package_catalog(
|
||||
"path": str(catalog_source) if catalog_source is not None else None,
|
||||
"source": str(catalog_source) if catalog_source is not None else None,
|
||||
"source_type": _catalog_source_type(catalog_source),
|
||||
"cache_used": bool(read_state.get("cache_used")),
|
||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
||||
"modules": [],
|
||||
"channel": channel,
|
||||
"sequence": sequence,
|
||||
"generated_at": generated_at,
|
||||
"not_before": not_before,
|
||||
"expires_at": expires_at,
|
||||
"signed": signature_state["signed"],
|
||||
"trusted": signature_state["trusted"],
|
||||
@@ -119,10 +130,13 @@ def validate_module_package_catalog(
|
||||
"path": str(catalog_source) if catalog_source is not None else None,
|
||||
"source": str(catalog_source) if catalog_source is not None else None,
|
||||
"source_type": _catalog_source_type(catalog_source),
|
||||
"cache_used": bool(read_state.get("cache_used")),
|
||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
||||
"modules": [],
|
||||
"channel": channel,
|
||||
"sequence": sequence,
|
||||
"generated_at": generated_at,
|
||||
"not_before": not_before,
|
||||
"expires_at": expires_at,
|
||||
"signed": signature_state["signed"],
|
||||
"trusted": signature_state["trusted"],
|
||||
@@ -137,10 +151,13 @@ def validate_module_package_catalog(
|
||||
"path": str(catalog_source) if catalog_source is not None else None,
|
||||
"source": str(catalog_source) if catalog_source is not None else None,
|
||||
"source_type": _catalog_source_type(catalog_source),
|
||||
"cache_used": bool(read_state.get("cache_used")),
|
||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
||||
"modules": [],
|
||||
"channel": channel,
|
||||
"sequence": sequence,
|
||||
"generated_at": generated_at,
|
||||
"not_before": not_before,
|
||||
"expires_at": expires_at,
|
||||
"signed": signature_state["signed"],
|
||||
"trusted": False,
|
||||
@@ -155,8 +172,10 @@ def validate_module_package_catalog(
|
||||
channel=channel,
|
||||
sequence=sequence,
|
||||
generated_at=generated_at,
|
||||
not_before=not_before,
|
||||
expires_at=expires_at,
|
||||
signature_state=signature_state,
|
||||
read_state=read_state,
|
||||
error=str(freshness["error"]),
|
||||
)
|
||||
if not replay["valid"]:
|
||||
@@ -166,8 +185,10 @@ def validate_module_package_catalog(
|
||||
channel=channel,
|
||||
sequence=sequence,
|
||||
generated_at=generated_at,
|
||||
not_before=not_before,
|
||||
expires_at=expires_at,
|
||||
signature_state=signature_state,
|
||||
read_state=read_state,
|
||||
error=str(replay["error"]),
|
||||
)
|
||||
warnings.extend(str(item) for item in freshness.get("warnings", ()) if item)
|
||||
@@ -182,10 +203,13 @@ def validate_module_package_catalog(
|
||||
"path": str(catalog_source) if catalog_source is not None else None,
|
||||
"source": str(catalog_source) if catalog_source is not None else None,
|
||||
"source_type": _catalog_source_type(catalog_source),
|
||||
"cache_used": bool(read_state.get("cache_used")),
|
||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
||||
"modules": list(modules),
|
||||
"channel": channel,
|
||||
"sequence": sequence,
|
||||
"generated_at": generated_at,
|
||||
"not_before": not_before,
|
||||
"expires_at": expires_at,
|
||||
"signed": signature_state["signed"],
|
||||
"trusted": signature_state["trusted"],
|
||||
@@ -353,14 +377,31 @@ def _parse_trusted_keys(value: str) -> dict[str, str]:
|
||||
|
||||
|
||||
def _read_catalog_payload(source: Path | str | None) -> object:
|
||||
payload, _metadata = _read_catalog_payload_with_metadata(source)
|
||||
return payload
|
||||
|
||||
|
||||
def _read_catalog_payload_with_metadata(source: Path | str | None) -> tuple[object, dict[str, object]]:
|
||||
cache_path = _configured_catalog_cache_path()
|
||||
metadata: dict[str, object] = {
|
||||
"cache_used": False,
|
||||
"cache_path": str(cache_path) if cache_path is not None else None,
|
||||
}
|
||||
if source is None:
|
||||
return {"modules": []}
|
||||
return {"modules": []}, metadata
|
||||
if isinstance(source, str) and _is_http_url(source):
|
||||
return json.loads(_read_catalog_url(source))
|
||||
return json.loads(Path(source).read_text(encoding="utf-8"))
|
||||
body, cache_used = _read_catalog_url_with_metadata(source)
|
||||
metadata["cache_used"] = cache_used
|
||||
return json.loads(body), metadata
|
||||
return json.loads(Path(source).read_text(encoding="utf-8")), metadata
|
||||
|
||||
|
||||
def _read_catalog_url(url: str) -> str:
|
||||
body, _cache_used = _read_catalog_url_with_metadata(url)
|
||||
return body
|
||||
|
||||
|
||||
def _read_catalog_url_with_metadata(url: str) -> tuple[str, bool]:
|
||||
cache_path = _configured_catalog_cache_path()
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=15) as response:
|
||||
@@ -368,10 +409,10 @@ def _read_catalog_url(url: str) -> str:
|
||||
if cache_path is not None:
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_path.write_text(body, encoding="utf-8")
|
||||
return body
|
||||
return body, False
|
||||
except (OSError, urllib.error.URLError):
|
||||
if cache_path is not None and cache_path.exists():
|
||||
return cache_path.read_text(encoding="utf-8")
|
||||
return cache_path.read_text(encoding="utf-8"), True
|
||||
raise
|
||||
|
||||
|
||||
@@ -549,7 +590,7 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
|
||||
action = str(value.get("action") or "install")
|
||||
if action not in {"install", "uninstall"}:
|
||||
raise ValueError(f"Unsupported catalog action for {module_id!r}: {action!r}")
|
||||
return {
|
||||
item = {
|
||||
"module_id": module_id,
|
||||
"name": str(value.get("name") or module_id),
|
||||
"description": _optional_str(value, "description"),
|
||||
@@ -563,6 +604,10 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
|
||||
"notes": _optional_str(value, "notes"),
|
||||
"tags": _string_list(value.get("tags")),
|
||||
}
|
||||
artifact_integrity = _normalize_artifact_integrity(value.get("artifact_integrity"))
|
||||
if artifact_integrity:
|
||||
item["artifact_integrity"] = artifact_integrity
|
||||
return item
|
||||
|
||||
|
||||
def _required_str(value: dict[str, Any], key: str) -> str:
|
||||
@@ -588,6 +633,37 @@ def _string_list(value: Any) -> list[str]:
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
|
||||
|
||||
def _normalize_artifact_integrity(value: Any) -> dict[str, object]:
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("Module package catalog artifact_integrity must be an object.")
|
||||
normalized: dict[str, object] = {}
|
||||
for key in ("python", "webui"):
|
||||
raw = value.get(key)
|
||||
if raw is None:
|
||||
continue
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"Module package catalog artifact_integrity.{key} must be an object.")
|
||||
clean = {
|
||||
field: text
|
||||
for field in (
|
||||
"ref",
|
||||
"path",
|
||||
"artifact_path",
|
||||
"sha256",
|
||||
"sbom_url",
|
||||
"provenance_url",
|
||||
"registry_identity",
|
||||
"git_ref",
|
||||
)
|
||||
if (text := _optional_str(raw, field))
|
||||
}
|
||||
if clean:
|
||||
normalized[key] = clean
|
||||
return normalized
|
||||
|
||||
|
||||
def _parse_datetime(value: object) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
@@ -628,8 +704,10 @@ def _invalid_catalog_result(
|
||||
channel: str | None,
|
||||
sequence: int | None,
|
||||
generated_at: str | None,
|
||||
not_before: str | None,
|
||||
expires_at: str | None,
|
||||
signature_state: dict[str, object],
|
||||
read_state: dict[str, object],
|
||||
error: str,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
@@ -638,10 +716,13 @@ def _invalid_catalog_result(
|
||||
"path": str(source) if source is not None else None,
|
||||
"source": str(source) if source is not None else None,
|
||||
"source_type": _catalog_source_type(source),
|
||||
"cache_used": bool(read_state.get("cache_used")),
|
||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
||||
"modules": list(modules),
|
||||
"channel": channel,
|
||||
"sequence": sequence,
|
||||
"generated_at": generated_at,
|
||||
"not_before": not_before,
|
||||
"expires_at": expires_at,
|
||||
"signed": signature_state["signed"],
|
||||
"trusted": signature_state["trusted"],
|
||||
|
||||
@@ -8,6 +8,9 @@ if TYPE_CHECKING:
|
||||
from fastapi import APIRouter
|
||||
|
||||
|
||||
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
|
||||
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION = "1"
|
||||
|
||||
PermissionLevel = Literal["system", "tenant"]
|
||||
SubjectType = Literal["account", "membership", "group", "service_account", "tenant"]
|
||||
|
||||
@@ -129,6 +132,61 @@ class ModuleContext:
|
||||
data: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
DocumentationLayer = Literal["always", "configured", "available", "evidence"]
|
||||
DocumentationLinkKind = Literal["runtime", "api", "repository", "wiki", "public"]
|
||||
DocumentationType = Literal["admin", "user"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DocumentationLink:
|
||||
label: str
|
||||
href: str
|
||||
kind: DocumentationLinkKind = "runtime"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DocumentationCondition:
|
||||
required_modules: tuple[str, ...] = ()
|
||||
any_modules: tuple[str, ...] = ()
|
||||
missing_modules: tuple[str, ...] = ()
|
||||
required_capabilities: tuple[str, ...] = ()
|
||||
required_scopes: tuple[str, ...] = ()
|
||||
any_scopes: tuple[str, ...] = ()
|
||||
configuration_keys: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DocumentationTopic:
|
||||
id: str
|
||||
title: str
|
||||
summary: str
|
||||
body: str = ""
|
||||
layer: DocumentationLayer = "configured"
|
||||
documentation_types: tuple[DocumentationType, ...] = ("admin",)
|
||||
audience: tuple[str, ...] = ()
|
||||
order: int = 100
|
||||
conditions: tuple[DocumentationCondition, ...] = ()
|
||||
links: tuple[DocumentationLink, ...] = ()
|
||||
related_modules: tuple[str, ...] = ()
|
||||
unlocks: tuple[str, ...] = ()
|
||||
configuration_keys: tuple[str, ...] = ()
|
||||
i18n_key: str | None = None
|
||||
translations: Mapping[str, Mapping[str, str]] = field(default_factory=dict)
|
||||
source_module_id: str | None = None
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DocumentationContext:
|
||||
registry: object
|
||||
principal: object | None = None
|
||||
settings: object | None = None
|
||||
session: object | None = None
|
||||
documentation_type: DocumentationType = "admin"
|
||||
locale: str = "en"
|
||||
data: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class ResourceAclProvider(Protocol):
|
||||
resource_type: str
|
||||
|
||||
@@ -146,6 +204,7 @@ TenantSummaryProvider = Callable[[object, str], Mapping[str, int]]
|
||||
DeleteVetoProvider = Callable[[object, str, str], None]
|
||||
RouteFactory = Callable[[ModuleContext], "APIRouter"]
|
||||
CapabilityFactory = Callable[[ModuleContext], object]
|
||||
DocumentationProvider = Callable[[DocumentationContext], Iterable[DocumentationTopic]]
|
||||
LifecycleHook = Callable[[ModuleContext], None]
|
||||
|
||||
|
||||
@@ -170,3 +229,5 @@ class ModuleManifest:
|
||||
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
|
||||
on_activate: LifecycleHook | None = None
|
||||
on_deactivate: LifecycleHook | None = None
|
||||
documentation: tuple[DocumentationTopic, ...] = ()
|
||||
documentation_providers: tuple[DocumentationProvider, ...] = ()
|
||||
|
||||
85
src/govoplan_core/core/organizations.py
Normal file
85
src/govoplan_core/core/organizations.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
ORGANIZATIONS_MODULE_ID = "organizations"
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY = "organizations.directory"
|
||||
|
||||
OrganizationStatus = Literal["active", "inactive", "suspended"]
|
||||
FunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationUnitRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
slug: str
|
||||
name: str
|
||||
parent_id: str | None = None
|
||||
description: str | None = None
|
||||
status: OrganizationStatus = "active"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationFunctionRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
organization_unit_id: str
|
||||
slug: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
delegable: bool = False
|
||||
act_in_place_allowed: bool = False
|
||||
status: OrganizationStatus = "active"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationFunctionAssignmentRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
account_id: str
|
||||
function_id: str
|
||||
organization_unit_id: str
|
||||
identity_id: str | None = None
|
||||
applies_to_subunits: bool = False
|
||||
source: FunctionAssignmentSource = "direct"
|
||||
delegated_from_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
status: OrganizationStatus = "active"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class OrganizationDirectory(Protocol):
|
||||
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
|
||||
...
|
||||
|
||||
def organization_units_for_tenant(self, tenant_id: str) -> Sequence[OrganizationUnitRef]:
|
||||
...
|
||||
|
||||
def get_function(self, function_id: str) -> OrganizationFunctionRef | None:
|
||||
...
|
||||
|
||||
def functions_for_organization_unit(
|
||||
self,
|
||||
organization_unit_id: str,
|
||||
*,
|
||||
include_subunits: bool = False,
|
||||
) -> Sequence[OrganizationFunctionRef]:
|
||||
...
|
||||
|
||||
def get_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
|
||||
...
|
||||
|
||||
def function_assignments_for_account(
|
||||
self,
|
||||
account_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
||||
...
|
||||
70
src/govoplan_core/core/pagination.py
Normal file
70
src/govoplan_core/core/pagination.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, Mapping
|
||||
|
||||
KEYSET_CURSOR_PREFIX = "ks1:"
|
||||
|
||||
|
||||
class KeysetCursorError(ValueError):
|
||||
"""Raised when a keyset cursor cannot be decoded for the current query."""
|
||||
|
||||
|
||||
def keyset_query_fingerprint(scope: str, params: Mapping[str, Any]) -> str:
|
||||
payload = {"scope": scope, "params": _jsonable(params)}
|
||||
raw = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24]
|
||||
|
||||
|
||||
def encode_keyset_cursor(scope: str, *, fingerprint: str, values: Mapping[str, Any]) -> str:
|
||||
payload = {
|
||||
"v": 1,
|
||||
"scope": scope,
|
||||
"fingerprint": fingerprint,
|
||||
"values": _jsonable(values),
|
||||
}
|
||||
raw = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
encoded = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
return f"{KEYSET_CURSOR_PREFIX}{encoded}"
|
||||
|
||||
|
||||
def decode_keyset_cursor(scope: str, cursor: str | None, *, fingerprint: str | None = None) -> dict[str, Any] | None:
|
||||
if cursor is None or not cursor.strip():
|
||||
return None
|
||||
raw_cursor = cursor.strip()
|
||||
if not raw_cursor.startswith(KEYSET_CURSOR_PREFIX):
|
||||
raise KeysetCursorError("Invalid pagination cursor")
|
||||
encoded = raw_cursor[len(KEYSET_CURSOR_PREFIX):]
|
||||
try:
|
||||
padded = encoded + ("=" * (-len(encoded) % 4))
|
||||
payload = json.loads(base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise KeysetCursorError("Invalid pagination cursor") from exc
|
||||
if not isinstance(payload, dict) or payload.get("v") != 1:
|
||||
raise KeysetCursorError("Invalid pagination cursor")
|
||||
if payload.get("scope") != scope:
|
||||
raise KeysetCursorError("Pagination cursor does not match this endpoint")
|
||||
if fingerprint is not None and payload.get("fingerprint") != fingerprint:
|
||||
raise KeysetCursorError("Pagination cursor does not match the current query")
|
||||
values = payload.get("values")
|
||||
if not isinstance(values, dict):
|
||||
raise KeysetCursorError("Invalid pagination cursor")
|
||||
return values
|
||||
|
||||
|
||||
def _jsonable(value: Any) -> Any:
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, date):
|
||||
return value.isoformat()
|
||||
if isinstance(value, Decimal):
|
||||
return str(value)
|
||||
if isinstance(value, Mapping):
|
||||
return {str(key): _jsonable(item) for key, item in sorted(value.items(), key=lambda pair: str(pair[0]))}
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [_jsonable(item) for item in value]
|
||||
return value
|
||||
124
src/govoplan_core/core/policy.py
Normal file
124
src/govoplan_core/core/policy.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Iterable, Literal, Mapping, cast
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
|
||||
|
||||
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign")
|
||||
|
||||
|
||||
def normalize_policy_scope_type(scope_type: str) -> PolicyScopeType:
|
||||
clean = scope_type.strip().casefold()
|
||||
if clean not in POLICY_SCOPE_TYPES:
|
||||
raise ValueError("Policy scope must be system, tenant, user, group or campaign")
|
||||
return cast(PolicyScopeType, clean)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PolicySourceRef:
|
||||
scope_type: PolicyScopeType
|
||||
scope_id: str | None = None
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
return policy_source_path(self.scope_type, self.scope_id)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"scope_type": self.scope_type, "scope_id": self.scope_id, "path": self.path}
|
||||
|
||||
|
||||
def policy_source_path(scope_type: str, scope_id: str | None = None) -> str:
|
||||
clean_scope = normalize_policy_scope_type(scope_type)
|
||||
if clean_scope == "system":
|
||||
if scope_id:
|
||||
raise ValueError("System policy sources do not carry a scope_id")
|
||||
return "system"
|
||||
if not scope_id:
|
||||
raise ValueError(f"{clean_scope.capitalize()} policy sources require a scope_id")
|
||||
return f"{clean_scope}:{quote(str(scope_id), safe='')}"
|
||||
|
||||
|
||||
def parse_policy_source_path(path: str) -> PolicySourceRef:
|
||||
clean_path = path.strip()
|
||||
if clean_path == "system":
|
||||
return PolicySourceRef(scope_type="system")
|
||||
scope_type, separator, encoded_scope_id = clean_path.partition(":")
|
||||
if not separator:
|
||||
raise ValueError("Policy source path must be system or <scope_type>:<url-encoded-scope-id>")
|
||||
clean_scope = normalize_policy_scope_type(scope_type)
|
||||
if clean_scope == "system":
|
||||
raise ValueError("System policy source path must be exactly system")
|
||||
scope_id = unquote(encoded_scope_id)
|
||||
if not scope_id:
|
||||
raise ValueError("Policy source path requires a non-empty scope id")
|
||||
return PolicySourceRef(scope_type=clean_scope, scope_id=scope_id)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PolicySourceStep:
|
||||
scope_type: PolicyScopeType
|
||||
label: str
|
||||
scope_id: str | None = None
|
||||
applied_fields: tuple[str, ...] = ()
|
||||
policy: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
return policy_source_path(self.scope_type, self.scope_id)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"scope_type": self.scope_type,
|
||||
"scope_id": self.scope_id,
|
||||
"path": self.path,
|
||||
"label": self.label,
|
||||
"applied_fields": list(self.applied_fields),
|
||||
"policy": dict(self.policy),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> PolicySourceStep:
|
||||
policy_value = value.get("policy")
|
||||
return cls(
|
||||
scope_type=normalize_policy_scope_type(str(value.get("scope_type", ""))),
|
||||
scope_id=str(value["scope_id"]) if value.get("scope_id") is not None else None,
|
||||
label=str(value.get("label") or ""),
|
||||
applied_fields=tuple(str(field) for field in (value.get("applied_fields") or ())),
|
||||
policy=policy_value if isinstance(policy_value, Mapping) else {},
|
||||
)
|
||||
|
||||
|
||||
def policy_source_step(
|
||||
scope_type: str,
|
||||
label: str,
|
||||
scope_id: str | None,
|
||||
applied_fields: Iterable[str] = (),
|
||||
policy: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return PolicySourceStep(
|
||||
scope_type=normalize_policy_scope_type(scope_type),
|
||||
scope_id=scope_id,
|
||||
label=label,
|
||||
applied_fields=tuple(str(field) for field in applied_fields),
|
||||
policy=dict(policy or {}),
|
||||
).to_dict()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PolicyDecision:
|
||||
allowed: bool
|
||||
reason: str | None = None
|
||||
source_path: tuple[PolicySourceStep, ...] = ()
|
||||
requirements: tuple[str, ...] = ()
|
||||
details: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"allowed": self.allowed,
|
||||
"reason": self.reason,
|
||||
"source_path": [step.to_dict() for step in self.source_path],
|
||||
"requirements": list(self.requirements),
|
||||
"details": dict(self.details),
|
||||
}
|
||||
@@ -14,9 +14,13 @@ from govoplan_core.core.modules import (
|
||||
PermissionDefinition,
|
||||
ResourceAclProvider,
|
||||
RoleTemplate,
|
||||
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
|
||||
SUPPORTED_MANIFEST_CONTRACT_VERSION,
|
||||
TenantSummaryProvider,
|
||||
)
|
||||
|
||||
_MODULE_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
||||
_NPM_PACKAGE_RE = re.compile(r"^(?:@[a-z0-9][a-z0-9_.-]*/)?[a-z0-9][a-z0-9_.-]*$")
|
||||
_SCOPE_RE = re.compile(r"^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:[a-z][a-z0-9_]*$")
|
||||
_WILDCARD_RE = re.compile(r"^([a-z][a-z0-9_]*|\*):\*$|^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:\*$")
|
||||
|
||||
@@ -150,6 +154,7 @@ class PlatformRegistry:
|
||||
ordered = tuple(self._topologically_sorted())
|
||||
seen_permissions: dict[str, PermissionDefinition] = {}
|
||||
for manifest in ordered:
|
||||
_validate_manifest_shape(manifest)
|
||||
for dependency in manifest.dependencies:
|
||||
if dependency not in self._manifests:
|
||||
raise RegistryError(f"Module {manifest.id!r} depends on unknown module {dependency!r}")
|
||||
@@ -207,3 +212,73 @@ class PlatformRegistry:
|
||||
unresolved = ", ".join(sorted(module_id for module_id, dependencies in incoming.items() if dependencies))
|
||||
raise RegistryError(f"Module dependency cycle or unresolved dependency: {unresolved}")
|
||||
return (self._manifests[module_id] for module_id in ordered)
|
||||
|
||||
|
||||
def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
||||
if not _MODULE_ID_RE.match(manifest.id):
|
||||
raise RegistryError(f"Module manifest id must match {_MODULE_ID_RE.pattern}: {manifest.id!r}")
|
||||
if not manifest.name.strip():
|
||||
raise RegistryError(f"Module {manifest.id!r} must declare a non-empty name")
|
||||
if not manifest.version.strip():
|
||||
raise RegistryError(f"Module {manifest.id!r} must declare a non-empty version")
|
||||
if manifest.compatibility.manifest_contract_version != SUPPORTED_MANIFEST_CONTRACT_VERSION:
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} uses unsupported manifest contract version "
|
||||
f"{manifest.compatibility.manifest_contract_version!r}; supported version is "
|
||||
f"{SUPPORTED_MANIFEST_CONTRACT_VERSION!r}"
|
||||
)
|
||||
|
||||
_validate_dependency_list(manifest.id, "dependencies", manifest.dependencies)
|
||||
_validate_dependency_list(manifest.id, "optional_dependencies", manifest.optional_dependencies)
|
||||
overlap = set(manifest.dependencies) & set(manifest.optional_dependencies)
|
||||
if overlap:
|
||||
joined = ", ".join(sorted(overlap))
|
||||
raise RegistryError(f"Module {manifest.id!r} lists dependencies as both required and optional: {joined}")
|
||||
|
||||
if manifest.migration_spec is not None:
|
||||
if manifest.migration_spec.module_id != manifest.id:
|
||||
raise RegistryError(f"Module {manifest.id!r} has migration spec for {manifest.migration_spec.module_id!r}")
|
||||
if manifest.migration_spec.metadata is None and not manifest.migration_spec.script_location:
|
||||
raise RegistryError(f"Module {manifest.id!r} migration spec must declare metadata or script location")
|
||||
|
||||
if manifest.frontend is not None:
|
||||
frontend = manifest.frontend
|
||||
if frontend.module_id != manifest.id:
|
||||
raise RegistryError(f"Module {manifest.id!r} has frontend metadata for {frontend.module_id!r}")
|
||||
if frontend.asset_manifest_contract_version != SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION:
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} uses unsupported frontend asset manifest contract version "
|
||||
f"{frontend.asset_manifest_contract_version!r}; supported version is "
|
||||
f"{SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION!r}"
|
||||
)
|
||||
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
|
||||
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
|
||||
for route in (*frontend.routes, *frontend.settings_routes):
|
||||
if not route.path.startswith("/"):
|
||||
raise RegistryError(f"Frontend route for module {manifest.id!r} must start with '/': {route.path!r}")
|
||||
if not route.component.strip():
|
||||
raise RegistryError(f"Frontend route {route.path!r} for module {manifest.id!r} must declare a component")
|
||||
for item in frontend.nav_items:
|
||||
_validate_nav_item(manifest.id, item)
|
||||
|
||||
for item in manifest.nav_items:
|
||||
_validate_nav_item(manifest.id, item)
|
||||
|
||||
|
||||
def _validate_dependency_list(module_id: str, field_name: str, dependencies: tuple[str, ...]) -> None:
|
||||
if len(dependencies) != len(set(dependencies)):
|
||||
raise RegistryError(f"Module {module_id!r} has duplicate {field_name}")
|
||||
for dependency in dependencies:
|
||||
if dependency == module_id:
|
||||
raise RegistryError(f"Module {module_id!r} cannot depend on itself")
|
||||
if not _MODULE_ID_RE.match(dependency):
|
||||
raise RegistryError(f"Module {module_id!r} has invalid dependency id {dependency!r}")
|
||||
|
||||
|
||||
def _validate_nav_item(module_id: str, item: NavItem) -> None:
|
||||
if not item.path.startswith("/"):
|
||||
raise RegistryError(f"Navigation item for module {module_id!r} must start with '/': {item.path!r}")
|
||||
if not item.label.strip():
|
||||
raise RegistryError(f"Navigation item {item.path!r} for module {module_id!r} must declare a label")
|
||||
if item.icon is not None and not item.icon.strip():
|
||||
raise RegistryError(f"Navigation item {item.path!r} for module {module_id!r} has an empty icon name")
|
||||
|
||||
@@ -29,6 +29,7 @@ def create_all_tables() -> None:
|
||||
# Build the configured registry so enabled module manifests register their
|
||||
# model metadata with the shared SQLAlchemy base before create_all runs.
|
||||
from govoplan_core.admin import models as core_admin_models # noqa: F401
|
||||
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401
|
||||
|
||||
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
|
||||
candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules)
|
||||
|
||||
@@ -10,6 +10,8 @@ from alembic.runtime.migration import MigrationContext
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
|
||||
from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan
|
||||
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
|
||||
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
||||
from govoplan_core.db.session import configure_database
|
||||
from govoplan_core.server.default_config import get_server_config
|
||||
@@ -26,8 +28,27 @@ from govoplan_core.settings import settings
|
||||
REVISION_AUTH_RBAC = "2c3d4e5f6a7b"
|
||||
REVISION_FILE_STORAGE = "3d4e5f6a7b8c"
|
||||
REVISION_FILE_FOLDERS = "4e5f6a7b8c9d"
|
||||
REVISION_NAMESPACE_PLATFORM_TABLES = "2e3f4a5b6c7d"
|
||||
REVISION_CORE_CHANGE_SEQUENCE = "3f4a5b6c7d8e"
|
||||
REVISION_HIERARCHICAL_SETTINGS = "f5a6b7c8d9e0"
|
||||
|
||||
_NAMESPACE_TABLE_RENAMES = (
|
||||
("tenants", "tenancy_tenants"),
|
||||
("accounts", "access_accounts"),
|
||||
("users", "access_users"),
|
||||
("groups", "access_groups"),
|
||||
("roles", "access_roles"),
|
||||
("system_role_assignments", "access_system_role_assignments"),
|
||||
("user_group_memberships", "access_user_group_memberships"),
|
||||
("user_role_assignments", "access_user_role_assignments"),
|
||||
("group_role_assignments", "access_group_role_assignments"),
|
||||
("api_keys", "access_api_keys"),
|
||||
("auth_sessions", "access_auth_sessions"),
|
||||
("system_settings", "core_system_settings"),
|
||||
("governance_templates", "admin_governance_templates"),
|
||||
("governance_template_assignments", "admin_governance_template_assignments"),
|
||||
)
|
||||
|
||||
_FILE_STORAGE_TABLES = {
|
||||
"file_blobs",
|
||||
"file_assets",
|
||||
@@ -74,21 +95,21 @@ _FILE_STORAGE_COLUMNS = {
|
||||
}
|
||||
|
||||
_CREATE_ALL_THROUGH_HIERARCHICAL_TABLES = {
|
||||
"accounts",
|
||||
"auth_sessions",
|
||||
"access_accounts",
|
||||
"access_auth_sessions",
|
||||
"audit_log",
|
||||
"campaign_versions",
|
||||
"campaign_jobs",
|
||||
"send_attempts",
|
||||
"system_role_assignments",
|
||||
"system_settings",
|
||||
"governance_templates",
|
||||
"governance_template_assignments",
|
||||
"access_system_role_assignments",
|
||||
"core_system_settings",
|
||||
"admin_governance_templates",
|
||||
"admin_governance_template_assignments",
|
||||
"mail_server_profiles",
|
||||
}
|
||||
|
||||
_CREATE_ALL_THROUGH_HIERARCHICAL_COLUMNS = {
|
||||
"auth_sessions": {"account_id", "csrf_token_hash"},
|
||||
"access_auth_sessions": {"account_id", "csrf_token_hash"},
|
||||
"audit_log": {"scope", "tenant_id", "user_id", "api_key_id"},
|
||||
"campaign_versions": {
|
||||
"workflow_state",
|
||||
@@ -102,13 +123,13 @@ _CREATE_ALL_THROUGH_HIERARCHICAL_COLUMNS = {
|
||||
},
|
||||
"campaign_jobs": {"claimed_at", "claim_token", "smtp_started_at", "outcome_unknown_at", "eml_sha256"},
|
||||
"send_attempts": {"status", "claim_token"},
|
||||
"users": {"account_id", "settings", "mail_profile_policy"},
|
||||
"groups": {"system_template_id", "system_required", "settings", "mail_profile_policy"},
|
||||
"roles": {"system_template_id", "system_required"},
|
||||
"tenants": {"settings", "allow_custom_groups", "allow_custom_roles", "allow_api_keys"},
|
||||
"access_users": {"account_id", "settings", "mail_profile_policy"},
|
||||
"access_groups": {"system_template_id", "system_required", "settings", "mail_profile_policy"},
|
||||
"access_roles": {"system_template_id", "system_required"},
|
||||
"tenancy_tenants": {"settings", "allow_custom_groups", "allow_custom_roles", "allow_api_keys"},
|
||||
"campaigns": {"settings", "mail_profile_policy"},
|
||||
"mail_server_profiles": {"scope_type", "scope_id"},
|
||||
"system_settings": {"settings", "allow_tenant_custom_groups", "allow_tenant_custom_roles", "allow_tenant_api_keys"},
|
||||
"core_system_settings": {"settings", "allow_tenant_custom_groups", "allow_tenant_custom_roles", "allow_tenant_api_keys"},
|
||||
}
|
||||
|
||||
_CREATE_ALL_THROUGH_HIERARCHICAL_INDEXES = {
|
||||
@@ -121,10 +142,10 @@ _CREATE_ALL_THROUGH_HIERARCHICAL_INDEXES = {
|
||||
"campaign_jobs": {"ix_campaign_jobs_claim_token", "ix_campaign_jobs_eml_sha256"},
|
||||
"send_attempts": {"ix_send_attempts_status", "ix_send_attempts_claim_token"},
|
||||
"audit_log": {"ix_audit_log_scope_created_at", "ix_audit_log_tenant_scope_created_at"},
|
||||
"auth_sessions": {"ix_auth_sessions_account_id"},
|
||||
"users": {"ix_users_account_id"},
|
||||
"groups": {"ix_groups_system_template_id"},
|
||||
"roles": {"ix_roles_system_template_id"},
|
||||
"access_auth_sessions": {"ix_access_auth_sessions_account_id"},
|
||||
"access_users": {"ix_access_users_account_id"},
|
||||
"access_groups": {"ix_access_groups_system_template_id"},
|
||||
"access_roles": {"ix_access_roles_system_template_id"},
|
||||
"mail_server_profiles": {"ix_mail_server_profiles_scope_type", "ix_mail_server_profiles_scope_id", "ix_mail_server_profiles_scope"},
|
||||
}
|
||||
|
||||
@@ -258,6 +279,111 @@ def _backfill_user_lock_state_for_create_all_schema(database_url: str) -> None:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _row_count(connection, table_name: str) -> int:
|
||||
quoted = connection.dialect.identifier_preparer.quote(table_name)
|
||||
return int(connection.execute(text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one())
|
||||
|
||||
|
||||
def _drop_table(connection, table_name: str) -> None:
|
||||
quoted = connection.dialect.identifier_preparer.quote(table_name)
|
||||
connection.execute(text(f"DROP TABLE {quoted}"))
|
||||
|
||||
|
||||
def _rename_table(connection, old_name: str, new_name: str) -> None:
|
||||
quoted_old = connection.dialect.identifier_preparer.quote(old_name)
|
||||
quoted_new = connection.dialect.identifier_preparer.quote(new_name)
|
||||
connection.execute(text(f"ALTER TABLE {quoted_old} RENAME TO {quoted_new}"))
|
||||
|
||||
|
||||
def reconcile_namespace_table_drift(database_url: str | None = None) -> bool:
|
||||
"""Repair dev databases stamped past the namespace-table migration.
|
||||
|
||||
During the repository split some development databases were stamped at the
|
||||
newer Alembic heads while still carrying the old platform table names. The
|
||||
real migration only renames tables, so replay that idempotent operation
|
||||
before startup bootstrap code touches the ORM.
|
||||
"""
|
||||
|
||||
url = database_url or settings.database_url
|
||||
changed = False
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
schema = inspect(connection)
|
||||
tables = set(schema.get_table_names())
|
||||
if not any(old_name in tables and new_name not in tables for old_name, new_name in _NAMESPACE_TABLE_RENAMES):
|
||||
return False
|
||||
|
||||
old_tables_to_drop: set[str] = set()
|
||||
new_tables_to_drop: set[str] = set()
|
||||
for old_name, new_name in _NAMESPACE_TABLE_RENAMES:
|
||||
if old_name not in tables or new_name not in tables:
|
||||
continue
|
||||
if _row_count(connection, old_name) == 0:
|
||||
old_tables_to_drop.add(old_name)
|
||||
elif _row_count(connection, new_name) == 0:
|
||||
new_tables_to_drop.add(new_name)
|
||||
else:
|
||||
raise RuntimeError(f"Cannot reconcile non-empty {old_name} over non-empty {new_name}")
|
||||
|
||||
for old_name, _new_name in reversed(_NAMESPACE_TABLE_RENAMES):
|
||||
if old_name in old_tables_to_drop:
|
||||
_drop_table(connection, old_name)
|
||||
tables.remove(old_name)
|
||||
changed = True
|
||||
for _old_name, new_name in reversed(_NAMESPACE_TABLE_RENAMES):
|
||||
if new_name in new_tables_to_drop:
|
||||
_drop_table(connection, new_name)
|
||||
tables.remove(new_name)
|
||||
changed = True
|
||||
|
||||
for old_name, new_name in _NAMESPACE_TABLE_RENAMES:
|
||||
if old_name not in tables or new_name in tables:
|
||||
continue
|
||||
_rename_table(connection, old_name, new_name)
|
||||
tables.remove(old_name)
|
||||
tables.add(new_name)
|
||||
changed = True
|
||||
finally:
|
||||
engine.dispose()
|
||||
return changed
|
||||
|
||||
|
||||
def reconcile_change_sequence_retention_floor_drift(database_url: str | None = None) -> bool:
|
||||
"""Repair databases stamped after the change-sequence migration changed.
|
||||
|
||||
Early development databases may have applied the change-sequence revision
|
||||
before the retention-floor table was added to that migration. The main
|
||||
change-sequence table is enough for full snapshots, but incremental delta
|
||||
requests need the retention floor to decide whether a watermark is stale.
|
||||
"""
|
||||
|
||||
url = database_url or settings.database_url
|
||||
changed = False
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
schema = inspect(connection)
|
||||
tables = set(schema.get_table_names())
|
||||
if ChangeSequenceEntry.__tablename__ not in tables:
|
||||
return False
|
||||
if ChangeSequenceRetentionFloor.__tablename__ not in tables:
|
||||
ChangeSequenceRetentionFloor.__table__.create(bind=connection, checkfirst=True)
|
||||
changed = True
|
||||
else:
|
||||
indexes = {
|
||||
index["name"]
|
||||
for index in schema.get_indexes(ChangeSequenceRetentionFloor.__tablename__)
|
||||
}
|
||||
for index in ChangeSequenceRetentionFloor.__table__.indexes:
|
||||
if index.name not in indexes:
|
||||
index.create(bind=connection)
|
||||
changed = True
|
||||
finally:
|
||||
engine.dispose()
|
||||
return changed
|
||||
|
||||
|
||||
def reconcile_legacy_create_all_schema(database_url: str | None = None) -> str | None:
|
||||
"""Repair the known Alembic/create_all drift without modifying application data.
|
||||
|
||||
@@ -327,6 +453,9 @@ def migrate_database(
|
||||
manifest_factories: tuple[ManifestFactory, ...] = (),
|
||||
) -> MigrationResult:
|
||||
url = database_url or settings.database_url
|
||||
if reconcile_legacy_schema:
|
||||
reconcile_namespace_table_drift(url)
|
||||
reconcile_change_sequence_retention_floor_drift(url)
|
||||
previous = database_revision(url)
|
||||
reconciled = reconcile_legacy_create_all_schema(url) if reconcile_legacy_schema else None
|
||||
command.upgrade(
|
||||
|
||||
@@ -38,15 +38,21 @@ class DatabaseHandle:
|
||||
with self.SessionLocal() as session:
|
||||
yield session
|
||||
|
||||
def dispose(self) -> None:
|
||||
self.engine.dispose()
|
||||
|
||||
|
||||
_default_database: DatabaseHandle | None = None
|
||||
|
||||
|
||||
def configure_database(database_url: str, *, engine: Engine | None = None) -> DatabaseHandle:
|
||||
def configure_database(database_url: str, *, engine: Engine | None = None, dispose_previous: bool = False) -> DatabaseHandle:
|
||||
global _default_database
|
||||
if engine is None and _default_database is not None and _default_database.database_url == database_url:
|
||||
return _default_database
|
||||
previous_database = _default_database
|
||||
_default_database = DatabaseHandle(database_url, engine=engine)
|
||||
if dispose_previous and previous_database is not None and previous_database is not _default_database:
|
||||
previous_database.dispose()
|
||||
return _default_database
|
||||
|
||||
|
||||
@@ -56,6 +62,13 @@ def set_database(handle: DatabaseHandle) -> DatabaseHandle:
|
||||
return handle
|
||||
|
||||
|
||||
def reset_database(*, dispose: bool = False) -> None:
|
||||
global _default_database
|
||||
if dispose and _default_database is not None:
|
||||
_default_database.dispose()
|
||||
_default_database = None
|
||||
|
||||
|
||||
def get_database() -> DatabaseHandle:
|
||||
if _default_database is None:
|
||||
raise RuntimeError("GovOPlaN database is not configured")
|
||||
|
||||
@@ -20,6 +20,9 @@ from govoplan_core.server.registry import available_module_manifests, build_plat
|
||||
|
||||
DEFAULT_APP = "govoplan_core.server.app:app"
|
||||
DEFAULT_CONFIG = "govoplan_core.server.default_config:get_server_config"
|
||||
DEFAULT_DEV_DATABASE_URL = "postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev"
|
||||
DEFAULT_DEV_DATABASE_URL_PGTOOLS = "postgresql://govoplan_dev@127.0.0.1:5432/govoplan_dev"
|
||||
DEFAULT_SQLITE_DATABASE_NAME = "multimailer-dev.db"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -61,13 +64,23 @@ def apply_runtime_defaults(config_path: str | None) -> Path | None:
|
||||
(runtime_root / "runtime" / "files").mkdir(parents=True, exist_ok=True)
|
||||
(runtime_root / "runtime" / "mock-mailbox").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
dev_database_backend = os.getenv("GOVOPLAN_DEV_DATABASE_BACKEND", "postgres").strip().lower()
|
||||
default_database_url = DEFAULT_DEV_DATABASE_URL
|
||||
default_pgtools_url = DEFAULT_DEV_DATABASE_URL_PGTOOLS
|
||||
if dev_database_backend == "sqlite":
|
||||
default_database_url = f"sqlite:///{runtime_root / 'runtime' / DEFAULT_SQLITE_DATABASE_NAME}"
|
||||
default_pgtools_url = ""
|
||||
|
||||
defaults = {
|
||||
"DATABASE_URL": f"sqlite:///{runtime_root / 'runtime' / 'multimailer-dev.db'}",
|
||||
"DATABASE_URL": os.getenv("GOVOPLAN_DEV_DATABASE_URL", default_database_url),
|
||||
"DEV_BOOTSTRAP_ENABLED": "true",
|
||||
"FILE_STORAGE_LOCAL_ROOT": str(runtime_root / "runtime" / "files"),
|
||||
"MOCK_MAILBOX_DIR": str(runtime_root / "runtime" / "mock-mailbox"),
|
||||
}
|
||||
for key, value in defaults.items():
|
||||
os.environ.setdefault(key, value)
|
||||
if default_pgtools_url:
|
||||
os.environ.setdefault("GOVOPLAN_DATABASE_URL_PGTOOLS", os.getenv("GOVOPLAN_DEV_DATABASE_URL_PGTOOLS", default_pgtools_url))
|
||||
return runtime_root
|
||||
|
||||
|
||||
@@ -95,7 +108,7 @@ def validate_sqlite_database_url(database_url: str) -> None:
|
||||
raise SystemExit(
|
||||
f"DATABASE_URL points to {db_path}, but its parent directory does not exist. "
|
||||
"Unset stale DATABASE_URL or set it to "
|
||||
f"sqlite:///{Path.cwd().resolve() / 'runtime' / 'multimailer-dev.db'}."
|
||||
f"sqlite:///{Path.cwd().resolve() / 'runtime' / DEFAULT_SQLITE_DATABASE_NAME}."
|
||||
)
|
||||
|
||||
|
||||
@@ -285,6 +298,10 @@ def print_devserver_summary(state: DevserverState, *, app: str, no_reload: bool)
|
||||
print(f"Runtime root: {state.runtime_root}")
|
||||
if state.database_url:
|
||||
print(f"Database: {state.database_url}")
|
||||
if state.database_url.startswith("postgresql"):
|
||||
pgtools_url = os.getenv("GOVOPLAN_DATABASE_URL_PGTOOLS")
|
||||
if pgtools_url:
|
||||
print(f"PostgreSQL tools URL: {pgtools_url}")
|
||||
if state.bootstrap_db_path is not None:
|
||||
bootstrap_state = "enabled" if getattr(state.config.settings, "dev_bootstrap_enabled", False) else "disabled by DEV_BOOTSTRAP_ENABLED"
|
||||
print(f"Dev bootstrap for missing SQLite DB: {bootstrap_state} ({state.bootstrap_db_path})")
|
||||
|
||||
152
src/govoplan_core/i18n.py
Normal file
152
src/govoplan_core/i18n.py
Normal file
@@ -0,0 +1,152 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Iterable
|
||||
|
||||
I18N_SETTINGS_KEY = "i18n"
|
||||
|
||||
DEFAULT_LANGUAGE_PACKAGES: tuple[dict[str, str], ...] = (
|
||||
{"code": "en", "label": "English", "native_label": "English"},
|
||||
{"code": "de", "label": "German", "native_label": "Deutsch"},
|
||||
)
|
||||
|
||||
|
||||
def normalize_language_code(value: object) -> str:
|
||||
raw = str(value or "").strip().lower().replace("_", "-")
|
||||
parts = [part for part in re.split(r"[^a-z0-9]+", raw) if part]
|
||||
return "-".join(parts)
|
||||
|
||||
|
||||
def language_code_candidates(value: object) -> tuple[str, ...]:
|
||||
code = normalize_language_code(value)
|
||||
if not code:
|
||||
return ()
|
||||
primary = code.split("-", 1)[0]
|
||||
return tuple(dict.fromkeys((code, primary)))
|
||||
|
||||
|
||||
def resolve_language_code(value: object, allowed_codes: Iterable[str]) -> str:
|
||||
allowed = {normalize_language_code(code) for code in allowed_codes if normalize_language_code(code)}
|
||||
for candidate in language_code_candidates(value):
|
||||
if candidate in allowed:
|
||||
return candidate
|
||||
return ""
|
||||
|
||||
|
||||
def i18n_settings(settings: dict[str, Any] | None) -> dict[str, Any]:
|
||||
raw = (settings or {}).get(I18N_SETTINGS_KEY)
|
||||
return dict(raw) if isinstance(raw, dict) else {}
|
||||
|
||||
|
||||
def normalize_language_packages(raw_packages: object = None) -> list[dict[str, str]]:
|
||||
packages: dict[str, dict[str, str]] = {}
|
||||
for item in DEFAULT_LANGUAGE_PACKAGES:
|
||||
packages[item["code"]] = dict(item)
|
||||
|
||||
if isinstance(raw_packages, (list, tuple)):
|
||||
for raw in raw_packages:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
code = normalize_language_code(raw.get("code"))
|
||||
if not code:
|
||||
continue
|
||||
label = str(raw.get("label") or code.upper()).strip()
|
||||
native_label = str(raw.get("native_label") or raw.get("nativeLabel") or label).strip()
|
||||
packages[code] = {"code": code, "label": label, "native_label": native_label}
|
||||
|
||||
return list(packages.values())
|
||||
|
||||
|
||||
def system_language_packages(settings: dict[str, Any] | None) -> list[dict[str, str]]:
|
||||
return normalize_language_packages(i18n_settings(settings).get("available_languages"))
|
||||
|
||||
|
||||
def normalize_enabled_language_codes(
|
||||
codes: object,
|
||||
available_languages: Iterable[dict[str, Any]],
|
||||
*,
|
||||
default_locale: object = None,
|
||||
fallback_codes: Iterable[str] = ("en", "de"),
|
||||
) -> list[str]:
|
||||
available_codes = [normalize_language_code(item.get("code")) for item in available_languages if isinstance(item, dict)]
|
||||
available = {code for code in available_codes if code}
|
||||
requested: list[str] = []
|
||||
if isinstance(codes, (list, tuple, set)):
|
||||
requested = [normalize_language_code(item) for item in codes]
|
||||
if not requested:
|
||||
requested = [normalize_language_code(item) for item in fallback_codes]
|
||||
|
||||
enabled: list[str] = []
|
||||
for item in requested:
|
||||
code = resolve_language_code(item, available)
|
||||
if code and code not in enabled:
|
||||
enabled.append(code)
|
||||
|
||||
default_code = resolve_language_code(default_locale, available)
|
||||
if default_code and default_code not in enabled:
|
||||
enabled.insert(0, default_code)
|
||||
|
||||
if enabled:
|
||||
return enabled
|
||||
if "en" in available:
|
||||
return ["en"]
|
||||
return available_codes[:1]
|
||||
|
||||
|
||||
def system_enabled_language_codes(settings: dict[str, Any] | None, *, default_locale: object = None) -> list[str]:
|
||||
i18n = i18n_settings(settings)
|
||||
raw_enabled = i18n.get("enabled_language_codes", i18n.get("enabled_languages"))
|
||||
return normalize_enabled_language_codes(raw_enabled, system_language_packages(settings), default_locale=default_locale)
|
||||
|
||||
|
||||
def tenant_enabled_language_codes(
|
||||
tenant_settings: dict[str, Any] | None,
|
||||
system_enabled_codes: Iterable[str],
|
||||
*,
|
||||
default_locale: object = None,
|
||||
) -> list[str]:
|
||||
enabled = [normalize_language_code(code) for code in system_enabled_codes if normalize_language_code(code)]
|
||||
available = [{"code": code} for code in enabled]
|
||||
raw_enabled = i18n_settings(tenant_settings).get("enabled_language_codes")
|
||||
return normalize_enabled_language_codes(raw_enabled, available, default_locale=default_locale, fallback_codes=enabled)
|
||||
|
||||
|
||||
def user_enabled_language_codes(user_settings: dict[str, Any] | None, tenant_enabled_codes: Iterable[str]) -> list[str]:
|
||||
enabled = [normalize_language_code(code) for code in tenant_enabled_codes if normalize_language_code(code)]
|
||||
available = [{"code": code} for code in enabled]
|
||||
raw_enabled = i18n_settings(user_settings).get("enabled_language_codes")
|
||||
return normalize_enabled_language_codes(raw_enabled, available, fallback_codes=enabled)
|
||||
|
||||
|
||||
def preferred_language_code(user_settings: dict[str, Any] | None, allowed_codes: Iterable[str], *, default_locale: object = None) -> str:
|
||||
enabled = [normalize_language_code(code) for code in allowed_codes if normalize_language_code(code)]
|
||||
preferred = resolve_language_code(i18n_settings(user_settings).get("preferred_language"), enabled)
|
||||
if preferred:
|
||||
return preferred
|
||||
default = resolve_language_code(default_locale, enabled)
|
||||
if default:
|
||||
return default
|
||||
return enabled[0] if enabled else "en"
|
||||
|
||||
|
||||
def update_i18n_settings(settings: dict[str, Any] | None, **values: Any) -> dict[str, Any]:
|
||||
updated = dict(settings or {})
|
||||
current = i18n_settings(updated)
|
||||
current.update(values)
|
||||
updated[I18N_SETTINGS_KEY] = current
|
||||
return updated
|
||||
|
||||
|
||||
def system_i18n_payload(settings_item: Any | None) -> dict[str, object]:
|
||||
settings = getattr(settings_item, "settings", None)
|
||||
packages = system_language_packages(settings)
|
||||
available_codes = [item["code"] for item in packages]
|
||||
default_language = resolve_language_code(getattr(settings_item, "default_locale", None), available_codes) or "en"
|
||||
enabled = system_enabled_language_codes(settings, default_locale=default_language)
|
||||
if default_language not in enabled:
|
||||
default_language = enabled[0] if enabled else "en"
|
||||
return {
|
||||
"available_languages": packages,
|
||||
"enabled_languages": enabled,
|
||||
"default_language": default_language,
|
||||
}
|
||||
@@ -17,6 +17,7 @@ from govoplan_core.core.campaigns import (
|
||||
CampaignPolicyContextProvider,
|
||||
CampaignRetentionProvider,
|
||||
)
|
||||
from govoplan_core.core.policy import policy_source_step
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
from govoplan_access.backend.db.models import Group, User
|
||||
from govoplan_audit.backend.db.models import AuditLog
|
||||
@@ -339,13 +340,13 @@ def _retention_policy_source_fields(patch: dict[str, Any], *, baseline: bool = F
|
||||
|
||||
def _retention_policy_source_step(scope_type: str, label: str, scope_id: str | None, patch: dict[str, Any], *, baseline: bool = False) -> dict[str, Any]:
|
||||
source_policy = PrivacyRetentionPolicy.model_validate(patch).model_dump(mode="json") if baseline else dict(patch)
|
||||
return {
|
||||
"scope_type": scope_type,
|
||||
"scope_id": scope_id,
|
||||
"label": label,
|
||||
"applied_fields": _retention_policy_source_fields(patch, baseline=baseline),
|
||||
"policy": source_policy,
|
||||
}
|
||||
return policy_source_step(
|
||||
scope_type,
|
||||
label,
|
||||
scope_id,
|
||||
applied_fields=_retention_policy_source_fields(patch, baseline=baseline),
|
||||
policy=source_policy,
|
||||
)
|
||||
|
||||
|
||||
def effective_privacy_policy_sources(
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import base64
|
||||
import hashlib
|
||||
from functools import lru_cache
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
@@ -17,6 +18,21 @@ class SecretDecryptionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SecretProvider(Protocol):
|
||||
def store_secret(self, *, scope: str, name: str, value: str) -> str:
|
||||
...
|
||||
|
||||
def read_secret(self, secret_ref: str) -> str | None:
|
||||
...
|
||||
|
||||
def delete_secret(self, secret_ref: str) -> None:
|
||||
...
|
||||
|
||||
|
||||
def _normalize_fernet_key(value: str) -> bytes:
|
||||
candidate = value.strip().encode("utf-8")
|
||||
try:
|
||||
|
||||
@@ -9,6 +9,7 @@ from govoplan_core.server.config import GovoplanServerConfig, load_server_config
|
||||
from govoplan_core.server.fastapi import create_govoplan_app
|
||||
from govoplan_core.server.platform import create_platform_router
|
||||
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
||||
from govoplan_core.server.route_validation import validate_no_route_collisions
|
||||
|
||||
|
||||
def create_app(config: GovoplanServerConfig | str | None = None):
|
||||
@@ -19,7 +20,8 @@ def create_app(config: GovoplanServerConfig | str | None = None):
|
||||
|
||||
database_url = getattr(server_config.settings, "database_url", None) if server_config.settings is not None else None
|
||||
if database_url:
|
||||
configure_database(str(database_url))
|
||||
dispose_previous = getattr(server_config.settings, "app_env", None) == "test"
|
||||
configure_database(str(database_url), dispose_previous=dispose_previous)
|
||||
|
||||
raw_enabled_modules = load_startup_enabled_modules(server_config.enabled_modules)
|
||||
candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
|
||||
@@ -55,6 +57,8 @@ def create_app(config: GovoplanServerConfig | str | None = None):
|
||||
if contribution.should_include(server_config.settings, registry):
|
||||
api_router.include_router(contribution.router)
|
||||
|
||||
validate_no_route_collisions(api_router, owner="server startup routes")
|
||||
|
||||
app = create_govoplan_app(
|
||||
title=server_config.title,
|
||||
version=server_config.version,
|
||||
|
||||
123
src/govoplan_core/server/conditional_requests.py
Normal file
123
src/govoplan_core/server/conditional_requests.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
JSON_CACHE_CONTROL = "private, no-cache"
|
||||
JSON_ETAG_VARY_HEADERS = ("Authorization", "Cookie", "X-API-Key", "Accept-Language")
|
||||
|
||||
|
||||
async def conditional_json_get_middleware(
|
||||
request: Request,
|
||||
call_next: Callable[[Request], Awaitable[Response]],
|
||||
) -> Response:
|
||||
"""Attach ETags to JSON GET responses and return 304 when unchanged.
|
||||
|
||||
The middleware deliberately works after route handling. That keeps the
|
||||
contract platform-wide without requiring every module router to learn about
|
||||
conditional requests, while still limiting buffering to successful JSON GET
|
||||
responses.
|
||||
"""
|
||||
|
||||
response = await call_next(request)
|
||||
if not _eligible_for_conditional_json_get(request, response):
|
||||
return response
|
||||
|
||||
body = b"".join([chunk async for chunk in response.body_iterator])
|
||||
etag = response.headers.get("etag") or json_response_etag(body)
|
||||
headers = dict(response.headers)
|
||||
headers["etag"] = etag
|
||||
headers["cache-control"] = _conditional_cache_control(headers.get("cache-control"))
|
||||
headers["vary"] = _merge_vary(headers.get("vary"), JSON_ETAG_VARY_HEADERS)
|
||||
headers.pop("content-length", None)
|
||||
|
||||
if if_none_match_matches(request.headers.get("if-none-match"), etag):
|
||||
return Response(status_code=304, headers=_not_modified_headers(headers), background=response.background)
|
||||
|
||||
return Response(content=body, status_code=response.status_code, headers=headers, background=response.background)
|
||||
|
||||
|
||||
def json_response_etag(body: bytes) -> str:
|
||||
digest = hashlib.sha256(body).hexdigest()
|
||||
return f'W/"sha256-{digest}"'
|
||||
|
||||
|
||||
def if_none_match_matches(header_value: str | None, etag: str) -> bool:
|
||||
if not header_value:
|
||||
return False
|
||||
normalized_etag = _normalize_etag_for_weak_compare(etag)
|
||||
for raw_candidate in header_value.split(","):
|
||||
candidate = raw_candidate.strip()
|
||||
if candidate == "*":
|
||||
return True
|
||||
if _normalize_etag_for_weak_compare(candidate) == normalized_etag:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _eligible_for_conditional_json_get(request: Request, response: Response) -> bool:
|
||||
if request.method.upper() != "GET":
|
||||
return False
|
||||
if response.status_code != 200:
|
||||
return False
|
||||
if "set-cookie" in response.headers:
|
||||
return False
|
||||
if "content-encoding" in response.headers:
|
||||
return False
|
||||
if "content-disposition" in response.headers:
|
||||
return False
|
||||
if "no-store" in request.headers.get("cache-control", "").lower():
|
||||
return False
|
||||
if "no-store" in response.headers.get("cache-control", "").lower():
|
||||
return False
|
||||
return _is_json_content_type(response.headers.get("content-type"))
|
||||
|
||||
|
||||
def _is_json_content_type(value: str | None) -> bool:
|
||||
media_type = (value or "").split(";", 1)[0].strip().lower()
|
||||
return media_type == "application/json" or media_type.endswith("+json")
|
||||
|
||||
|
||||
def _conditional_cache_control(current: str | None) -> str:
|
||||
if not current:
|
||||
return JSON_CACHE_CONTROL
|
||||
directives = [item.strip() for item in current.split(",") if item.strip()]
|
||||
normalized = {item.split("=", 1)[0].strip().lower() for item in directives}
|
||||
if "private" not in normalized and "public" not in normalized:
|
||||
directives.append("private")
|
||||
if "no-cache" not in normalized:
|
||||
directives.append("no-cache")
|
||||
return ", ".join(directives)
|
||||
|
||||
|
||||
def _merge_vary(current: str | None, additions: tuple[str, ...]) -> str:
|
||||
tokens: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for value in [current or "", *additions]:
|
||||
for token in value.split(","):
|
||||
stripped = token.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
normalized = stripped.lower()
|
||||
if normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
tokens.append(stripped)
|
||||
return ", ".join(tokens)
|
||||
|
||||
|
||||
def _not_modified_headers(headers: dict[str, str]) -> dict[str, str]:
|
||||
allowed = {"cache-control", "content-location", "date", "etag", "expires", "vary", "x-correlation-id"}
|
||||
return {key: value for key, value in headers.items() if key.lower() in allowed}
|
||||
|
||||
|
||||
def _normalize_etag_for_weak_compare(value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if normalized.startswith("W/"):
|
||||
normalized = normalized[2:].strip()
|
||||
if len(normalized) >= 2 and normalized[0] == '"' and normalized[-1] == '"':
|
||||
normalized = normalized[1:-1]
|
||||
return normalized
|
||||
@@ -3,8 +3,9 @@ from __future__ import annotations
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
from sqlalchemy.engine import make_url
|
||||
|
||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, require_scope
|
||||
from govoplan_access.auth import ApiPrincipal, require_scope
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.db.bootstrap import bootstrap_dev_data, create_all_tables
|
||||
from govoplan_core.db.session import get_database
|
||||
@@ -12,6 +13,13 @@ from govoplan_core.server.config import GovoplanServerConfig
|
||||
from govoplan_core.settings import Settings, settings
|
||||
|
||||
|
||||
def _dev_bootstrap_needs_create_all(database_url: str) -> bool:
|
||||
try:
|
||||
return make_url(database_url).get_backend_name() == "sqlite"
|
||||
except Exception:
|
||||
return database_url.startswith("sqlite:")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
if settings.app_env.lower() == "dev" and settings.dev_auto_migrate_enabled:
|
||||
@@ -19,7 +27,8 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
migrate_database(database_url=settings.database_url)
|
||||
if settings.app_env.lower() == "dev" and settings.dev_bootstrap_enabled:
|
||||
create_all_tables()
|
||||
if _dev_bootstrap_needs_create_all(settings.database_url):
|
||||
create_all_tables()
|
||||
with get_database().SessionLocal() as session:
|
||||
bootstrap_dev_data(
|
||||
session,
|
||||
|
||||
@@ -4,10 +4,12 @@ from collections.abc import AsyncIterator, Callable, Iterable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi import APIRouter, FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from govoplan_core.core.events import event_context, new_event_id, normalize_trace_id
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.server.conditional_requests import conditional_json_get_middleware
|
||||
|
||||
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
|
||||
|
||||
@@ -25,6 +27,20 @@ def create_govoplan_app(
|
||||
app = FastAPI(title=title, version=version, lifespan=lifespan)
|
||||
app.state.govoplan_registry = registry
|
||||
|
||||
@app.middleware("http")
|
||||
async def request_correlation_context(request: Request, call_next):
|
||||
correlation_id = (
|
||||
normalize_trace_id(request.headers.get("x-correlation-id"))
|
||||
or normalize_trace_id(request.headers.get("x-request-id"))
|
||||
or new_event_id()
|
||||
)
|
||||
with event_context(correlation_id=correlation_id):
|
||||
response = await call_next(request)
|
||||
response.headers["X-Correlation-ID"] = correlation_id
|
||||
return response
|
||||
|
||||
app.middleware("http")(conditional_json_get_middleware)
|
||||
|
||||
origins = [item.strip() for item in cors_origins if item.strip()]
|
||||
if origins:
|
||||
app.add_middleware(
|
||||
|
||||
@@ -3,10 +3,13 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_core.i18n import system_i18n_payload
|
||||
|
||||
|
||||
def _registry(request: Request) -> PlatformRegistry:
|
||||
@@ -72,10 +75,13 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
||||
try:
|
||||
with get_database().session() as session:
|
||||
maintenance_mode = saved_maintenance_mode(session)
|
||||
settings_item = session.get(SystemSettings, SYSTEM_SETTINGS_ID)
|
||||
except (RuntimeError, SQLAlchemyError):
|
||||
maintenance_mode = None
|
||||
settings_item = None
|
||||
return {
|
||||
"maintenance_mode": maintenance_mode.as_dict() if maintenance_mode is not None else {"enabled": False, "message": None},
|
||||
"i18n": system_i18n_payload(settings_item),
|
||||
}
|
||||
|
||||
@router.get("/modules")
|
||||
|
||||
73
src/govoplan_core/server/route_validation.py
Normal file
73
src/govoplan_core/server/route_validation.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class RouteCollisionError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RouteSignature:
|
||||
method: str
|
||||
path: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RouteRecord:
|
||||
signature: RouteSignature
|
||||
owner: str
|
||||
|
||||
|
||||
def validate_no_route_collisions(routes_or_app: object, *, owner: str = "router") -> None:
|
||||
seen: dict[RouteSignature, RouteRecord] = {}
|
||||
for record in iter_route_records(routes_or_app, owner=owner):
|
||||
previous = seen.get(record.signature)
|
||||
if previous is not None:
|
||||
raise RouteCollisionError(_collision_message(record.signature, previous.owner, record.owner))
|
||||
seen[record.signature] = record
|
||||
|
||||
|
||||
def validate_router_can_mount(existing_routes_or_app: object, candidate_router: object, *, prefix: str = "", owner: str) -> None:
|
||||
validate_no_route_collisions(candidate_router, owner=owner)
|
||||
existing = {record.signature: record for record in iter_route_records(existing_routes_or_app, owner="existing application")}
|
||||
for record in iter_route_records(candidate_router, prefix=prefix, owner=owner):
|
||||
previous = existing.get(record.signature)
|
||||
if previous is not None:
|
||||
raise RouteCollisionError(_collision_message(record.signature, previous.owner, record.owner))
|
||||
|
||||
|
||||
def iter_route_records(routes_or_app: object, *, prefix: str = "", owner: str = "router") -> Iterable[RouteRecord]:
|
||||
routes = getattr(routes_or_app, "routes", routes_or_app)
|
||||
for route in routes or ():
|
||||
path = _join_route_path(prefix, str(getattr(route, "path", "") or ""))
|
||||
include_context = getattr(route, "include_context", None)
|
||||
nested_prefix = _join_route_path(prefix, str(getattr(include_context, "prefix", "") or ""))
|
||||
nested_router = getattr(route, "original_router", None)
|
||||
if nested_router is not None:
|
||||
yield from iter_route_records(nested_router, prefix=nested_prefix, owner=owner)
|
||||
|
||||
methods = getattr(route, "methods", None)
|
||||
if methods:
|
||||
for method in sorted(str(item).upper() for item in methods):
|
||||
yield RouteRecord(RouteSignature(method=method, path=path), owner)
|
||||
|
||||
nested_routes = getattr(route, "routes", None)
|
||||
if nested_routes is not None and nested_routes is not routes:
|
||||
yield from iter_route_records(nested_routes, prefix=path, owner=owner)
|
||||
|
||||
|
||||
def _join_route_path(prefix: str, path: str) -> str:
|
||||
if not prefix:
|
||||
return path or "/"
|
||||
if not path:
|
||||
return prefix or "/"
|
||||
return f"{prefix.rstrip('/')}/{path.lstrip('/')}" or "/"
|
||||
|
||||
|
||||
def _collision_message(signature: RouteSignature, previous_owner: str, next_owner: str) -> str:
|
||||
return (
|
||||
f"Route collision: {signature.method} {signature.path} is registered by "
|
||||
f"{previous_owner} and {next_owner}"
|
||||
)
|
||||
@@ -8,7 +8,7 @@ class Settings(BaseSettings):
|
||||
app_env: str = Field(default="dev", alias="APP_ENV")
|
||||
|
||||
database_url: str = Field(
|
||||
default="sqlite:///./runtime/multimailer-dev.db",
|
||||
default="postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev",
|
||||
alias="DATABASE_URL",
|
||||
)
|
||||
access_database_url: str | None = Field(default=None, alias="ACCESS_DATABASE_URL")
|
||||
@@ -17,7 +17,7 @@ class Settings(BaseSettings):
|
||||
tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE")
|
||||
tenant_db_url_template: str | None = Field(default=None, alias="TENANT_DB_URL_TEMPLATE")
|
||||
tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE")
|
||||
enabled_modules: str = Field(default="tenancy,access,admin,policy,audit,campaigns,files,mail,calendar", alias="ENABLED_MODULES")
|
||||
enabled_modules: str = Field(default="tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops", alias="ENABLED_MODULES")
|
||||
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
|
||||
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user