chore: consolidate platform split checks
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user