intermittent commit

This commit is contained in:
2026-07-14 13:22:10 +02:00
parent 8aa1943581
commit e6f7c45f0a
76 changed files with 6039 additions and 2188 deletions

View File

@@ -8,8 +8,6 @@ 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,
@@ -23,6 +21,7 @@ from govoplan_core.core.module_package_catalog import (
_load_private_key,
_parse_trusted_keys,
)
from govoplan_core.security.http_fetch import fetch_http_text
CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider"
@@ -31,6 +30,20 @@ DiagnosticSeverity = Literal["blocker", "warning", "info"]
PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"]
@dataclass(frozen=True, slots=True)
class _ConfigurationCatalogValidationState:
packages: tuple[dict[str, object], ...]
channel: str | None
sequence: int | None
generated_at: str | None
not_before: str | None
expires_at: str | None
signature_state: dict[str, object]
freshness: dict[str, object]
replay: dict[str, object]
read_state: dict[str, object]
@dataclass(frozen=True, slots=True)
class ConfigurationModuleRequirement:
module_id: str
@@ -461,55 +474,148 @@ def validate_configuration_package_catalog(
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}
read_state = _configuration_catalog_default_read_state()
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)
state = _configuration_catalog_validation_state(source, trusted_keys=effective_trusted_keys)
read_state = state.read_state
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."))
policy_error = _configuration_catalog_policy_error(
source,
configured=configured,
state=state,
require_trusted=effective_require_trusted,
approved_channels=effective_approved_channels,
)
if policy_error is not None:
return policy_error
return _validation_result(
source,
valid=True,
configured=configured,
read_state=read_state,
packages=packages,
read_state=state.read_state,
packages=state.packages,
channel=state.channel,
sequence=state.sequence,
generated_at=state.generated_at,
not_before=state.not_before,
expires_at=state.expires_at,
signature_state=state.signature_state,
warnings=_configuration_catalog_warnings(state),
)
def _configuration_catalog_validation_state(
source: Path | str | None,
*,
trusted_keys: dict[str, str],
) -> _ConfigurationCatalogValidationState:
payload, read_state = _read_catalog_payload_with_metadata(source)
channel = _catalog_channel(payload)
sequence = _catalog_sequence(payload)
return _ConfigurationCatalogValidationState(
packages=_normalize_catalog_packages(payload),
channel=channel,
sequence=sequence,
generated_at=generated_at,
not_before=not_before,
expires_at=expires_at,
signature_state=signature_state,
warnings=warnings,
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=trusted_keys),
freshness=_catalog_freshness_state(payload),
replay=_catalog_replay_state(channel=channel, sequence=sequence),
read_state=read_state,
)
def _configuration_catalog_policy_error(
source: Path | str | None,
*,
configured: bool,
state: _ConfigurationCatalogValidationState,
require_trusted: bool,
approved_channels: tuple[str, ...],
) -> dict[str, object] | None:
if state.signature_state.get("fatal"):
return _configuration_catalog_invalid_result(
source,
configured=configured,
state=state,
error=str(state.signature_state["error"]),
)
if approved_channels and state.channel not in approved_channels:
return _configuration_catalog_invalid_result(
source,
configured=configured,
state=state,
error=(
f"Configuration package catalog channel {state.channel!r} is not approved. "
f"Approved channels: {', '.join(approved_channels)}."
),
)
if require_trusted and not state.signature_state["trusted"]:
return _configuration_catalog_invalid_result(
source,
configured=configured,
state=state,
error=str(state.signature_state["error"] or "Configuration package catalog must be signed by a trusted key."),
)
if not state.freshness["valid"]:
return _configuration_catalog_invalid_result(
source,
configured=configured,
state=state,
error=str(state.freshness["error"]),
)
if not state.replay["valid"]:
return _configuration_catalog_invalid_result(
source,
configured=configured,
state=state,
error=str(state.replay["error"]),
)
return None
def _configuration_catalog_invalid_result(
source: Path | str | None,
*,
configured: bool,
state: _ConfigurationCatalogValidationState,
error: str,
) -> dict[str, object]:
return _validation_result(
source,
configured=configured,
read_state=state.read_state,
packages=(),
channel=state.channel,
sequence=state.sequence,
generated_at=state.generated_at,
not_before=state.not_before,
expires_at=state.expires_at,
signature_state=state.signature_state,
error=error,
)
def _configuration_catalog_warnings(state: _ConfigurationCatalogValidationState) -> list[str]:
warnings = [str(item) for item in state.freshness.get("warnings", ()) if item]
warnings.extend(str(item) for item in state.replay.get("warnings", ()) if item)
if not state.signature_state["signed"]:
warnings.append("Configuration package catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
elif not state.signature_state["trusted"]:
warnings.append(str(state.signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
return warnings
def _configuration_catalog_default_read_state() -> dict[str, object]:
cache_path = _configured_catalog_cache_path()
return {"cache_used": False, "cache_path": str(cache_path) if cache_path else None}
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):
@@ -686,17 +792,14 @@ def _configured_trusted_keys_cache_path() -> Path | 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: # noqa: S310 - validated configuration key HTTP(S) URL. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
body = response.read().decode("utf-8")
body = fetch_http_text(url, timeout=15, label="Trusted configuration catalog key URL")
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):
except OSError:
if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8")
raise
@@ -714,13 +817,12 @@ def _read_catalog_payload_with_metadata(source: Path | str | None) -> tuple[obje
return {"packages": []}, metadata
if isinstance(source, str) and _is_http_url(source):
try:
with urllib.request.urlopen(source, timeout=15) as response: # noqa: S310 - validated configuration catalog HTTP(S) URL. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
body = response.read().decode("utf-8")
body = fetch_http_text(source, timeout=15, label="Configuration package catalog URL")
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):
except OSError:
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

View File

@@ -97,6 +97,16 @@ class ConfigurationChangeSafetyPlan:
}
@dataclass(frozen=True, slots=True)
class _ConfigurationChangeSafetyState:
field: ConfigurationFieldSafety
missing_scopes: tuple[str, ...]
blockers: tuple[str, ...]
warnings: tuple[str, ...]
approval_required: bool
approval_satisfied: bool
_CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
ConfigurationFieldSafety(
key="module_management.desired_enabled",
@@ -339,23 +349,56 @@ def plan_configuration_change(
) -> 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.",
)
return _unknown_configuration_plan(key)
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.",
)
return _deployment_managed_configuration_plan(key, field)
state = _configuration_change_safety_state(
field,
actor_scopes=actor_scopes,
value=value,
dry_run=dry_run,
maintenance_mode=maintenance_mode,
approval_count=approval_count,
)
return _configuration_change_safety_plan(
field,
state=state,
dry_run=dry_run,
maintenance_mode=maintenance_mode,
)
def _unknown_configuration_plan(key: str) -> ConfigurationChangeSafetyPlan:
return ConfigurationChangeSafetyPlan(
key=key,
allowed=False,
field=None,
blockers=("unknown_configuration_field",),
policy_explanation="This setting is not in the configuration safety catalog.",
)
def _deployment_managed_configuration_plan(key: str, field: ConfigurationFieldSafety) -> ConfigurationChangeSafetyPlan:
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.",
)
def _configuration_change_safety_state(
field: ConfigurationFieldSafety,
*,
actor_scopes: tuple[str, ...] | list[str],
value: object,
dry_run: bool,
maintenance_mode: bool,
approval_count: int,
) -> _ConfigurationChangeSafetyState:
blockers: list[str] = []
warnings: list[str] = []
missing_scopes = tuple(scope for scope in field.required_scopes if not scopes_grant(actor_scopes, scope))
@@ -377,24 +420,41 @@ def plan_configuration_change(
blockers.append("env_only_secret")
if field.rollback_history_required:
warnings.append("rollback_history_required")
return ConfigurationChangeSafetyPlan(
key=field.key,
allowed=not blockers,
return _ConfigurationChangeSafetyState(
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,
blockers=tuple(dict.fromkeys(blockers)),
warnings=tuple(dict.fromkeys(warnings)),
approval_required=approval_required,
approval_satisfied=approval_satisfied,
)
def _configuration_change_safety_plan(
field: ConfigurationFieldSafety,
*,
state: _ConfigurationChangeSafetyState,
dry_run: bool,
maintenance_mode: bool,
) -> ConfigurationChangeSafetyPlan:
return ConfigurationChangeSafetyPlan(
key=field.key,
allowed=not state.blockers,
field=field,
risk=field.risk,
missing_scopes=state.missing_scopes,
dry_run_required=field.dry_run_required,
dry_run_satisfied=not field.dry_run_required or dry_run,
approval_required=state.approval_required,
approval_satisfied=state.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)),
blockers=state.blockers,
warnings=state.warnings,
)

View File

@@ -72,6 +72,25 @@ class ConfigValidationResult:
return "\n".join(lines)
@dataclass(frozen=True, slots=True)
class _RuntimeProfile:
name: str
production: bool
production_like: bool
local: bool
@dataclass(slots=True)
class _ConfigIssueCollector:
strict: bool
issues: list[ConfigIssue]
def add(self, level: ConfigIssueLevel, key: str, message: str, action: str) -> None:
if self.strict and level == "warning":
level = "error"
self.issues.append(ConfigIssue(level=level, key=key, message=message, action=action))
_LOCAL_PROFILES = {"dev", "local", "local-dev", "test"}
_PRODUCTION_PROFILES = {"prod", "production", "self-hosted"}
_PRODUCTION_LIKE_PROFILES = {"staging", "production-like", "production-like-dev"}
@@ -114,95 +133,130 @@ def validate_runtime_configuration(
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] = []
runtime = _runtime_profile(env, profile=profile)
collector = _ConfigIssueCollector(strict=strict, issues=[])
_validate_app_env(env, runtime, collector)
_validate_database_settings(env, runtime, collector)
_validate_master_key(env, runtime, collector)
_validate_enabled_modules(env, runtime, collector)
_validate_async_and_auth_settings(env, runtime, collector)
_validate_cors_settings(env, runtime, collector)
_validate_file_storage_settings(env, runtime, collector)
_validate_module_catalog_trust(env, runtime, collector)
return ConfigValidationResult(profile=runtime.name, issues=tuple(collector.issues))
def _runtime_profile(env: Mapping[str, str], *, profile: str | None) -> _RuntimeProfile:
clean_profile = normalize_install_profile(profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV"))
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
return _RuntimeProfile(
name=clean_profile,
production=production,
production_like=production_like,
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))
def _validate_app_env(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
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.")
if not app_env and runtime.production_like:
collector.add("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 runtime.production_like:
collector.add("error", "APP_ENV", f"APP_ENV={app_env!r} is not valid for profile {runtime.name!r}.", "Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.")
def _validate_database_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
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.")
collector.add("error", "DATABASE_URL", "DATABASE_URL is missing.", "Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.")
return
backend = _database_backend(database_url)
if backend is None:
collector.add("error", "DATABASE_URL", "DATABASE_URL is not a valid SQLAlchemy URL.", "Use a value like postgresql+psycopg://user:password@host:5432/database.")
return
if backend == "sqlite" and runtime.production_like:
collector.add("error", "DATABASE_URL", "SQLite is only supported for disposable local development.", "Use PostgreSQL for production-like and self-hosted installs.")
elif backend != "postgresql" and runtime.production:
collector.add("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")):
collector.add("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.")
def _validate_master_key(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
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.")
if not master_key and not runtime.local:
collector.add("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.")
return
if not master_key:
return
error = _master_key_error(master_key)
if error:
collector.add("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():
collector.add("error", "MASTER_KEY_B64", "MASTER_KEY_B64 still looks like a placeholder.", "Generate a real deployment key and store it outside git.")
def _validate_enabled_modules(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
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.")
if not enabled_modules and runtime.production_like:
collector.add("error", "ENABLED_MODULES", "ENABLED_MODULES is missing.", "Set ENABLED_MODULES explicitly so startup module composition is intentional.")
elif "access" not in enabled_modules and runtime.production_like:
collector.add("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 runtime.production_like:
collector.add("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.")
def _validate_async_and_auth_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
if _truthy(env.get("CELERY_ENABLED")) and not _clean(env.get("REDIS_URL")):
collector.add("error", "REDIS_URL", "CELERY_ENABLED=true but REDIS_URL is missing.", "Set REDIS_URL to the Redis broker/result backend used by workers.")
if runtime.production and _truthy(env.get("DEV_BOOTSTRAP_ENABLED")):
collector.add("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 runtime.production and not _truthy(env.get("AUTH_COOKIE_SECURE")):
collector.add("error", "AUTH_COOKIE_SECURE", "Secure auth cookies are disabled for production.", "Set AUTH_COOKIE_SECURE=true behind HTTPS.")
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.")
def _validate_cors_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
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.")
if runtime.production_like and not cors_origins:
collector.add("error", "CORS_ORIGINS", "CORS_ORIGINS is missing.", "Set CORS_ORIGINS to the exact WebUI origin or origins.")
elif "*" in cors_origins and runtime.production_like:
collector.add("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.")
elif runtime.production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
collector.add("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.")
def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
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.")
_validate_local_file_storage(env, runtime, collector)
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.")
_validate_s3_file_storage(env, collector)
else:
issue("error", "FILE_STORAGE_BACKEND", f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.", "Use `local` or `s3`.")
collector.add("error", "FILE_STORAGE_BACKEND", f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.", "Use `local` or `s3`.")
def _validate_local_file_storage(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
if not _clean(env.get("FILE_STORAGE_LOCAL_ROOT")) and runtime.production_like:
collector.add("error", "FILE_STORAGE_LOCAL_ROOT", "Local file storage root is missing.", "Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.")
elif runtime.production:
collector.add("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.")
def _validate_s3_file_storage(env: Mapping[str, str], collector: _ConfigIssueCollector) -> None:
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)):
collector.add("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
def _validate_module_catalog_trust(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
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))
if not runtime.production or not catalog_source:
return
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE")):
collector.add("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")):
collector.add("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.")
def _self_hosted_env_template(master_key: str) -> str:
@@ -299,8 +353,8 @@ def _master_key_error(value: str) -> str | None:
try:
Fernet(candidate)
return None
except Exception:
pass
except (TypeError, ValueError):
raw = None
try:
raw = base64.b64decode(candidate)
except Exception:
@@ -309,6 +363,6 @@ def _master_key_error(value: str) -> str | None:
return "MASTER_KEY_B64 must decode to exactly 32 bytes."
try:
Fernet(base64.urlsafe_b64encode(raw))
except Exception:
except (TypeError, ValueError):
return "MASTER_KEY_B64 is not usable as a Fernet key."
return None

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,125 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
INSTALLER_NOTIFICATION_ACTION_URL = "/admin?section=modules"
def emit_module_installer_notification(
*,
session: object,
registry: object | None,
tenant_id: str | None,
request: Mapping[str, object],
event_kind: str,
subject: str,
body_text: str,
recipient_id: str | None = None,
priority: int = 5,
) -> bool:
if not tenant_id:
return False
provider = notification_dispatch_provider(registry)
if provider is None:
return False
request_id = str(request.get("request_id") or "")
if not request_id:
return False
provider.enqueue_notification(
session,
NotificationDispatchRequest(
tenant_id=tenant_id,
source_module="core",
source_resource_type="module_install_request",
source_resource_id=request_id,
event_kind=event_kind,
channel="inbox",
recipient_type="user" if recipient_id else None,
recipient_id=recipient_id,
subject=subject,
body_text=body_text,
action_url=INSTALLER_NOTIFICATION_ACTION_URL,
priority=priority,
payload={
"request_id": request_id,
"status": request.get("status"),
"run_id": _result_run_id(request),
},
metadata={
"trace": request.get("trace") if isinstance(request.get("trace"), Mapping) else None,
"retry_of": request.get("retry_of"),
},
),
enqueue_delivery=False,
)
return True
def build_runtime_notification_registry(settings: object) -> object | None:
try:
raw_enabled_modules = load_startup_enabled_modules(str(getattr(settings, "enabled_modules", "") or ""))
candidate_modules = startup_candidate_module_ids(str(getattr(settings, "enabled_modules", "") or ""), raw_enabled_modules)
available_modules = available_module_manifests(enabled_modules=candidate_modules, ignore_load_errors=True)
enabled_modules = load_startup_enabled_modules(str(getattr(settings, "enabled_modules", "") or ""), available=available_modules)
if "notifications" not in enabled_modules:
return None
registry = build_platform_registry(enabled_modules)
registry.configure_capability_context(ModuleContext(registry=registry, settings=settings))
return registry
except Exception: # noqa: BLE001 - notification bridge must not block installer work.
return None
def installer_notification_subject(event_kind: str, request: Mapping[str, object]) -> str:
request_id = str(request.get("request_id") or "unknown")
status = str(request.get("status") or event_kind.rsplit(".", 1)[-1])
prefixes = {
"queued": "Module installer request queued",
"running": "Module installer request started",
"completed": "Module installer request completed",
"failed": "Module installer request failed",
"cancelled": "Module installer request cancelled",
}
prefix = prefixes.get(status, "Module installer request updated")
return ": ".join((prefix, request_id))
def _installer_status_sentence(request_id: str, status: str) -> str:
return " ".join(("Installer request", request_id, "is", status))
def installer_notification_body(event_kind: str, request: Mapping[str, object]) -> str:
status = str(request.get("status") or event_kind.rsplit(".", 1)[-1])
request_id = str(request.get("request_id") or "unknown")
result = request.get("result") if isinstance(request.get("result"), Mapping) else {}
error = str(request.get("error") or result.get("error") or "").strip()
sentence = _installer_status_sentence(request_id, status)
if error:
return ". Error: ".join((sentence, error))
run_id = _result_run_id(request)
if run_id:
return ". Run: ".join((sentence, run_id))
return sentence + "."
def installer_notification_priority(status: str) -> int:
if status == "failed":
return 20
if status in {"completed", "cancelled"}:
return 8
if status == "running":
return 4
return 5
def _result_run_id(request: Mapping[str, object]) -> str | None:
result = request.get("result") if isinstance(request.get("result"), Mapping) else {}
run_id: Any = result.get("run_id") if isinstance(result, Mapping) else None
return str(run_id) if run_id else None

View File

@@ -7,13 +7,11 @@ import json
import os
from pathlib import Path
from typing import Any
import urllib.error
import urllib.parse
import urllib.request
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from govoplan_core.security.http_fetch import fetch_http_text
def module_license_decision(required_features: list[str] | tuple[str, ...]) -> dict[str, object]:
@@ -258,18 +256,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
def _read_trusted_keys_url(url: str) -> str:
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username or parsed.password:
raise ValueError("Trusted license key URL must be an absolute HTTP(S) URL without embedded credentials.")
cache_path = _configured_trusted_keys_cache_path()
try:
with urllib.request.urlopen(url, timeout=15) as response: # noqa: S310 - validated license key HTTP(S) URL. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
body = response.read().decode("utf-8")
body = fetch_http_text(url, timeout=15, label="Trusted license key URL")
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):
except OSError:
if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8")
raise

View File

@@ -73,6 +73,23 @@ class ModuleInstallPlanItem:
return payload
@dataclass(frozen=True, slots=True)
class _NormalizedModuleInstallPlanItem:
module_id: str
action: str
source: str
catalog: Mapping[str, object] | None
status: str
python_package: str | None
python_ref: str | None
webui_package: str | None
webui_ref: str | None
artifact_integrity: Mapping[str, object] | None
data_safety_acknowledged: bool
destroy_data: bool
notes: str | None
@dataclass(frozen=True, slots=True)
class ModuleInstallPlan:
items: tuple[ModuleInstallPlanItem, ...] = ()
@@ -291,6 +308,28 @@ def desired_modules_after_package_plan(
def normalize_module_install_plan_item(
item: Mapping[str, object] | ModuleInstallPlanItem,
) -> ModuleInstallPlanItem:
normalized = _normalized_module_install_plan_item(item)
_validate_module_install_plan_item(normalized)
return ModuleInstallPlanItem(
module_id=normalized.module_id,
action=normalized.action,
source=normalized.source,
catalog=normalized.catalog,
python_package=normalized.python_package,
python_ref=normalized.python_ref,
webui_package=normalized.webui_package,
webui_ref=normalized.webui_ref,
artifact_integrity=normalized.artifact_integrity,
data_safety_acknowledged=normalized.data_safety_acknowledged,
destroy_data=normalized.destroy_data,
status=normalized.status,
notes=normalized.notes,
)
def _normalized_module_install_plan_item(
item: Mapping[str, object] | ModuleInstallPlanItem,
) -> _NormalizedModuleInstallPlanItem:
if isinstance(item, ModuleInstallPlanItem):
raw = item.as_dict()
elif isinstance(item, Mapping):
@@ -311,33 +350,12 @@ def normalize_module_install_plan_item(
data_safety_acknowledged = _clean_bool(raw.get("data_safety_acknowledged"))
destroy_data = _clean_bool(raw.get("destroy_data"))
notes = _clean_optional_string(raw.get("notes"))
if action not in INSTALL_PLAN_ACTIONS:
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 in {"install", "update"} 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:
raise ModuleManagementError(f"Uninstall plan item {module_id!r} needs a Python package name.")
if action != "uninstall" and destroy_data:
raise ModuleManagementError(f"Install plan item {module_id!r} can only destroy data during uninstall.")
if action in {"install", "update"} and bool(webui_package) != bool(webui_ref):
raise ModuleManagementError(f"Install plan item {module_id!r} needs both WebUI package and WebUI reference, or neither.")
if action == "uninstall" and webui_ref and not webui_package:
raise ModuleManagementError(f"Uninstall plan item {module_id!r} has a WebUI reference but no WebUI package.")
if python_ref:
_validate_dependency_ref(python_ref, field="python_ref", module_id=module_id)
if webui_ref:
_validate_dependency_ref(webui_ref, field="webui_ref", module_id=module_id)
return ModuleInstallPlanItem(
return _NormalizedModuleInstallPlanItem(
module_id=module_id,
action=action,
source=source,
catalog=catalog,
status=status,
python_package=python_package,
python_ref=python_ref,
webui_package=webui_package,
@@ -345,11 +363,41 @@ def normalize_module_install_plan_item(
artifact_integrity=artifact_integrity,
data_safety_acknowledged=data_safety_acknowledged,
destroy_data=destroy_data,
status=status,
notes=notes,
)
def _validate_module_install_plan_item(item: _NormalizedModuleInstallPlanItem) -> None:
if item.action not in INSTALL_PLAN_ACTIONS:
raise ModuleManagementError(f"Unsupported install plan action for {item.module_id!r}: {item.action!r}.")
if item.status not in INSTALL_PLAN_STATUSES:
raise ModuleManagementError(f"Unsupported install plan status for {item.module_id!r}: {item.status!r}.")
if item.source not in INSTALL_PLAN_SOURCES:
raise ModuleManagementError(f"Unsupported install plan source for {item.module_id!r}: {item.source!r}.")
_validate_module_install_plan_item_requirements(item)
_validate_module_install_plan_item_refs(item)
def _validate_module_install_plan_item_requirements(item: _NormalizedModuleInstallPlanItem) -> None:
if item.action in {"install", "update"} and not item.python_ref:
raise ModuleManagementError(f"Install plan item {item.module_id!r} needs a Python package reference.")
if item.action == "uninstall" and not item.python_package:
raise ModuleManagementError(f"Uninstall plan item {item.module_id!r} needs a Python package name.")
if item.action != "uninstall" and item.destroy_data:
raise ModuleManagementError(f"Install plan item {item.module_id!r} can only destroy data during uninstall.")
if item.action in {"install", "update"} and bool(item.webui_package) != bool(item.webui_ref):
raise ModuleManagementError(f"Install plan item {item.module_id!r} needs both WebUI package and WebUI reference, or neither.")
if item.action == "uninstall" and item.webui_ref and not item.webui_package:
raise ModuleManagementError(f"Uninstall plan item {item.module_id!r} has a WebUI reference but no WebUI package.")
def _validate_module_install_plan_item_refs(item: _NormalizedModuleInstallPlanItem) -> None:
if item.python_ref:
_validate_dependency_ref(item.python_ref, field="python_ref", module_id=item.module_id)
if item.webui_ref:
_validate_dependency_ref(item.webui_ref, field="webui_ref", module_id=item.module_id)
def plan_desired_enabled_modules(
requested_enabled: Iterable[str],
available: Mapping[str, ModuleManifest],

View File

@@ -3,20 +3,20 @@ from __future__ import annotations
import base64
import binascii
from collections import defaultdict
from dataclasses import dataclass
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
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
from govoplan_core.security.http_fetch import fetch_http_text, is_http_url
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
CATALOG_MIGRATION_SAFETY = ("automatic", "requires_review", "forward_only", "destructive")
@@ -28,6 +28,20 @@ CATALOG_MIGRATION_TASK_PHASES = (
)
@dataclass(frozen=True, slots=True)
class _CatalogValidationState:
modules: tuple[dict[str, object], ...]
channel: str | None
sequence: int | None
generated_at: str | None
not_before: str | None
expires_at: str | None
signature_state: dict[str, object]
freshness: dict[str, object]
replay: dict[str, object]
read_state: dict[str, object]
def module_package_catalog(
path: Path | str | None = None,
*,
@@ -61,178 +75,143 @@ def validate_module_package_catalog(
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()
if catalog_source is not None and not _catalog_source_exists(catalog_source):
return {
"valid": False,
"configured": True,
"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,
"key_id": None,
"warnings": [],
"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}
return _catalog_error_result(catalog_source, error=f"Module package catalog does not exist: {catalog_source}")
try:
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)
replay = _catalog_replay_state(channel=channel, sequence=sequence)
state = _catalog_validation_state(catalog_source, trusted_keys=effective_trusted_keys)
except Exception as exc:
return {
"valid": False,
"configured": catalog_source is not None,
"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,
"key_id": None,
"warnings": [],
"error": str(exc),
}
if signature_state.get("fatal"):
return {
"valid": False,
"configured": catalog_source is not None,
"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"],
"key_id": signature_state["key_id"],
"warnings": [],
"error": str(signature_state["error"] or "Module package catalog signature is invalid."),
}
if effective_approved_channels and channel not in effective_approved_channels:
return {
"valid": False,
"configured": catalog_source is not None,
"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"],
"key_id": signature_state["key_id"],
"warnings": [],
"error": f"Module package catalog channel {channel!r} is not approved. Approved channels: {', '.join(effective_approved_channels)}.",
}
if effective_require_trusted and not signature_state["trusted"]:
return {
"valid": False,
"configured": catalog_source is not None,
"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,
"key_id": signature_state["key_id"],
"warnings": [],
"error": str(signature_state["error"] or "Module package catalog must be signed by a trusted key."),
}
if not freshness["valid"]:
return _invalid_catalog_result(
catalog_source,
modules=(),
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"]),
return _catalog_error_result(catalog_source, error=str(exc))
policy_error = _catalog_policy_error(
catalog_source,
state,
require_trusted=effective_require_trusted,
approved_channels=effective_approved_channels,
)
if policy_error is not None:
return policy_error
return _valid_catalog_result(catalog_source, state)
def _catalog_validation_state(
source: Path | str | None,
*,
trusted_keys: dict[str, str],
) -> _CatalogValidationState:
payload, read_state = _read_catalog_payload_with_metadata(source)
modules = _normalize_catalog_modules(payload)
channel = _catalog_channel(payload)
sequence = _catalog_sequence(payload)
return _CatalogValidationState(
modules=modules,
channel=channel,
sequence=sequence,
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=trusted_keys),
freshness=_catalog_freshness_state(payload),
replay=_catalog_replay_state(channel=channel, sequence=sequence),
read_state=read_state,
)
def _catalog_policy_error(
source: Path | str | None,
state: _CatalogValidationState,
*,
require_trusted: bool,
approved_channels: tuple[str, ...],
) -> dict[str, object] | None:
if state.signature_state.get("fatal"):
return _invalid_catalog_state_result(source, state, error=str(state.signature_state["error"] or "Module package catalog signature is invalid."))
if approved_channels and state.channel not in approved_channels:
return _invalid_catalog_state_result(
source,
state,
error=f"Module package catalog channel {state.channel!r} is not approved. Approved channels: {', '.join(approved_channels)}.",
)
if not replay["valid"]:
return _invalid_catalog_result(
catalog_source,
modules=(),
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)
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"]:
warnings.append(str(signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
if require_trusted and not state.signature_state["trusted"]:
return _invalid_catalog_state_result(source, state, error=str(state.signature_state["error"] or "Module package catalog must be signed by a trusted key."))
if not state.freshness["valid"]:
return _invalid_catalog_state_result(source, state, error=str(state.freshness["error"]))
if not state.replay["valid"]:
return _invalid_catalog_state_result(source, state, error=str(state.replay["error"]))
return None
def _catalog_error_result(source: Path | str | None, *, error: str) -> dict[str, object]:
return _invalid_catalog_result(
source,
modules=(),
channel=None,
sequence=None,
generated_at=None,
not_before=None,
expires_at=None,
signature_state=_unsigned_catalog_signature_state(),
read_state=_default_catalog_read_state(),
error=error,
)
def _invalid_catalog_state_result(source: Path | str | None, state: _CatalogValidationState, *, error: str) -> dict[str, object]:
return _invalid_catalog_result(
source,
modules=(),
channel=state.channel,
sequence=state.sequence,
generated_at=state.generated_at,
not_before=state.not_before,
expires_at=state.expires_at,
signature_state=state.signature_state,
read_state=state.read_state,
error=error,
)
def _valid_catalog_result(source: Path | str | None, state: _CatalogValidationState) -> dict[str, object]:
return {
"valid": True,
"configured": catalog_source is not None and _catalog_source_exists(catalog_source),
"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"],
"key_id": signature_state["key_id"],
"warnings": warnings,
"configured": source is not None and _catalog_source_exists(source),
"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(state.read_state.get("cache_used")),
"cache_path": state.read_state.get("cache_path") if isinstance(state.read_state.get("cache_path"), str) else None,
"modules": list(state.modules),
"channel": state.channel,
"sequence": state.sequence,
"generated_at": state.generated_at,
"not_before": state.not_before,
"expires_at": state.expires_at,
"signed": state.signature_state["signed"],
"trusted": state.signature_state["trusted"],
"key_id": state.signature_state["key_id"],
"warnings": _catalog_validation_warnings(state),
"error": None,
}
def _catalog_validation_warnings(state: _CatalogValidationState) -> list[str]:
warnings: list[str] = []
warnings.extend(str(item) for item in state.freshness.get("warnings", ()) if item)
warnings.extend(str(item) for item in state.replay.get("warnings", ()) if item)
warnings.extend(_catalog_interface_warnings(state.modules))
if not state.signature_state["signed"]:
warnings.append("Catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
elif not state.signature_state["trusted"]:
warnings.append(str(state.signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
return warnings
def _default_catalog_read_state() -> dict[str, object]:
cache_path = _configured_catalog_cache_path()
return {"cache_used": False, "cache_path": str(cache_path) if cache_path is not None else None}
def _unsigned_catalog_signature_state() -> dict[str, object]:
return {"signed": False, "trusted": False, "key_id": None}
def sign_module_package_catalog(
*,
path: Path,
@@ -350,17 +329,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
def _read_trusted_keys_url(url: str) -> str:
if not _is_http_url(url):
raise ValueError("Trusted 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: # noqa: S310 - validated catalog key HTTP(S) URL. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
body = response.read().decode("utf-8")
body = fetch_http_text(url, timeout=15, label="Trusted catalog key URL")
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):
except OSError:
if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8")
raise
@@ -421,13 +397,12 @@ def _read_catalog_url(url: str) -> str:
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: # noqa: S310 - validated module catalog HTTP(S) URL. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
body = response.read().decode("utf-8")
body = fetch_http_text(url, timeout=15, label="Module package catalog URL")
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, False
except (OSError, urllib.error.URLError):
except OSError:
if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8"), True
raise
@@ -935,8 +910,7 @@ def _catalog_source_type(source: Path | str | None) -> str | None:
def _is_http_url(value: str) -> bool:
parsed = urllib.parse.urlparse(value)
return parsed.scheme in {"http", "https"} and bool(parsed.netloc) and not parsed.username and not parsed.password
return is_http_url(value)
def _invalid_catalog_result(

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
CAPABILITY_NOTIFICATIONS_DISPATCH = "notifications.dispatch"
@dataclass(frozen=True, slots=True)
class NotificationDispatchRequest:
tenant_id: str
source_module: str
source_resource_type: str
source_resource_id: str | None
event_kind: str
channel: str = "inbox"
recipient: str | None = None
recipient_type: str | None = None
recipient_id: str | None = None
recipient_label: str | None = None
subject: str | None = None
body_text: str | None = None
body_html: str | None = None
action_url: str | None = None
priority: int = 0
not_before_at: datetime | None = None
payload: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class NotificationDispatchProvider(Protocol):
def enqueue_notification(
self,
session: object,
request: NotificationDispatchRequest,
*,
enqueue_delivery: bool = True,
) -> Mapping[str, object]:
...
def deliver_notification(self, session: object, *, notification_id: str) -> Mapping[str, object]:
...
def deliver_pending(
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 50,
) -> Mapping[str, object]:
...
def notification_dispatch_provider(registry: object | None) -> NotificationDispatchProvider | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_NOTIFICATIONS_DISPATCH):
return None
capability = registry.capability(CAPABILITY_NOTIFICATIONS_DISPATCH)
return capability if isinstance(capability, NotificationDispatchProvider) else None

View File

@@ -186,46 +186,20 @@ class PlatformRegistry:
def validate(self) -> RegistrySnapshot:
ordered = tuple(self._topologically_sorted())
available_capabilities = set(self._capability_factories)
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}")
for capability in manifest.required_capabilities:
if capability not in available_capabilities:
raise RegistryError(f"Module {manifest.id!r} requires unavailable capability {capability!r}")
for dependency in manifest.optional_dependencies:
if dependency == manifest.id:
raise RegistryError(f"Module {manifest.id!r} cannot list itself as an optional dependency")
for permission in manifest.permissions:
if permission.module_id != manifest.id:
raise RegistryError(f"Permission {permission.scope!r} has mismatched module id {permission.module_id!r}")
if not _SCOPE_RE.match(permission.scope):
raise RegistryError(f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}")
expected_prefix = f"{permission.module_id}:{permission.resource}:"
if not permission.scope.startswith(expected_prefix) or permission.scope.rsplit(":", 1)[-1] != permission.action:
raise RegistryError(f"Permission fields do not match scope {permission.scope!r}")
if permission.scope in seen_permissions:
raise RegistryError(f"Duplicate permission scope: {permission.scope}")
seen_permissions[permission.scope] = permission
_validate_manifest_relationships(
manifest,
known_modules=self._manifests,
available_capabilities=available_capabilities,
)
permissions = _collect_manifest_permissions(ordered)
_validate_interface_closure(ordered)
known_scopes = set(seen_permissions)
for manifest in ordered:
for template in manifest.role_templates:
for scope in template.permissions:
if scope in {"*", "tenant:*", "system:*"}:
continue
if _WILDCARD_RE.match(scope):
continue
if scope not in known_scopes:
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
return RegistrySnapshot(
manifests=ordered,
permissions=tuple(seen_permissions.values()),
permissions=tuple(permissions.values()),
role_templates=tuple(template for manifest in ordered for template in manifest.role_templates),
nav_items=self.nav_items(),
)
@@ -299,7 +273,80 @@ def _attribute_delete_veto_issue(
return replace(issue, module_id=issue.module_id or registration.module_id, details=details)
def _validate_manifest_relationships(
manifest: ModuleManifest,
*,
known_modules: Mapping[str, ModuleManifest],
available_capabilities: set[str],
) -> None:
for dependency in manifest.dependencies:
if dependency not in known_modules:
raise RegistryError(f"Module {manifest.id!r} depends on unknown module {dependency!r}")
for capability in manifest.required_capabilities:
if capability not in available_capabilities:
raise RegistryError(f"Module {manifest.id!r} requires unavailable capability {capability!r}")
for dependency in manifest.optional_dependencies:
if dependency == manifest.id:
raise RegistryError(f"Module {manifest.id!r} cannot list itself as an optional dependency")
def _collect_manifest_permissions(manifests: tuple[ModuleManifest, ...]) -> dict[str, PermissionDefinition]:
permissions: dict[str, PermissionDefinition] = {}
for manifest in manifests:
for permission in manifest.permissions:
_validate_manifest_permission(manifest, permission, permissions)
permissions[permission.scope] = permission
return permissions
def _validate_manifest_permission(
manifest: ModuleManifest,
permission: PermissionDefinition,
seen_permissions: Mapping[str, PermissionDefinition],
) -> None:
if permission.module_id != manifest.id:
raise RegistryError(f"Permission {permission.scope!r} has mismatched module id {permission.module_id!r}")
if not _SCOPE_RE.match(permission.scope):
raise RegistryError(f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}")
expected_prefix = f"{permission.module_id}:{permission.resource}:"
if not permission.scope.startswith(expected_prefix) or permission.scope.rsplit(":", 1)[-1] != permission.action:
raise RegistryError(f"Permission fields do not match scope {permission.scope!r}")
if permission.scope in seen_permissions:
raise RegistryError(f"Duplicate permission scope: {permission.scope}")
def _validate_role_template_scopes(
manifests: tuple[ModuleManifest, ...],
*,
known_scopes: set[str],
) -> None:
for manifest in manifests:
for template in manifest.role_templates:
for scope in template.permissions:
if _role_template_scope_known(scope, known_scopes):
continue
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
def _role_template_scope_known(scope: str, known_scopes: set[str]) -> bool:
if scope in {"*", "tenant:*", "system:*"}:
return True
if _WILDCARD_RE.match(scope):
return True
return scope in known_scopes
def _validate_manifest_shape(manifest: ModuleManifest) -> None:
_validate_manifest_identity(manifest)
_validate_manifest_contract_lists(manifest)
_validate_manifest_overlaps(manifest)
_validate_manifest_migration_spec(manifest)
_validate_manifest_frontend(manifest)
for item in manifest.nav_items:
_validate_nav_item(manifest.id, item)
def _validate_manifest_identity(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():
@@ -313,12 +360,17 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
f"{SUPPORTED_MANIFEST_CONTRACT_VERSION!r}"
)
def _validate_manifest_contract_lists(manifest: ModuleManifest) -> None:
_validate_dependency_list(manifest.id, "dependencies", manifest.dependencies)
_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)
def _validate_manifest_overlaps(manifest: ModuleManifest) -> None:
overlap = set(manifest.dependencies) & set(manifest.optional_dependencies)
if overlap:
joined = ", ".join(sorted(overlap))
@@ -328,36 +380,42 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
joined = ", ".join(sorted(capability_overlap))
raise RegistryError(f"Module {manifest.id!r} lists capabilities as both required and optional: {joined}")
def _validate_manifest_migration_spec(manifest: ModuleManifest) -> None:
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:
def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
if manifest.frontend is None:
return
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):
_validate_frontend_route(manifest.id, route.path, route.component)
for item in frontend.nav_items:
_validate_nav_item(manifest.id, item)
def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
if not path.startswith("/"):
raise RegistryError(f"Frontend route for module {module_id!r} must start with '/': {path!r}")
if not component.strip():
raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component")
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list)
for manifest in manifests:

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
from typing import Any
from govoplan_core.core.modules import ModuleContext
_context: ModuleContext | None = None
@@ -21,3 +23,41 @@ def get_runtime_context() -> ModuleContext | None:
def get_registry() -> object | None:
return _context.registry if _context is not None else None
class ModuleSettingsProxy:
def __init__(self, runtime: ModuleRuntimeState) -> None:
self._runtime = runtime
def __getattr__(self, name: str) -> Any:
return getattr(self._runtime.get_settings(), name)
class ModuleRuntimeState:
def __init__(self, module_name: str) -> None:
self.module_name = module_name
self.settings = ModuleSettingsProxy(self)
self._registry: object | None = None
self._settings: object | None = None
def configure_runtime(self, *, registry: object | None = None, settings: object | None = None) -> None:
if registry is not None:
self._registry = registry
if settings is not None:
self._settings = settings
def clear_runtime(self) -> None:
self._registry = None
self._settings = None
def get_registry(self) -> object | None:
return self._registry
def get_settings(self) -> object:
if self._settings is not None:
return self._settings
try:
from govoplan_core.settings import settings as legacy_settings
except ModuleNotFoundError as exc:
raise RuntimeError(f"GovOPlaN {self.module_name} runtime settings are not configured") from exc
return legacy_settings

View File

@@ -0,0 +1,68 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any, Literal
from sqlalchemy import inspect
ChangeOperation = Literal["created", "updated", "deleted"]
def object_state(obj: object) -> Any:
return inspect(obj)
def has_attr_changes(state: Any, attrs: tuple[str, ...]) -> bool:
return any(name in state.attrs and state.attrs[name].history.has_changes() for name in attrs)
def previous_value(obj: object, attr_name: str) -> str | None:
state = object_state(obj)
if attr_name not in state.attrs:
return None
history = state.attrs[attr_name].history
if not history.has_changes() or not history.deleted:
return None
value = history.deleted[0]
return str(value) if value is not None else None
def ensure_object_id(obj: object, new_id: Callable[[], str]) -> str:
resource_id = getattr(obj, "id", None)
if resource_id:
return str(resource_id)
resource_id = new_id()
setattr(obj, "id", resource_id)
return resource_id
def operation_for_object(obj: object, *, changed_attrs: tuple[str, ...]) -> ChangeOperation | None:
state = object_state(obj)
if state.pending:
return "created"
if state.deleted:
return "deleted"
if not has_attr_changes(state, changed_attrs):
return None
return "updated"
def operation_for_soft_deletable(
obj: object,
*,
changed_attrs: tuple[str, ...],
deleted_attr: str = "deleted_at",
) -> ChangeOperation | None:
state = object_state(obj)
if state.pending:
return "created"
if not has_attr_changes(state, changed_attrs):
return None
if deleted_attr in state.attrs:
history = state.attrs[deleted_attr].history
if history.has_changes():
if any(value is not None for value in history.added):
return "deleted"
if any(value is not None for value in history.deleted):
return "created"
return "updated"