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

@@ -94,6 +94,28 @@ class UserInfo(BaseModel):
ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences)
class AuthSessionUserInfo(BaseModel):
id: str
account_id: str
email: str
display_name: str | None = None
tenant_display_name: str | None = None
is_tenant_admin: bool = False
password_reset_required: bool = False
class AuthSessionResponse(BaseModel):
authenticated: bool = True
auth_method: Literal["session", "api_key"] = "session"
user: AuthSessionUserInfo
# Backwards-compatible alias for the active tenant.
tenant: TenantInfo
active_tenant: TenantInfo
session_id: str | None = None
api_key_id: str | None = None
expires_at: datetime | None = None
class ProfileUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -143,6 +165,40 @@ class PrincipalContextInfo(BaseModel):
display_name: str | None = None
class AuthShellResponse(BaseModel):
user: UserInfo
# Backwards-compatible alias for the active tenant.
tenant: TenantInfo
active_tenant: TenantInfo
tenants: list[TenantMembershipInfo] = Field(default_factory=list)
scopes: list[str]
principal: PrincipalContextInfo | None = None
profile_loaded: bool = False
roles_loaded: bool = False
groups_loaded: bool = False
class AuthProfileResponse(BaseModel):
user: UserInfo
# Backwards-compatible alias for the active tenant.
tenant: TenantInfo
active_tenant: TenantInfo
available_languages: list[LanguageInfo] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list)
default_language: str = "en"
profile_loaded: bool = True
class AuthRolesResponse(BaseModel):
roles: list[RoleInfo] = Field(default_factory=list)
roles_loaded: bool = True
class AuthGroupsResponse(BaseModel):
groups: list[GroupInfo] = Field(default_factory=list)
groups_loaded: bool = True
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
@@ -159,6 +215,9 @@ class LoginResponse(BaseModel):
available_languages: list[LanguageInfo] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list)
default_language: str = "en"
profile_loaded: bool = True
roles_loaded: bool = True
groups_loaded: bool = True
class MeResponse(BaseModel):
@@ -174,3 +233,6 @@ class MeResponse(BaseModel):
available_languages: list[LanguageInfo] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list)
default_language: str = "en"
profile_loaded: bool = True
roles_loaded: bool = True
groups_loaded: bool = True

View File

@@ -5,6 +5,8 @@ from celery import Celery
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider
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 CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import configure_runtime
from govoplan_core.settings import settings
from govoplan_core.db.session import configure_database
@@ -13,7 +15,7 @@ from govoplan_core.server.registry import available_module_manifests, build_plat
configure_database(settings.database_url)
celery = Celery(
"multimailer",
"govoplan",
broker=settings.redis_url,
backend=settings.redis_url,
)
@@ -21,8 +23,10 @@ celery = Celery(
celery.conf.update(
task_default_queue="default",
task_routes={
"multimailer.send_email": {"queue": "send_email"},
"multimailer.append_sent": {"queue": "append_sent"},
"govoplan.campaigns.send_email": {"queue": "send_email"},
"govoplan.campaigns.append_sent": {"queue": "append_sent"},
"govoplan.notifications.deliver": {"queue": "notifications"},
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
},
worker_prefetch_multiplier=1,
task_acks_late=True,
@@ -30,12 +34,12 @@ celery.conf.update(
)
@celery.task(name="multimailer.ping")
@celery.task(name="govoplan.ping")
def ping():
return "pong"
def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider:
def _platform_registry() -> PlatformRegistry:
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules)
available_modules = available_module_manifests(enabled_modules=candidate_modules, ignore_load_errors=True)
@@ -44,13 +48,26 @@ def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider:
context = ModuleContext(registry=registry, settings=settings)
configure_runtime(context)
registry.configure_capability_context(context)
return registry
def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider:
registry = _platform_registry()
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_DELIVERY_TASKS)
if not isinstance(capability, CampaignDeliveryTaskProvider):
raise RuntimeError("Campaign delivery task capability is invalid")
return capability
@celery.task(name="multimailer.send_email", bind=True, max_retries=0)
def _notification_dispatch() -> NotificationDispatchProvider:
registry = _platform_registry()
capability = registry.require_capability(CAPABILITY_NOTIFICATIONS_DISPATCH)
if not isinstance(capability, NotificationDispatchProvider):
raise RuntimeError("Notification dispatch capability is invalid")
return capability
@celery.task(name="govoplan.campaigns.send_email", bind=True, max_retries=0)
def send_email(self, job_id: str):
"""Send one explicitly queued campaign job.
@@ -65,7 +82,7 @@ def send_email(self, job_id: str):
return dict(_campaign_delivery_tasks().send_campaign_job(session, job_id=job_id, enqueue_imap_task=True))
@celery.task(name="multimailer.append_sent", bind=True, max_retries=None)
@celery.task(name="govoplan.campaigns.append_sent", bind=True, max_retries=None)
def append_sent(self, job_id: str):
"""Append the exact sent MIME to the configured IMAP Sent folder."""
@@ -78,3 +95,19 @@ def append_sent(self, job_id: str):
if getattr(exc, "temporary", None) is True:
raise self.retry(exc=exc, countdown=300)
raise
@celery.task(name="govoplan.notifications.deliver", bind=True, max_retries=0)
def deliver_notification(self, notification_id: str):
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
return dict(_notification_dispatch().deliver_notification(session, notification_id=notification_id))
@celery.task(name="govoplan.notifications.deliver_pending", bind=True, max_retries=0)
def deliver_pending_notifications(self, tenant_id: str | None = None, limit: int = 50):
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
return dict(_notification_dispatch().deliver_pending(session, tenant_id=tenant_id, limit=limit))

View File

@@ -5,7 +5,6 @@ import json
from pathlib import Path
import sys
import time
from typing import Any
from govoplan_core.core.module_installer import (
ModuleInstallerError,
@@ -27,6 +26,13 @@ from govoplan_core.core.module_installer import (
update_module_installer_request,
update_module_installer_daemon_status,
)
from govoplan_core.core.module_installer_notifications import (
build_runtime_notification_registry,
emit_module_installer_notification,
installer_notification_body,
installer_notification_priority,
installer_notification_subject,
)
from govoplan_core.core.module_license import issue_module_license, module_license_diagnostics
from govoplan_core.core.module_package_catalog import sign_module_package_catalog, validate_module_package_catalog
from govoplan_core.core.module_management import (
@@ -39,7 +45,7 @@ from govoplan_core.server.registry import available_module_manifests
from govoplan_core.settings import settings
def main() -> int:
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Preflight, apply, or roll back a GovOPlaN module package install plan.")
parser.add_argument("--database-url", default=settings.database_url, help="Database URL containing system_settings.")
parser.add_argument("--runtime-dir", type=Path, help="Directory for installer locks and run snapshots.")
@@ -95,202 +101,295 @@ def main() -> int:
parser.add_argument("--license-signing-private-key", type=Path, help="PEM Ed25519 private key for --issue-license.")
parser.add_argument("--license-issuer", help="Optional issuer string for --issue-license.")
parser.add_argument("--license-notes", help="Optional operator note for --issue-license.")
args = parser.parse_args()
return parser
def main() -> int:
args = _build_parser().parse_args()
runtime_dir = args.runtime_dir or default_installer_runtime_dir(args.database_url)
try:
if args.issue_license:
if not args.license_id or not args.license_subject or not args.license_valid_until:
raise ModuleInstallerError("--issue-license requires --license-id, --license-subject, and --license-valid-until.")
if not args.license_signing_key_id or not args.license_signing_private_key:
raise ModuleInstallerError("--issue-license requires --license-signing-key-id and --license-signing-private-key.")
if not [item for item in args.license_feature if item.strip()]:
raise ModuleInstallerError("--issue-license requires at least one --license-feature.")
try:
path = issue_module_license(
path=args.issue_license,
license_id=args.license_id,
subject=args.license_subject,
features=args.license_feature,
valid_from=args.license_valid_from,
valid_until=args.license_valid_until,
signing_key_id=args.license_signing_key_id,
signing_private_key_path=args.license_signing_private_key,
issuer=args.license_issuer,
notes=args.license_notes,
)
except (OSError, ValueError) as exc:
raise ModuleInstallerError(str(exc)) from exc
_print_result(
{
"issued": True,
"path": str(path),
"license_id": args.license_id,
"subject": args.license_subject,
"features": list(dict.fromkeys(item.strip() for item in args.license_feature if item.strip())),
"valid_until": args.license_valid_until,
"key_id": args.license_signing_key_id,
},
output_format=args.format,
)
return 0
if args.validate_license is not None:
path = Path(args.validate_license).expanduser() if args.validate_license else None
result = module_license_diagnostics(
path,
require_trusted=args.require_trusted_license,
trusted_keys=_trusted_keys_from_args(args.license_trusted_key, flag_name="--license-trusted-key"),
required_features=args.license_required_feature,
)
_print_result(result, output_format=args.format)
return 0 if result.get("valid") and not result.get("missing_features") else 1
if args.sign_package_catalog:
if not args.catalog_signing_key_id or not args.catalog_signing_private_key:
raise ModuleInstallerError("--sign-package-catalog requires --catalog-signing-key-id and --catalog-signing-private-key.")
path = sign_module_package_catalog(
path=args.sign_package_catalog,
key_id=args.catalog_signing_key_id,
private_key_path=args.catalog_signing_private_key,
output_path=args.catalog_output,
)
_print_result({"signed": True, "path": str(path), "key_id": args.catalog_signing_key_id}, output_format=args.format)
return 0
if args.validate_package_catalog is not None:
path = Path(args.validate_package_catalog).expanduser() if args.validate_package_catalog else None
result = validate_module_package_catalog(
path,
require_trusted=args.require_signed_catalog,
approved_channels=tuple(args.approved_catalog_channel),
trusted_keys=_trusted_keys_from_args(args.catalog_trusted_key, flag_name="--catalog-trusted-key"),
)
_print_result(result, output_format=args.format)
return 0 if result.get("valid") else 1
if args.daemon_status:
_print_result(module_installer_daemon_status(runtime_dir=runtime_dir), output_format=args.format)
return 0
if args.daemon or args.daemon_once:
return _run_daemon(args=args, runtime_dir=runtime_dir)
if args.list_requests:
_print_result({
"requests": list(list_module_installer_requests(runtime_dir=runtime_dir)),
"daemon": module_installer_daemon_status(runtime_dir=runtime_dir),
}, output_format=args.format)
return 0
if args.show_request:
_print_result(read_module_installer_request(runtime_dir=runtime_dir, request_id=args.show_request), output_format=args.format)
return 0
if args.cancel_request:
_print_result(cancel_module_installer_request(runtime_dir=runtime_dir, request_id=args.cancel_request, cancelled_by="cli"), output_format=args.format)
return 0
if args.retry_request:
_print_result(retry_module_installer_request(runtime_dir=runtime_dir, request_id=args.retry_request, requested_by="cli"), output_format=args.format)
return 0
if args.enqueue_supervised:
request = queue_module_installer_request(
runtime_dir=runtime_dir,
requested_by="cli",
options=_request_options_from_args(args),
)
_print_result(request, output_format=args.format)
return 0
if args.list_runs:
_print_result({
"runs": list(list_module_installer_runs(runtime_dir=runtime_dir)),
"lock": module_installer_lock_status(runtime_dir=runtime_dir),
}, output_format=args.format)
return 0
if args.show_run:
_print_result(read_module_installer_run(runtime_dir=runtime_dir, run_id=args.show_run), output_format=args.format)
return 0
if args.lock_status:
_print_result(module_installer_lock_status(runtime_dir=runtime_dir), output_format=args.format)
return 0
if args.rollback:
result = rollback_module_install_run(
run_id=args.rollback,
runtime_dir=runtime_dir,
npm_bin=args.npm_bin,
build_webui=args.build_webui,
database_restore_command=args.database_restore_command,
database_url=str(args.database_url),
)
_print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1
configure_database(str(args.database_url))
available = available_module_manifests(ignore_load_errors=True)
with get_database().session() as session:
configured = configured_enabled_modules(settings.enabled_modules)
desired = saved_desired_enabled_modules(session, configured)
plan = saved_module_install_plan(session)
if args.supervise:
result = supervise_module_install_plan(
session=session,
plan=plan,
available=available,
current_enabled=desired,
desired_enabled=desired,
database_url=str(args.database_url),
runtime_dir=runtime_dir,
webui_root=args.webui_root,
npm_bin=args.npm_bin,
build_webui=args.build_webui,
migrate_database=args.migrate,
database_backup_command=args.database_backup_command,
database_restore_command=args.database_restore_command,
database_restore_check_command=args.database_restore_check_command,
activate_installed_modules=not args.no_activate_installed_modules,
remove_uninstalled_modules_from_desired=not args.keep_uninstalled_modules_in_desired,
restart_command=args.restart_command,
health_url=args.health_url,
health_timeout_seconds=args.health_timeout_seconds,
health_interval_seconds=args.health_interval_seconds,
)
_print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1
if args.apply or args.dry_run:
result = run_module_install_plan(
session=session,
plan=plan,
available=available,
current_enabled=desired,
desired_enabled=desired,
database_url=str(args.database_url),
runtime_dir=runtime_dir,
webui_root=args.webui_root,
npm_bin=args.npm_bin,
build_webui=args.build_webui,
migrate_database=args.migrate,
database_backup_command=args.database_backup_command,
database_restore_command=args.database_restore_command,
database_restore_check_command=args.database_restore_check_command,
activate_installed_modules=not args.no_activate_installed_modules,
remove_uninstalled_modules_from_desired=not args.keep_uninstalled_modules_in_desired,
dry_run=args.dry_run,
)
_print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1
from govoplan_core.core.maintenance import saved_maintenance_mode
maintenance = saved_maintenance_mode(session)
preflight = module_install_preflight(
plan=plan,
available=available,
current_enabled=desired,
desired_enabled=desired,
maintenance_mode=maintenance.enabled,
session=session,
webui_root=args.webui_root,
runtime_dir=runtime_dir,
)
_print_preflight(preflight.as_dict(), output_format=args.format)
return 0 if preflight.allowed else 1
return _dispatch_command(args=args, runtime_dir=runtime_dir)
except ModuleInstallerError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
def _dispatch_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
for handler in (
_handle_license_command,
_handle_catalog_command,
_handle_runtime_command,
_handle_queue_command,
_handle_run_history_command,
):
result = handler(args=args, runtime_dir=runtime_dir)
if result is not None:
return result
return _handle_install_command(args=args, runtime_dir=runtime_dir)
def _handle_license_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
del runtime_dir
if args.issue_license:
return _issue_license_command(args)
if args.validate_license is not None:
return _validate_license_command(args)
return None
def _issue_license_command(args: argparse.Namespace) -> int:
if not args.license_id or not args.license_subject or not args.license_valid_until:
raise ModuleInstallerError("--issue-license requires --license-id, --license-subject, and --license-valid-until.")
if not args.license_signing_key_id or not args.license_signing_private_key:
raise ModuleInstallerError("--issue-license requires --license-signing-key-id and --license-signing-private-key.")
if not [item for item in args.license_feature if item.strip()]:
raise ModuleInstallerError("--issue-license requires at least one --license-feature.")
try:
path = issue_module_license(
path=args.issue_license,
license_id=args.license_id,
subject=args.license_subject,
features=args.license_feature,
valid_from=args.license_valid_from,
valid_until=args.license_valid_until,
signing_key_id=args.license_signing_key_id,
signing_private_key_path=args.license_signing_private_key,
issuer=args.license_issuer,
notes=args.license_notes,
)
except (OSError, ValueError) as exc:
raise ModuleInstallerError(str(exc)) from exc
_print_result(
{
"issued": True,
"path": str(path),
"license_id": args.license_id,
"subject": args.license_subject,
"features": list(dict.fromkeys(item.strip() for item in args.license_feature if item.strip())),
"valid_until": args.license_valid_until,
"key_id": args.license_signing_key_id,
},
output_format=args.format,
)
return 0
def _validate_license_command(args: argparse.Namespace) -> int:
path = Path(args.validate_license).expanduser() if args.validate_license else None
result = module_license_diagnostics(
path,
require_trusted=args.require_trusted_license,
trusted_keys=_trusted_keys_from_args(args.license_trusted_key, flag_name="--license-trusted-key"),
required_features=args.license_required_feature,
)
_print_result(result, output_format=args.format)
return 0 if result.get("valid") and not result.get("missing_features") else 1
def _handle_catalog_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
del runtime_dir
if args.sign_package_catalog:
return _sign_package_catalog_command(args)
if args.validate_package_catalog is not None:
return _validate_package_catalog_command(args)
return None
def _sign_package_catalog_command(args: argparse.Namespace) -> int:
if not args.catalog_signing_key_id or not args.catalog_signing_private_key:
raise ModuleInstallerError("--sign-package-catalog requires --catalog-signing-key-id and --catalog-signing-private-key.")
path = sign_module_package_catalog(
path=args.sign_package_catalog,
key_id=args.catalog_signing_key_id,
private_key_path=args.catalog_signing_private_key,
output_path=args.catalog_output,
)
_print_result({"signed": True, "path": str(path), "key_id": args.catalog_signing_key_id}, output_format=args.format)
return 0
def _validate_package_catalog_command(args: argparse.Namespace) -> int:
path = Path(args.validate_package_catalog).expanduser() if args.validate_package_catalog else None
result = validate_module_package_catalog(
path,
require_trusted=args.require_signed_catalog,
approved_channels=tuple(args.approved_catalog_channel),
trusted_keys=_trusted_keys_from_args(args.catalog_trusted_key, flag_name="--catalog-trusted-key"),
)
_print_result(result, output_format=args.format)
return 0 if result.get("valid") else 1
def _handle_runtime_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
if args.daemon_status:
_print_result(module_installer_daemon_status(runtime_dir=runtime_dir), output_format=args.format)
return 0
if args.daemon or args.daemon_once:
return _run_daemon(args=args, runtime_dir=runtime_dir)
return None
def _handle_queue_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
if args.list_requests:
_print_result({
"requests": list(list_module_installer_requests(runtime_dir=runtime_dir)),
"daemon": module_installer_daemon_status(runtime_dir=runtime_dir),
}, output_format=args.format)
return 0
if args.show_request:
_print_result(read_module_installer_request(runtime_dir=runtime_dir, request_id=args.show_request), output_format=args.format)
return 0
if args.cancel_request:
_print_result(cancel_module_installer_request(runtime_dir=runtime_dir, request_id=args.cancel_request, cancelled_by="cli"), output_format=args.format)
return 0
if args.retry_request:
_print_result(retry_module_installer_request(runtime_dir=runtime_dir, request_id=args.retry_request, requested_by="cli"), output_format=args.format)
return 0
if args.enqueue_supervised:
request = queue_module_installer_request(runtime_dir=runtime_dir, requested_by="cli", options=_request_options_from_args(args))
_print_result(request, output_format=args.format)
return 0
return None
def _handle_run_history_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
if args.list_runs:
_print_result({
"runs": list(list_module_installer_runs(runtime_dir=runtime_dir)),
"lock": module_installer_lock_status(runtime_dir=runtime_dir),
}, output_format=args.format)
return 0
if args.show_run:
_print_result(read_module_installer_run(runtime_dir=runtime_dir, run_id=args.show_run), output_format=args.format)
return 0
if args.lock_status:
_print_result(module_installer_lock_status(runtime_dir=runtime_dir), output_format=args.format)
return 0
if args.rollback:
return _rollback_command(args=args, runtime_dir=runtime_dir)
return None
def _rollback_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
result = rollback_module_install_run(
run_id=args.rollback,
runtime_dir=runtime_dir,
npm_bin=args.npm_bin,
build_webui=args.build_webui,
database_restore_command=args.database_restore_command,
database_url=str(args.database_url),
)
_print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1
def _handle_install_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
configure_database(str(args.database_url))
available = available_module_manifests(ignore_load_errors=True)
with get_database().session() as session:
configured = configured_enabled_modules(settings.enabled_modules)
desired = saved_desired_enabled_modules(session, configured)
plan = saved_module_install_plan(session)
if args.supervise:
return _supervise_install_command(args=args, runtime_dir=runtime_dir, session=session, available=available, desired=desired, plan=plan)
if args.apply or args.dry_run:
return _apply_install_command(args=args, runtime_dir=runtime_dir, session=session, available=available, desired=desired, plan=plan)
return _preflight_install_command(args=args, runtime_dir=runtime_dir, session=session, available=available, desired=desired, plan=plan)
def _supervise_install_command(
*,
args: argparse.Namespace,
runtime_dir: Path,
session: object,
available: object,
desired: object,
plan: object,
) -> int:
result = supervise_module_install_plan(
session=session,
plan=plan,
available=available,
current_enabled=desired,
desired_enabled=desired,
database_url=str(args.database_url),
runtime_dir=runtime_dir,
webui_root=args.webui_root,
npm_bin=args.npm_bin,
build_webui=args.build_webui,
migrate_database=args.migrate,
database_backup_command=args.database_backup_command,
database_restore_command=args.database_restore_command,
database_restore_check_command=args.database_restore_check_command,
activate_installed_modules=not args.no_activate_installed_modules,
remove_uninstalled_modules_from_desired=not args.keep_uninstalled_modules_in_desired,
restart_command=args.restart_command,
health_url=args.health_url,
health_timeout_seconds=args.health_timeout_seconds,
health_interval_seconds=args.health_interval_seconds,
)
_print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1
def _apply_install_command(
*,
args: argparse.Namespace,
runtime_dir: Path,
session: object,
available: object,
desired: object,
plan: object,
) -> int:
result = run_module_install_plan(
session=session,
plan=plan,
available=available,
current_enabled=desired,
desired_enabled=desired,
database_url=str(args.database_url),
runtime_dir=runtime_dir,
webui_root=args.webui_root,
npm_bin=args.npm_bin,
build_webui=args.build_webui,
migrate_database=args.migrate,
database_backup_command=args.database_backup_command,
database_restore_command=args.database_restore_command,
database_restore_check_command=args.database_restore_check_command,
activate_installed_modules=not args.no_activate_installed_modules,
remove_uninstalled_modules_from_desired=not args.keep_uninstalled_modules_in_desired,
dry_run=args.dry_run,
)
_print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1
def _preflight_install_command(
*,
args: argparse.Namespace,
runtime_dir: Path,
session: object,
available: object,
desired: object,
plan: object,
) -> int:
from govoplan_core.core.maintenance import saved_maintenance_mode
maintenance = saved_maintenance_mode(session)
preflight = module_install_preflight(
plan=plan,
available=available,
current_enabled=desired,
desired_enabled=desired,
maintenance_mode=maintenance.enabled,
session=session,
webui_root=args.webui_root,
runtime_dir=runtime_dir,
)
_print_preflight(preflight.as_dict(), output_format=args.format)
return 0 if preflight.allowed else 1
def _default_webui_root() -> Path:
return Path(__file__).resolve().parents[3] / "webui"
@@ -355,6 +454,7 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
request_id = str(request["request_id"])
options = request.get("options") if isinstance(request.get("options"), dict) else {}
configure_database(str(args.database_url))
_emit_installer_daemon_notification(request, event_kind="module_installer.request.running")
try:
available = available_module_manifests(ignore_load_errors=True)
with get_database().session() as session:
@@ -384,7 +484,7 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
health_interval_seconds=_float_option(options, "health_interval_seconds", args.health_interval_seconds),
request_context=_request_context(request),
)
update_module_installer_request(
updated_request = update_module_installer_request(
runtime_dir=runtime_dir,
request_id=request_id,
patch={
@@ -393,8 +493,12 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
"result": result.as_dict(),
},
)
_emit_installer_daemon_notification(
updated_request,
event_kind="module_installer.request.completed" if result.return_code == 0 else "module_installer.request.failed",
)
except Exception as exc:
update_module_installer_request(
updated_request = update_module_installer_request(
runtime_dir=runtime_dir,
request_id=request_id,
patch={
@@ -403,6 +507,34 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
"error": str(exc),
},
)
_emit_installer_daemon_notification(updated_request, event_kind="module_installer.request.failed")
def _emit_installer_daemon_notification(request: dict[str, object], *, event_kind: str) -> None:
tenant_id = str(request.get("tenant_id") or "")
if not tenant_id:
return
registry = build_runtime_notification_registry(settings)
if registry is None:
return
status_value = str(request.get("status") or event_kind.rsplit(".", 1)[-1])
try:
with get_database().SessionLocal() as session:
emitted = emit_module_installer_notification(
session=session,
registry=registry,
tenant_id=tenant_id,
request=request,
event_kind=event_kind,
subject=installer_notification_subject(event_kind, request),
body_text=installer_notification_body(event_kind, request),
recipient_id=str(request.get("requested_by") or "") or None,
priority=installer_notification_priority(status_value),
)
if emitted:
session.commit()
except Exception: # noqa: BLE001 - notification bridge must not block installer work.
return
def _request_options_from_args(args: argparse.Namespace) -> dict[str, object]:
@@ -427,6 +559,7 @@ def _request_context(request: dict[str, object]) -> dict[str, object]:
context: dict[str, object] = {
"request_id": request.get("request_id"),
"requested_by": request.get("requested_by"),
"tenant_id": request.get("tenant_id"),
}
if request.get("retry_of"):
context["retry_of"] = request["retry_of"]

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"

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, replace
import json
import logging
import os
from pathlib import Path
import re
@@ -25,6 +26,8 @@ from govoplan_core.server.config import ManifestFactory
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.settings import settings
logger = logging.getLogger(__name__)
# Historic development databases could be created partly through Alembic and
# partly through Base.metadata.create_all(). In that state Alembic still says
# "2c..." while the 3d/4e file-storage tables already exist, so a normal
@@ -166,6 +169,15 @@ _CREATE_ALL_THROUGH_HIERARCHICAL_INDEXES = {
}
@dataclass(frozen=True, slots=True)
class _LegacyCreateAllSchemaState:
current: str | None
has_no_revision: bool
has_file_storage: bool
has_file_folders: bool
has_create_all_hierarchical_schema: bool
@dataclass(frozen=True, slots=True)
class MigrationResult:
previous_revision: str | None
@@ -233,10 +245,7 @@ def run_registered_module_migration_tasks(
dry_run: bool = False,
manifest_factories: tuple[ManifestFactory, ...] = (),
) -> tuple[dict[str, object], ...]:
active_phases = tuple(dict.fromkeys(str(item).strip() for item in phases if str(item).strip()))
invalid_phases = tuple(phase for phase in active_phases if phase not in MIGRATION_TASK_PHASES)
if invalid_phases:
raise ValueError("Unsupported module migration task phase(s): " + ", ".join(invalid_phases))
active_phases = _normalized_migration_task_phases(phases)
url = database_url or settings.database_url
registry = _registered_module_registry(
database_url=url,
@@ -244,91 +253,192 @@ def run_registered_module_migration_tasks(
manifest_factories=manifest_factories,
)
manifests = {manifest.id: manifest for manifest in registry.manifests()}
ordered_ids = tuple(dict.fromkeys([
*(str(item).strip() for item in (migration_module_order or ()) if str(item).strip()),
*manifests.keys(),
]))
ordered_ids = _ordered_migration_task_module_ids(migration_module_order, manifests)
records: list[dict[str, object]] = []
database = get_database()
with database.SessionLocal() as session:
for phase in active_phases:
for module_id in ordered_ids:
manifest = manifests.get(module_id)
if manifest is None or manifest.migration_spec is None:
continue
for task in manifest.migration_spec.migration_tasks:
if task.phase != phase:
continue
record: dict[str, object] = {
"module_id": manifest.id,
"task_id": task.task_id,
"phase": task.phase,
"summary": task.summary,
"task_version": task.task_version,
"safety": task.safety,
"idempotent": task.idempotent,
"dry_run": dry_run,
}
if task.timeout_seconds is not None:
record["timeout_seconds"] = task.timeout_seconds
if not task.idempotent:
record.update({"status": "blocked", "message": "Task is not idempotent."})
records.append(record)
raise ModuleMigrationTaskExecutionError(
f"Module migration task {manifest.id}/{task.task_id} is not idempotent.",
records=tuple(records),
)
if dry_run:
record.update({"status": "skipped", "message": "Dry run; executor was not called."})
records.append(record)
continue
if task.executor is None:
record.update({"status": "blocked", "message": "Task has no executor."})
records.append(record)
raise ModuleMigrationTaskExecutionError(
f"Module migration task {manifest.id}/{task.task_id} has no executor.",
records=tuple(records),
)
context = ModuleMigrationTaskContext(
module_id=manifest.id,
task_id=task.task_id,
phase=task.phase,
database_url=url,
target_version=manifest.version,
session=session,
dry_run=dry_run,
metadata=task.metadata,
)
try:
result = task.executor(context)
normalized = _normalize_migration_task_result(result)
except Exception as exc:
session.rollback()
record.update({
"status": "blocked",
"message": f"{type(exc).__name__}: {exc}",
})
records.append(record)
raise ModuleMigrationTaskExecutionError(
f"Module migration task {manifest.id}/{task.task_id} failed.",
records=tuple(records),
) from exc
record.update({
"status": normalized.status,
"message": normalized.message,
"details": _jsonable_migration_task_details(normalized.details),
})
records.append(record)
if normalized.status == "blocked":
session.rollback()
raise ModuleMigrationTaskExecutionError(
normalized.message or f"Module migration task {manifest.id}/{task.task_id} blocked migration.",
records=tuple(records),
)
session.commit()
for manifest, task in _iter_phase_migration_tasks(phase, ordered_ids=ordered_ids, manifests=manifests):
_run_registered_module_migration_task(
session,
manifest=manifest,
task=task,
database_url=url,
dry_run=dry_run,
records=records,
)
return tuple(records)
def _normalized_migration_task_phases(phases: tuple[str, ...] | list[str]) -> tuple[str, ...]:
active_phases = tuple(dict.fromkeys(str(item).strip() for item in phases if str(item).strip()))
invalid_phases = tuple(phase for phase in active_phases if phase not in MIGRATION_TASK_PHASES)
if invalid_phases:
raise ValueError("Unsupported module migration task phase(s): " + ", ".join(invalid_phases))
return active_phases
def _ordered_migration_task_module_ids(
migration_module_order: tuple[str, ...] | list[str] | None,
manifests: Mapping[str, object],
) -> tuple[str, ...]:
return tuple(dict.fromkeys([
*(str(item).strip() for item in (migration_module_order or ()) if str(item).strip()),
*manifests.keys(),
]))
def _iter_phase_migration_tasks(
phase: str,
*,
ordered_ids: tuple[str, ...],
manifests: Mapping[str, object],
) -> Iterable[tuple[object, object]]:
for module_id in ordered_ids:
manifest = manifests.get(module_id)
migration_spec = getattr(manifest, "migration_spec", None)
if manifest is None or migration_spec is None:
continue
for task in migration_spec.migration_tasks:
if task.phase == phase:
yield manifest, task
def _run_registered_module_migration_task(
session: object,
*,
manifest: object,
task: object,
database_url: str,
dry_run: bool,
records: list[dict[str, object]],
) -> None:
record = _migration_task_record(manifest, task, dry_run=dry_run)
_validate_migration_task_can_run(manifest, task, record, records, dry_run=dry_run)
if dry_run:
record.update({"status": "skipped", "message": "Dry run; executor was not called."})
records.append(record)
return
normalized = _execute_module_migration_task(
session,
manifest=manifest,
task=task,
record=record,
records=records,
database_url=database_url,
dry_run=dry_run,
)
record.update({
"status": normalized.status,
"message": normalized.message,
"details": _jsonable_migration_task_details(normalized.details),
})
records.append(record)
if normalized.status == "blocked":
session.rollback()
raise ModuleMigrationTaskExecutionError(
normalized.message or f"Module migration task {manifest.id}/{task.task_id} blocked migration.",
records=tuple(records),
)
session.commit()
def _migration_task_record(manifest: object, task: object, *, dry_run: bool) -> dict[str, object]:
record: dict[str, object] = {
"module_id": manifest.id,
"task_id": task.task_id,
"phase": task.phase,
"summary": task.summary,
"task_version": task.task_version,
"safety": task.safety,
"idempotent": task.idempotent,
"dry_run": dry_run,
}
if task.timeout_seconds is not None:
record["timeout_seconds"] = task.timeout_seconds
return record
def _validate_migration_task_can_run(
manifest: object,
task: object,
record: dict[str, object],
records: list[dict[str, object]],
*,
dry_run: bool,
) -> None:
if not task.idempotent:
_block_migration_task(
manifest,
task,
record,
records,
message="Task is not idempotent.",
error=f"Module migration task {manifest.id}/{task.task_id} is not idempotent.",
)
if not dry_run and task.executor is None:
_block_migration_task(
manifest,
task,
record,
records,
message="Task has no executor.",
error=f"Module migration task {manifest.id}/{task.task_id} has no executor.",
)
def _block_migration_task(
manifest: object,
task: object,
record: dict[str, object],
records: list[dict[str, object]],
*,
message: str,
error: str,
) -> None:
record.update({"status": "blocked", "message": message})
records.append(record)
raise ModuleMigrationTaskExecutionError(
error,
records=tuple(records),
)
def _execute_module_migration_task(
session: object,
*,
manifest: object,
task: object,
record: dict[str, object],
records: list[dict[str, object]],
database_url: str,
dry_run: bool,
) -> ModuleMigrationTaskResult:
context = ModuleMigrationTaskContext(
module_id=manifest.id,
task_id=task.task_id,
phase=task.phase,
database_url=database_url,
target_version=manifest.version,
session=session,
dry_run=dry_run,
metadata=task.metadata,
)
try:
return _normalize_migration_task_result(task.executor(context))
except Exception as exc:
session.rollback()
record.update({
"status": "blocked",
"message": f"{type(exc).__name__}: {exc}",
})
records.append(record)
raise ModuleMigrationTaskExecutionError(
f"Module migration task {manifest.id}/{task.task_id} failed.",
records=tuple(records),
) from exc
def _normalize_migration_task_result(result: ModuleMigrationTaskResult | None) -> ModuleMigrationTaskResult:
if result is None:
return ModuleMigrationTaskResult()
@@ -524,18 +634,21 @@ def _backfill_user_lock_state_for_create_all_schema(database_url: str) -> None:
def _row_count(connection, table_name: str) -> int:
quoted = _quoted_table_name(connection, table_name)
return int(connection.execute(text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one()) # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
statement = text(f"SELECT COUNT(*) FROM {quoted}") # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
return int(connection.execute(statement).scalar_one())
def _drop_table(connection, table_name: str) -> None:
quoted = _quoted_table_name(connection, table_name)
connection.execute(text(f"DROP TABLE {quoted}")) # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
statement = text(f"DROP TABLE {quoted}") # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
connection.execute(statement)
def _rename_table(connection, old_name: str, new_name: str) -> None:
quoted_old = _quoted_table_name(connection, old_name)
quoted_new = _quoted_table_name(connection, new_name)
connection.execute(text(f"ALTER TABLE {quoted_old} RENAME TO {quoted_new}")) # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
statement = text(f"ALTER TABLE {quoted_old} RENAME TO {quoted_new}") # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
connection.execute(statement)
def _quoted_table_name(connection, table_name: str) -> str:
@@ -715,7 +828,8 @@ def reconcile_covered_alembic_dependency_heads(
for revision_id in current:
try:
revision = script.get_revision(revision_id)
except Exception:
except Exception as exc:
logger.debug("Skipping Alembic revision %s while pruning dependency heads: %s", revision_id, exc, exc_info=True)
continue
ancestors = {
item.revision
@@ -749,58 +863,68 @@ def reconcile_legacy_create_all_schema(
"""
url = database_url or settings.database_url
engine = create_engine(url)
try:
with engine.connect() as connection:
heads = MigrationContext.configure(connection).get_current_heads()
current = heads[0] if len(heads) == 1 else None
has_no_revision = len(heads) == 0
schema = inspect(connection)
tables = set(schema.get_table_names())
has_file_storage = _FILE_STORAGE_TABLES.issubset(tables) and all(
_has_columns(schema, table, _FILE_STORAGE_COLUMNS[table])
for table in _FILE_STORAGE_TABLES
)
has_file_folders = _FILE_FOLDER_TABLES.issubset(tables) and _has_columns(
schema,
"file_folders",
_FILE_STORAGE_COLUMNS["file_folders"],
)
has_create_all_hierarchical_schema = _has_create_all_schema_through_hierarchical_settings(schema, tables)
finally:
engine.dispose()
target: str | None = None
if current == REVISION_AUTH_RBAC and has_file_storage and has_file_folders:
target = REVISION_FILE_FOLDERS
elif current == REVISION_AUTH_RBAC and has_file_storage:
target = REVISION_FILE_STORAGE
elif current == REVISION_FILE_STORAGE and has_file_folders:
target = REVISION_FILE_FOLDERS
elif current == REVISION_FILE_FOLDERS and has_create_all_hierarchical_schema:
# Development DBs may be stamped at 4e after earlier create_all
# reconciliation even though later tables/columns were already created
# by a newer model set. Skip only when that complete known schema is
# present, then let newer migrations such as mail credential usernames
# run normally.
_backfill_user_lock_state_for_create_all_schema(url)
target = REVISION_HIERARCHICAL_SETTINGS
elif has_no_revision and has_create_all_hierarchical_schema:
_backfill_user_lock_state_for_create_all_schema(url)
target = REVISION_HIERARCHICAL_SETTINGS
elif has_no_revision and has_file_storage and has_file_folders:
# This is the other create_all-only development shape. The strict
# column checks above ensure that we only stamp a complete known schema.
target = REVISION_FILE_FOLDERS
state = _legacy_create_all_schema_state(url)
target = _legacy_create_all_reconciliation_target(state)
if target is None:
return None
if target == REVISION_HIERARCHICAL_SETTINGS:
_backfill_user_lock_state_for_create_all_schema(url)
command.stamp(alembic_config(database_url=url, migration_track=migration_track), target)
return target
def _legacy_create_all_schema_state(database_url: str) -> _LegacyCreateAllSchemaState:
engine = create_engine(database_url)
try:
with engine.connect() as connection:
heads = MigrationContext.configure(connection).get_current_heads()
schema = inspect(connection)
tables = set(schema.get_table_names())
return _legacy_create_all_schema_state_from_inspection(heads, schema, tables)
finally:
engine.dispose()
def _legacy_create_all_schema_state_from_inspection(
heads: tuple[str, ...],
schema: object,
tables: set[str],
) -> _LegacyCreateAllSchemaState:
has_file_storage = _FILE_STORAGE_TABLES.issubset(tables) and all(
_has_columns(schema, table, _FILE_STORAGE_COLUMNS[table])
for table in _FILE_STORAGE_TABLES
)
has_file_folders = _FILE_FOLDER_TABLES.issubset(tables) and _has_columns(
schema,
"file_folders",
_FILE_STORAGE_COLUMNS["file_folders"],
)
return _LegacyCreateAllSchemaState(
current=heads[0] if len(heads) == 1 else None,
has_no_revision=len(heads) == 0,
has_file_storage=has_file_storage,
has_file_folders=has_file_folders,
has_create_all_hierarchical_schema=_has_create_all_schema_through_hierarchical_settings(schema, tables),
)
def _legacy_create_all_reconciliation_target(state: _LegacyCreateAllSchemaState) -> str | None:
if state.current == REVISION_AUTH_RBAC and state.has_file_storage and state.has_file_folders:
return REVISION_FILE_FOLDERS
if state.current == REVISION_AUTH_RBAC and state.has_file_storage:
return REVISION_FILE_STORAGE
if state.current == REVISION_FILE_STORAGE and state.has_file_folders:
return REVISION_FILE_FOLDERS
if state.current == REVISION_FILE_FOLDERS and state.has_create_all_hierarchical_schema:
return REVISION_HIERARCHICAL_SETTINGS
if state.has_no_revision and state.has_create_all_hierarchical_schema:
return REVISION_HIERARCHICAL_SETTINGS
if state.has_no_revision and state.has_file_storage and state.has_file_folders:
return REVISION_FILE_FOLDERS
return None
def migrate_database(
*,
database_url: str | None = None,

View File

@@ -0,0 +1,100 @@
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
import time
import weakref
from collections.abc import Iterator
from sqlalchemy import event
from sqlalchemy.engine import Connection, Engine
from sqlalchemy.engine.interfaces import ExecutionContext
@dataclass(slots=True)
class QueryMetrics:
query_count: int = 0
total_ms: float = 0.0
slowest_ms: float = 0.0
error_count: int = 0
def record(self, elapsed_ms: float, *, failed: bool = False) -> None:
self.query_count += 1
self.total_ms += elapsed_ms
self.slowest_ms = max(self.slowest_ms, elapsed_ms)
if failed:
self.error_count += 1
_current_metrics: ContextVar[QueryMetrics | None] = ContextVar("govoplan_query_metrics", default=None)
_instrumented_engines: weakref.WeakSet[Engine] = weakref.WeakSet()
_START_STACK_KEY = "govoplan_query_metric_starts"
@contextmanager
def collect_query_metrics() -> Iterator[QueryMetrics]:
metrics = QueryMetrics()
token = _current_metrics.set(metrics)
try:
yield metrics
finally:
_current_metrics.reset(token)
def current_query_metrics() -> QueryMetrics | None:
return _current_metrics.get()
def instrument_engine(engine: Engine) -> Engine:
if engine in _instrumented_engines:
return engine
event.listen(engine, "before_cursor_execute", _before_cursor_execute)
event.listen(engine, "after_cursor_execute", _after_cursor_execute)
event.listen(engine, "handle_error", _handle_error)
_instrumented_engines.add(engine)
return engine
def _before_cursor_execute(
conn: Connection,
_cursor: object,
_statement: str,
_parameters: object,
_context: ExecutionContext,
_executemany: bool,
) -> None:
if current_query_metrics() is None:
return
starts = conn.info.setdefault(_START_STACK_KEY, [])
starts.append(time.perf_counter())
def _after_cursor_execute(
conn: Connection,
_cursor: object,
_statement: str,
_parameters: object,
_context: ExecutionContext,
_executemany: bool,
) -> None:
_record_elapsed(conn, failed=False)
def _handle_error(exception_context: object) -> None:
conn = getattr(exception_context, "connection", None)
if isinstance(conn, Connection):
_record_elapsed(conn, failed=True)
def _record_elapsed(conn: Connection, *, failed: bool) -> None:
metrics = current_query_metrics()
if metrics is None:
return
starts = conn.info.get(_START_STACK_KEY)
if not starts:
return
started_at = starts.pop()
elapsed_ms = (time.perf_counter() - started_at) * 1000
metrics.record(elapsed_ms, failed=failed)

View File

@@ -7,6 +7,8 @@ from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.db.query_metrics import instrument_engine
def default_connect_args(database_url: str) -> dict[str, Any]:
return {"check_same_thread": False} if database_url.startswith("sqlite") else {}
@@ -22,13 +24,13 @@ def create_database_engine(
merged_connect_args = dict(default_connect_args(database_url))
if connect_args:
merged_connect_args.update(connect_args)
return create_engine(database_url, pool_pre_ping=pool_pre_ping, connect_args=merged_connect_args, **kwargs)
return instrument_engine(create_engine(database_url, pool_pre_ping=pool_pre_ping, connect_args=merged_connect_args, **kwargs))
class DatabaseHandle:
def __init__(self, database_url: str, *, engine: Engine | None = None) -> None:
self.database_url = database_url
self.engine = engine or create_database_engine(database_url)
self.engine = instrument_engine(engine) if engine is not None else create_database_engine(database_url)
self.SessionLocal = sessionmaker(bind=self.engine, autoflush=False, autocommit=False, expire_on_commit=False)
def session(self) -> Session:

View File

@@ -67,6 +67,32 @@ class ImapConfig(ImapServerConfig):
password: str | None = None
def normalize_split_transport_credentials(value: object) -> object:
"""Move legacy transport username/password fields into credentials."""
if not isinstance(value, dict):
return value
data = dict(value)
credentials = data.get("credentials") if isinstance(data.get("credentials"), dict) else {}
credentials = {key: dict(item) for key, item in credentials.items() if isinstance(item, dict)}
for protocol in ("smtp", "imap"):
transport = data.get(protocol)
if not isinstance(transport, dict):
continue
next_transport = dict(transport)
next_credentials = dict(credentials.get(protocol) or {})
for field in ("username", "password"):
if field in next_transport and field not in next_credentials:
next_credentials[field] = next_transport[field]
next_transport.pop(field, None)
next_transport.pop("enabled", None)
data[protocol] = next_transport
if next_credentials:
credentials[protocol] = next_credentials
if credentials:
data["credentials"] = credentials
return data
def default_smtp_port(security: TransportSecurity | str | None) -> int:
if security == TransportSecurity.TLS or security == "tls":
return 465

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
"""Compatibility facade for privacy retention policy.
Policy-owned behavior is provided by ``govoplan-policy`` through the
@@ -7,27 +5,22 @@ Policy-owned behavior is provided by ``govoplan-policy`` through the
without importing policy implementation code from core.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Mapping
from govoplan_core.core.policy import CAPABILITY_POLICY_PRIVACY_RETENTION, PrivacyRetentionService
from govoplan_core.core.runtime import get_registry
from govoplan_core.privacy.schemas import (
RETENTION_DAY_KEYS as RETENTION_DAY_KEYS,
RETENTION_POLICY_FIELD_KEYS,
default_allow_lower_level_limits as default_allow_lower_level_limits,
normalize_allow_lower_level_limits,
)
PRIVACY_POLICY_SETTINGS_KEY = "privacy_retention_policy"
RETENTION_DAY_KEYS = (
"raw_campaign_json_retention_days",
"generated_eml_retention_days",
"stored_report_detail_retention_days",
"mock_mailbox_retention_days",
"audit_detail_retention_days",
)
RETENTION_POLICY_FIELD_KEYS = (
"store_raw_campaign_json",
*RETENTION_DAY_KEYS,
"audit_detail_level",
)
_DEFAULT_POLICY = {
"store_raw_campaign_json": True,
"raw_campaign_json_retention_days": None,
@@ -122,28 +115,10 @@ def _policy_data(settings_payload: Mapping[str, Any] | None) -> dict[str, Any]:
for key in RETENTION_POLICY_FIELD_KEYS:
if key in raw:
payload[key] = raw[key]
payload["allow_lower_level_limits"] = _normalize_allow_lower_level_limits(raw.get("allow_lower_level_limits"), fill_defaults=True)
payload["allow_lower_level_limits"] = normalize_allow_lower_level_limits(raw.get("allow_lower_level_limits"), fill_defaults=True)
return payload
def _normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
if value in (None, ""):
return default_allow_lower_level_limits() if fill_defaults else None
if not isinstance(value, Mapping):
raise ValueError("allow_lower_level_limits must be an object")
normalized = default_allow_lower_level_limits() if fill_defaults else {}
for key, allowed in value.items():
clean_key = str(key)
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
raise ValueError(f"Unknown retention policy field: {clean_key}")
normalized[clean_key] = bool(allowed)
return normalized
def default_allow_lower_level_limits() -> dict[str, bool]:
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
def privacy_policy_from_settings(item: object) -> Any:
service = _runtime_service()
if service is not None:

View File

@@ -0,0 +1,83 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
RETENTION_DAY_KEYS = (
"raw_campaign_json_retention_days",
"generated_eml_retention_days",
"stored_report_detail_retention_days",
"mock_mailbox_retention_days",
"audit_detail_retention_days",
)
RETENTION_POLICY_FIELD_KEYS = (
"store_raw_campaign_json",
*RETENTION_DAY_KEYS,
"audit_detail_level",
)
def default_allow_lower_level_limits() -> dict[str, bool]:
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
def normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
if value in (None, ""):
return default_allow_lower_level_limits() if fill_defaults else None
if not isinstance(value, dict):
raise ValueError("allow_lower_level_limits must be an object")
normalized = default_allow_lower_level_limits() if fill_defaults else {}
for key, allowed in value.items():
clean_key = str(key)
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
raise ValueError(f"Unknown retention policy field: {clean_key}")
normalized[clean_key] = bool(allowed)
return normalized
class PrivacyRetentionPolicyItem(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool = True
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] = "full"
allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits)
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return normalize_allow_lower_level_limits(value, fill_defaults=True)
class PrivacyRetentionPolicyPatchItem(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool | None = None
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] | None = None
allow_lower_level_limits: dict[str, bool] | None = None
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return normalize_allow_lower_level_limits(value, fill_defaults=False)
__all__ = [
"RETENTION_DAY_KEYS",
"RETENTION_POLICY_FIELD_KEYS",
"PrivacyRetentionPolicyItem",
"PrivacyRetentionPolicyPatchItem",
"default_allow_lower_level_limits",
"normalize_allow_lower_level_limits",
]

View File

@@ -0,0 +1,66 @@
from __future__ import annotations
import urllib.parse
import urllib.request
from dataclasses import dataclass
from typing import Mapping
@dataclass(frozen=True, slots=True)
class HttpFetchResponse:
status: int
headers: dict[str, str]
body: bytes
def text(self, encoding: str = "utf-8") -> str:
return self.body.decode(encoding)
def is_http_url(value: str) -> bool:
try:
validate_http_url(value)
except ValueError:
return False
return True
def validate_http_url(value: str, *, label: str = "URL") -> str:
parsed = urllib.parse.urlparse(str(value).strip())
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError(f"{label} must be an absolute HTTP(S) URL.")
if parsed.username or parsed.password:
raise ValueError(f"{label} must not include embedded credentials.")
return urllib.parse.urlunparse(parsed)
def fetch_http(
url: str,
*,
timeout: float = 15,
label: str = "URL",
method: str = "GET",
headers: Mapping[str, str] | None = None,
) -> HttpFetchResponse:
request = urllib.request.Request(
validate_http_url(url, label=label),
headers=dict(headers or {}),
method=method,
)
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - URL is validated by validate_http_url. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
return HttpFetchResponse(
status=int(getattr(response, "status", 0)),
headers=dict(response.headers.items()),
body=response.read(),
)
def fetch_http_text(
url: str,
*,
timeout: float = 15,
label: str = "URL",
method: str = "GET",
headers: Mapping[str, str] | None = None,
encoding: str = "utf-8",
) -> str:
return fetch_http(url, timeout=timeout, label=label, method=method, headers=headers).text(encoding)

View File

@@ -95,6 +95,11 @@ MODULE_SYSTEM_SCOPES = frozenset(
for legacy, module in LEGACY_TO_MODULE_SCOPES.items()
if legacy.startswith("system:")
)
LEGACY_SYSTEM_SCOPES = frozenset(
legacy
for legacy in LEGACY_TO_MODULE_SCOPES
if legacy.startswith("system:")
)
MODULE_TENANT_SCOPES = frozenset(LEGACY_TO_MODULE_SCOPES.values()) - MODULE_SYSTEM_SCOPES
@@ -110,7 +115,7 @@ def compatible_required_scopes(required: str) -> tuple[str, ...]:
def scopes_grant_compatible(scopes: Iterable[str], required: str) -> bool:
granted = list(scopes)
if required in MODULE_SYSTEM_SCOPES:
if required in MODULE_SYSTEM_SCOPES or required in LEGACY_SYSTEM_SCOPES:
return "*" in granted or "system:*" in granted or any(
scope != "tenant:*" and scopes_grant([scope], alias)
for scope in granted

View File

@@ -3,6 +3,8 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable
from govoplan_core.security.scope_aliases import LEGACY_SCOPE_ALIASES
@dataclass(frozen=True, slots=True)
class PermissionDefinition:
@@ -96,28 +98,6 @@ KNOWN_SCOPES = frozenset(item.scope for item in ALL_PERMISSIONS)
TENANT_SCOPES = frozenset(item.scope for item in TENANT_PERMISSIONS)
SYSTEM_SCOPES = frozenset(item.scope for item in SYSTEM_PERMISSIONS)
LEGACY_SCOPE_ALIASES: dict[str, frozenset[str]] = {
# Only names that are no longer canonical remain runtime aliases. Canonical
# permissions keep their narrow meaning; the Alembic migration expands old
# role records once so upgraded installations do not lose prior access.
"campaign:write": frozenset({
"campaign:create", "campaign:update", "campaign:copy", "campaign:archive", "campaign:delete", "campaign:share",
"recipients:read", "recipients:write", "recipients:import",
}),
"attachments:read": frozenset({"files:read", "files:download"}),
"attachments:write": frozenset({"files:upload", "files:organize", "files:share", "files:delete"}),
"admin:users": frozenset({
"admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend",
"admin:groups:read", "admin:groups:write", "admin:groups:manage_members",
"admin:roles:read", "admin:roles:write", "admin:roles:assign",
}),
"admin:users:write": frozenset({"admin:users:create", "admin:users:update", "admin:users:suspend", "admin:roles:assign", "admin:groups:manage_members"}),
"admin:api_keys:write": frozenset({"admin:api_keys:create", "admin:api_keys:revoke"}),
"admin:settings": frozenset({"admin:settings:read", "admin:settings:write", "admin:api_keys:read", "admin:api_keys:create", "admin:api_keys:revoke"}),
"system:tenants:write": frozenset({"system:tenants:create", "system:tenants:update", "system:tenants:suspend"}),
"system:access:write": frozenset({"system:access:assign", "system:roles:assign", "system:accounts:create", "system:accounts:update", "system:accounts:suspend"}),
}
DEFAULT_TENANT_ROLES: dict[str, dict[str, object]] = {
"owner": {"name": "Owner", "description": "Full tenant access, including administration and delivery.", "permissions": ["tenant:*"], "is_builtin": True, "is_assignable": True},
"tenant_admin": {"name": "Tenant administrator", "description": "Manage tenant settings, users, groups and roles. Real delivery remains separately delegable.", "permissions": [

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
LEGACY_SCOPE_ALIASES: dict[str, frozenset[str]] = {
# Only names that are no longer canonical remain runtime aliases. Canonical
# permissions keep their narrow meaning; migrations expand old role records
# once so upgraded installations do not lose prior access.
"campaign:write": frozenset({
"campaign:create",
"campaign:update",
"campaign:copy",
"campaign:archive",
"campaign:delete",
"campaign:share",
"recipients:read",
"recipients:write",
"recipients:import",
}),
"attachments:read": frozenset({"files:read", "files:download"}),
"attachments:write": frozenset({"files:upload", "files:organize", "files:share", "files:delete"}),
"admin:users": frozenset({
"admin:users:read",
"admin:users:create",
"admin:users:update",
"admin:users:suspend",
"admin:groups:read",
"admin:groups:write",
"admin:groups:manage_members",
"admin:roles:read",
"admin:roles:write",
"admin:roles:assign",
}),
"admin:users:write": frozenset({
"admin:users:create",
"admin:users:update",
"admin:users:suspend",
"admin:roles:assign",
"admin:groups:manage_members",
}),
"admin:api_keys:write": frozenset({"admin:api_keys:create", "admin:api_keys:revoke"}),
"admin:settings": frozenset({
"admin:settings:read",
"admin:settings:write",
"admin:api_keys:read",
"admin:api_keys:create",
"admin:api_keys:revoke",
}),
"system:tenants:write": frozenset({"system:tenants:create", "system:tenants:update", "system:tenants:suspend"}),
"system:access:write": frozenset({
"system:access:assign",
"system:roles:assign",
"system:accounts:create",
"system:accounts:update",
"system:accounts:suspend",
}),
}

View File

@@ -38,8 +38,8 @@ def _normalize_fernet_key(value: str) -> bytes:
try:
Fernet(candidate)
return candidate
except Exception:
pass
except (TypeError, ValueError):
raw = None
try:
raw = base64.b64decode(candidate)
except Exception as exc:

View File

@@ -13,52 +13,13 @@ from govoplan_core.server.route_validation import validate_no_route_collisions
def create_app(config: GovoplanServerConfig | str | None = None):
if isinstance(config, str) or config is None:
server_config = load_server_config(config)
else:
server_config = config
database_url = getattr(server_config.settings, "database_url", None) if server_config.settings is not None else None
if database_url:
dispose_previous = getattr(server_config.settings, "app_env", None) == "test"
configure_database(str(database_url), dispose_previous=dispose_previous)
raw_enabled_modules = load_startup_enabled_modules(server_config.enabled_modules)
candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
startup_available_modules = available_module_manifests(
server_config.manifest_factories,
enabled_modules=candidate_modules,
ignore_load_errors=True,
)
available_modules = available_module_manifests(server_config.manifest_factories, ignore_load_errors=True)
enabled_modules = load_startup_enabled_modules(server_config.enabled_modules, available=startup_available_modules)
registry = build_platform_registry(enabled_modules, manifest_factories=server_config.manifest_factories)
api_router = APIRouter(prefix=server_config.api_prefix)
for router in server_config.base_routers:
api_router.include_router(router)
lifecycle = ModuleLifecycleManager(
registry=registry,
available_modules=available_modules,
settings=server_config.settings,
api_prefix=server_config.api_prefix,
manifest_factories=tuple(server_config.manifest_factories),
module_context_data=server_config.module_context_data,
)
server_config = _server_config(config)
_configure_app_database(server_config)
registry, available_modules = _server_module_registry(server_config)
api_router = _server_api_router(server_config, registry)
lifecycle = _server_lifecycle(server_config, registry=registry, available_modules=available_modules)
lifecycle.configure_runtime()
api_router.include_router(create_platform_router(settings=server_config.settings))
for router in server_config.post_module_routers:
api_router.include_router(router)
for contribution in server_config.extra_routers:
if contribution.should_include(server_config.settings, registry):
api_router.include_router(contribution.router)
validate_no_route_collisions(api_router, owner="server startup routes")
app = create_govoplan_app(
title=server_config.title,
version=server_config.version,
@@ -74,4 +35,61 @@ def create_app(config: GovoplanServerConfig | str | None = None):
return app
def _server_config(config: GovoplanServerConfig | str | None) -> GovoplanServerConfig:
if isinstance(config, str) or config is None:
return load_server_config(config)
return config
def _configure_app_database(server_config: GovoplanServerConfig) -> None:
database_url = getattr(server_config.settings, "database_url", None) if server_config.settings is not None else None
if database_url:
dispose_previous = getattr(server_config.settings, "app_env", None) == "test"
configure_database(str(database_url), dispose_previous=dispose_previous)
def _server_module_registry(server_config: GovoplanServerConfig):
raw_enabled_modules = load_startup_enabled_modules(server_config.enabled_modules)
candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
startup_available_modules = available_module_manifests(
server_config.manifest_factories,
enabled_modules=candidate_modules,
ignore_load_errors=True,
)
available_modules = available_module_manifests(server_config.manifest_factories, ignore_load_errors=True)
enabled_modules = load_startup_enabled_modules(server_config.enabled_modules, available=startup_available_modules)
registry = build_platform_registry(enabled_modules, manifest_factories=server_config.manifest_factories)
return registry, available_modules
def _server_api_router(server_config: GovoplanServerConfig, registry) -> APIRouter:
api_router = APIRouter(prefix=server_config.api_prefix)
for router in server_config.base_routers:
api_router.include_router(router)
api_router.include_router(create_platform_router(settings=server_config.settings))
for router in server_config.post_module_routers:
api_router.include_router(router)
for contribution in server_config.extra_routers:
if contribution.should_include(server_config.settings, registry):
api_router.include_router(contribution.router)
validate_no_route_collisions(api_router, owner="server startup routes")
return api_router
def _server_lifecycle(
server_config: GovoplanServerConfig,
*,
registry,
available_modules,
) -> ModuleLifecycleManager:
return ModuleLifecycleManager(
registry=registry,
available_modules=available_modules,
settings=server_config.settings,
api_prefix=server_config.api_prefix,
manifest_factories=tuple(server_config.manifest_factories),
module_context_data=server_config.module_context_data,
)
app = create_app()

View File

@@ -1,5 +1,8 @@
from __future__ import annotations
import logging
import os
import time
from collections.abc import AsyncIterator, Callable, Iterable
from contextlib import AbstractAsyncContextManager
from typing import Any
@@ -9,9 +12,19 @@ from fastapi.middleware.cors import CORSMiddleware
from govoplan_core.core.events import event_context, new_event_id, normalize_trace_id
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.query_metrics import collect_query_metrics
from govoplan_core.server.conditional_requests import conditional_json_get_middleware
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
logger = logging.getLogger("govoplan.request")
def _slow_request_threshold_ms() -> float:
raw = os.getenv("GOVOPLAN_SLOW_REQUEST_MS", "500").strip()
try:
return float(raw)
except ValueError:
return 500.0
def create_govoplan_app(
@@ -26,6 +39,7 @@ def create_govoplan_app(
) -> FastAPI:
app = FastAPI(title=title, version=version, lifespan=lifespan)
app.state.govoplan_registry = registry
slow_request_threshold_ms = _slow_request_threshold_ms()
@app.middleware("http")
async def request_correlation_context(request: Request, call_next):
@@ -39,6 +53,42 @@ def create_govoplan_app(
response.headers["X-Correlation-ID"] = correlation_id
return response
@app.middleware("http")
async def slow_request_logging(request: Request, call_next):
started_at = time.perf_counter()
with collect_query_metrics() as db_metrics:
try:
response = await call_next(request)
except Exception:
elapsed_ms = (time.perf_counter() - started_at) * 1000
if slow_request_threshold_ms > 0 and elapsed_ms >= slow_request_threshold_ms:
logger.warning(
"slow request failed method=%s path=%s duration_ms=%.1f db_query_count=%s db_time_ms=%.1f db_slowest_ms=%.1f db_error_count=%s",
request.method,
request.url.path,
elapsed_ms,
db_metrics.query_count,
db_metrics.total_ms,
db_metrics.slowest_ms,
db_metrics.error_count,
exc_info=True,
)
raise
elapsed_ms = (time.perf_counter() - started_at) * 1000
if slow_request_threshold_ms > 0 and elapsed_ms >= slow_request_threshold_ms:
logger.warning(
"slow request method=%s path=%s status=%s duration_ms=%.1f db_query_count=%s db_time_ms=%.1f db_slowest_ms=%.1f db_error_count=%s",
request.method,
request.url.path,
response.status_code,
elapsed_ms,
db_metrics.query_count,
db_metrics.total_ms,
db_metrics.slowest_ms,
db_metrics.error_count,
)
return response
app.middleware("http")(conditional_json_get_middleware)
origins = [item.strip() for item in cors_origins if item.strip()]

View File

@@ -17,15 +17,15 @@ class Settings(BaseSettings):
tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE")
tenant_db_url_template: str | None = Field(default=None, alias="TENANT_DB_URL_TEMPLATE")
tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE")
enabled_modules: str = Field(default="tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops", alias="ENABLED_MODULES")
enabled_modules: str = Field(default="tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,notifications,docs,ops", alias="ENABLED_MODULES")
migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK")
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
s3_endpoint_url: str = Field(default="http://garage:3900", alias="S3_ENDPOINT_URL")
s3_region: str = Field(default="garage", alias="S3_REGION")
s3_access_key_id: str = Field(default="GKmultimailerdev0000000000000000", alias="S3_ACCESS_KEY_ID")
s3_secret_access_key: str = Field(default="multimailer-dev-secret-change-me", alias="S3_SECRET_ACCESS_KEY")
s3_access_key_id: str = Field(default="GKgovoplandev0000000000000000000", alias="S3_ACCESS_KEY_ID")
s3_secret_access_key: str = Field(default="govoplan-dev-secret-change-me", alias="S3_SECRET_ACCESS_KEY")
s3_bucket: str = Field(default="attachments", alias="S3_BUCKET")
# Managed file storage. Development defaults to local filesystem storage;
@@ -41,19 +41,19 @@ class Settings(BaseSettings):
file_upload_max_bytes: int = Field(default=50 * 1024 * 1024, alias="FILE_UPLOAD_MAX_BYTES")
file_upload_zip_max_bytes: int = Field(default=250 * 1024 * 1024, alias="FILE_UPLOAD_ZIP_MAX_BYTES")
auth_session_cookie_name: str = Field(default="msm_session", alias="AUTH_SESSION_COOKIE_NAME")
auth_csrf_cookie_name: str = Field(default="msm_csrf", alias="AUTH_CSRF_COOKIE_NAME")
auth_session_cookie_name: str = Field(default="govoplan_session", alias="AUTH_SESSION_COOKIE_NAME")
auth_csrf_cookie_name: str = Field(default="govoplan_csrf", alias="AUTH_CSRF_COOKIE_NAME")
auth_cookie_secure: bool = Field(default=False, alias="AUTH_COOKIE_SECURE")
auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE")
auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN")
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS")
master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64")
celery_queues: str = Field(default="send_email,append_sent,default", alias="CELERY_QUEUES")
celery_queues: str = Field(default="send_email,append_sent,notifications,default", alias="CELERY_QUEUES")
mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR")
# Development bootstrap only. Do not use this in production.
dev_bootstrap_api_key: str | None = Field(default="dev-multimailer-api-key", alias="DEV_BOOTSTRAP_API_KEY")
dev_bootstrap_api_key: str | None = Field(default="dev-govoplan-api-key", alias="DEV_BOOTSTRAP_API_KEY")
dev_auto_migrate_enabled: bool = Field(default=True, alias="DEV_AUTO_MIGRATE_ENABLED")
dev_bootstrap_enabled: bool = Field(default=False, alias="DEV_BOOTSTRAP_ENABLED")
dev_bootstrap_password: str = Field(default="dev-admin", alias="DEV_BOOTSTRAP_PASSWORD")