intermittent commit
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
125
src/govoplan_core/core/module_installer_notifications.py
Normal file
125
src/govoplan_core/core/module_installer_notifications.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
64
src/govoplan_core/core/notifications.py
Normal file
64
src/govoplan_core/core/notifications.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
68
src/govoplan_core/core/sqlalchemy_change_tracking.py
Normal file
68
src/govoplan_core/core/sqlalchemy_change_tracking.py
Normal 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"
|
||||
Reference in New Issue
Block a user