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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
@@ -10,7 +9,6 @@ ORGANIZATIONS_MODULE_ID = "organizations"
CAPABILITY_ORGANIZATION_DIRECTORY = "organizations.directory"
OrganizationStatus = Literal["active", "inactive", "suspended"]
FunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
@dataclass(frozen=True, slots=True)
@@ -39,23 +37,6 @@ class OrganizationFunctionRef:
status: OrganizationStatus = "active"
@dataclass(frozen=True, slots=True)
class OrganizationFunctionAssignmentRef:
id: str
tenant_id: str
identity_id: str
function_id: str
organization_unit_id: str
account_id: str | None = None
applies_to_subunits: bool = False
source: FunctionAssignmentSource = "direct"
delegated_from_assignment_id: str | None = None
acting_for_account_id: str | None = None
valid_from: datetime | None = None
valid_until: datetime | None = None
status: OrganizationStatus = "active"
@runtime_checkable
class OrganizationDirectory(Protocol):
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
@@ -74,22 +55,3 @@ class OrganizationDirectory(Protocol):
include_subunits: bool = False,
) -> Sequence[OrganizationFunctionRef]:
...
def get_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
...
def function_assignments_for_identity(
self,
identity_id: str,
*,
tenant_id: str | None = None,
) -> Sequence[OrganizationFunctionAssignmentRef]:
...
def function_assignments_for_account(
self,
account_id: str,
*,
tenant_id: str | None = None,
) -> Sequence[OrganizationFunctionAssignmentRef]:
...

View File

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

View File

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

View File

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

View File

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