intermittent commit
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -8,8 +8,6 @@ from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Literal, Protocol, runtime_checkable
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from govoplan_core.core.module_package_catalog import (
|
||||
_canonical_catalog_bytes,
|
||||
@@ -23,6 +21,7 @@ from govoplan_core.core.module_package_catalog import (
|
||||
_load_private_key,
|
||||
_parse_trusted_keys,
|
||||
)
|
||||
from govoplan_core.security.http_fetch import fetch_http_text
|
||||
|
||||
|
||||
CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider"
|
||||
@@ -31,6 +30,20 @@ DiagnosticSeverity = Literal["blocker", "warning", "info"]
|
||||
PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ConfigurationCatalogValidationState:
|
||||
packages: tuple[dict[str, object], ...]
|
||||
channel: str | None
|
||||
sequence: int | None
|
||||
generated_at: str | None
|
||||
not_before: str | None
|
||||
expires_at: str | None
|
||||
signature_state: dict[str, object]
|
||||
freshness: dict[str, object]
|
||||
replay: dict[str, object]
|
||||
read_state: dict[str, object]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationModuleRequirement:
|
||||
module_id: str
|
||||
@@ -461,55 +474,148 @@ def validate_configuration_package_catalog(
|
||||
configured = source is not None and _catalog_source_exists(source)
|
||||
if source is not None and not _catalog_source_exists(source):
|
||||
return _validation_result(source, configured=True, packages=(), error=f"Configuration package catalog does not exist: {source}")
|
||||
read_state = {"cache_used": False, "cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() else None}
|
||||
read_state = _configuration_catalog_default_read_state()
|
||||
try:
|
||||
payload, read_state = _read_catalog_payload_with_metadata(source)
|
||||
packages = _normalize_catalog_packages(payload)
|
||||
channel = _catalog_channel(payload)
|
||||
sequence = _catalog_sequence(payload)
|
||||
generated_at = _catalog_optional_text(payload, "generated_at")
|
||||
not_before = _catalog_optional_text(payload, "not_before")
|
||||
expires_at = _catalog_optional_text(payload, "expires_at")
|
||||
effective_require_trusted = _configured_require_signature() if require_trusted is None else require_trusted
|
||||
effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels
|
||||
effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys()
|
||||
signature_state = _catalog_signature_state(payload, trusted_keys=effective_trusted_keys)
|
||||
freshness = _catalog_freshness_state(payload)
|
||||
replay = _catalog_replay_state(channel=channel, sequence=sequence)
|
||||
state = _configuration_catalog_validation_state(source, trusted_keys=effective_trusted_keys)
|
||||
read_state = state.read_state
|
||||
except Exception as exc:
|
||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), error=str(exc))
|
||||
if signature_state.get("fatal"):
|
||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(signature_state["error"]))
|
||||
if effective_approved_channels and channel not in effective_approved_channels:
|
||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=f"Configuration package catalog channel {channel!r} is not approved. Approved channels: {', '.join(effective_approved_channels)}.")
|
||||
if effective_require_trusted and not signature_state["trusted"]:
|
||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(signature_state["error"] or "Configuration package catalog must be signed by a trusted key."))
|
||||
if not freshness["valid"]:
|
||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(freshness["error"]))
|
||||
if not replay["valid"]:
|
||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(replay["error"]))
|
||||
warnings = [str(item) for item in freshness.get("warnings", ()) if item]
|
||||
warnings.extend(str(item) for item in replay.get("warnings", ()) if item)
|
||||
if not signature_state["signed"]:
|
||||
warnings.append("Configuration package catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
|
||||
elif not signature_state["trusted"]:
|
||||
warnings.append(str(signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
|
||||
policy_error = _configuration_catalog_policy_error(
|
||||
source,
|
||||
configured=configured,
|
||||
state=state,
|
||||
require_trusted=effective_require_trusted,
|
||||
approved_channels=effective_approved_channels,
|
||||
)
|
||||
if policy_error is not None:
|
||||
return policy_error
|
||||
return _validation_result(
|
||||
source,
|
||||
valid=True,
|
||||
configured=configured,
|
||||
read_state=read_state,
|
||||
packages=packages,
|
||||
read_state=state.read_state,
|
||||
packages=state.packages,
|
||||
channel=state.channel,
|
||||
sequence=state.sequence,
|
||||
generated_at=state.generated_at,
|
||||
not_before=state.not_before,
|
||||
expires_at=state.expires_at,
|
||||
signature_state=state.signature_state,
|
||||
warnings=_configuration_catalog_warnings(state),
|
||||
)
|
||||
|
||||
|
||||
def _configuration_catalog_validation_state(
|
||||
source: Path | str | None,
|
||||
*,
|
||||
trusted_keys: dict[str, str],
|
||||
) -> _ConfigurationCatalogValidationState:
|
||||
payload, read_state = _read_catalog_payload_with_metadata(source)
|
||||
channel = _catalog_channel(payload)
|
||||
sequence = _catalog_sequence(payload)
|
||||
return _ConfigurationCatalogValidationState(
|
||||
packages=_normalize_catalog_packages(payload),
|
||||
channel=channel,
|
||||
sequence=sequence,
|
||||
generated_at=generated_at,
|
||||
not_before=not_before,
|
||||
expires_at=expires_at,
|
||||
signature_state=signature_state,
|
||||
warnings=warnings,
|
||||
generated_at=_catalog_optional_text(payload, "generated_at"),
|
||||
not_before=_catalog_optional_text(payload, "not_before"),
|
||||
expires_at=_catalog_optional_text(payload, "expires_at"),
|
||||
signature_state=_catalog_signature_state(payload, trusted_keys=trusted_keys),
|
||||
freshness=_catalog_freshness_state(payload),
|
||||
replay=_catalog_replay_state(channel=channel, sequence=sequence),
|
||||
read_state=read_state,
|
||||
)
|
||||
|
||||
|
||||
def _configuration_catalog_policy_error(
|
||||
source: Path | str | None,
|
||||
*,
|
||||
configured: bool,
|
||||
state: _ConfigurationCatalogValidationState,
|
||||
require_trusted: bool,
|
||||
approved_channels: tuple[str, ...],
|
||||
) -> dict[str, object] | None:
|
||||
if state.signature_state.get("fatal"):
|
||||
return _configuration_catalog_invalid_result(
|
||||
source,
|
||||
configured=configured,
|
||||
state=state,
|
||||
error=str(state.signature_state["error"]),
|
||||
)
|
||||
if approved_channels and state.channel not in approved_channels:
|
||||
return _configuration_catalog_invalid_result(
|
||||
source,
|
||||
configured=configured,
|
||||
state=state,
|
||||
error=(
|
||||
f"Configuration package catalog channel {state.channel!r} is not approved. "
|
||||
f"Approved channels: {', '.join(approved_channels)}."
|
||||
),
|
||||
)
|
||||
if require_trusted and not state.signature_state["trusted"]:
|
||||
return _configuration_catalog_invalid_result(
|
||||
source,
|
||||
configured=configured,
|
||||
state=state,
|
||||
error=str(state.signature_state["error"] or "Configuration package catalog must be signed by a trusted key."),
|
||||
)
|
||||
if not state.freshness["valid"]:
|
||||
return _configuration_catalog_invalid_result(
|
||||
source,
|
||||
configured=configured,
|
||||
state=state,
|
||||
error=str(state.freshness["error"]),
|
||||
)
|
||||
if not state.replay["valid"]:
|
||||
return _configuration_catalog_invalid_result(
|
||||
source,
|
||||
configured=configured,
|
||||
state=state,
|
||||
error=str(state.replay["error"]),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _configuration_catalog_invalid_result(
|
||||
source: Path | str | None,
|
||||
*,
|
||||
configured: bool,
|
||||
state: _ConfigurationCatalogValidationState,
|
||||
error: str,
|
||||
) -> dict[str, object]:
|
||||
return _validation_result(
|
||||
source,
|
||||
configured=configured,
|
||||
read_state=state.read_state,
|
||||
packages=(),
|
||||
channel=state.channel,
|
||||
sequence=state.sequence,
|
||||
generated_at=state.generated_at,
|
||||
not_before=state.not_before,
|
||||
expires_at=state.expires_at,
|
||||
signature_state=state.signature_state,
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
def _configuration_catalog_warnings(state: _ConfigurationCatalogValidationState) -> list[str]:
|
||||
warnings = [str(item) for item in state.freshness.get("warnings", ()) if item]
|
||||
warnings.extend(str(item) for item in state.replay.get("warnings", ()) if item)
|
||||
if not state.signature_state["signed"]:
|
||||
warnings.append("Configuration package catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
|
||||
elif not state.signature_state["trusted"]:
|
||||
warnings.append(str(state.signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
|
||||
return warnings
|
||||
|
||||
|
||||
def _configuration_catalog_default_read_state() -> dict[str, object]:
|
||||
cache_path = _configured_catalog_cache_path()
|
||||
return {"cache_used": False, "cache_path": str(cache_path) if cache_path else None}
|
||||
|
||||
|
||||
def sign_configuration_package_catalog(*, path: Path, key_id: str, private_key_path: Path, output_path: Path | None = None) -> Path:
|
||||
payload = _read_catalog_payload(path)
|
||||
if not isinstance(payload, dict):
|
||||
@@ -686,17 +792,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
|
||||
|
||||
|
||||
def _read_trusted_keys_url(url: str) -> str:
|
||||
if not _is_http_url(url):
|
||||
raise ValueError("Trusted configuration catalog key URL must use http:// or https://.")
|
||||
cache_path = _configured_trusted_keys_cache_path()
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=15) as response: # noqa: S310 - validated configuration key HTTP(S) URL. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
||||
body = response.read().decode("utf-8")
|
||||
body = fetch_http_text(url, timeout=15, label="Trusted configuration catalog key URL")
|
||||
if cache_path is not None:
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_path.write_text(body, encoding="utf-8")
|
||||
return body
|
||||
except (OSError, urllib.error.URLError):
|
||||
except OSError:
|
||||
if cache_path is not None and cache_path.exists():
|
||||
return cache_path.read_text(encoding="utf-8")
|
||||
raise
|
||||
@@ -714,13 +817,12 @@ def _read_catalog_payload_with_metadata(source: Path | str | None) -> tuple[obje
|
||||
return {"packages": []}, metadata
|
||||
if isinstance(source, str) and _is_http_url(source):
|
||||
try:
|
||||
with urllib.request.urlopen(source, timeout=15) as response: # noqa: S310 - validated configuration catalog HTTP(S) URL. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
||||
body = response.read().decode("utf-8")
|
||||
body = fetch_http_text(source, timeout=15, label="Configuration package catalog URL")
|
||||
if cache_path is not None:
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_path.write_text(body, encoding="utf-8")
|
||||
return json.loads(body), metadata
|
||||
except (OSError, urllib.error.URLError):
|
||||
except OSError:
|
||||
if cache_path is not None and cache_path.exists():
|
||||
metadata["cache_used"] = True
|
||||
return json.loads(cache_path.read_text(encoding="utf-8")), metadata
|
||||
|
||||
@@ -97,6 +97,16 @@ class ConfigurationChangeSafetyPlan:
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ConfigurationChangeSafetyState:
|
||||
field: ConfigurationFieldSafety
|
||||
missing_scopes: tuple[str, ...]
|
||||
blockers: tuple[str, ...]
|
||||
warnings: tuple[str, ...]
|
||||
approval_required: bool
|
||||
approval_satisfied: bool
|
||||
|
||||
|
||||
_CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||
ConfigurationFieldSafety(
|
||||
key="module_management.desired_enabled",
|
||||
@@ -339,23 +349,56 @@ def plan_configuration_change(
|
||||
) -> ConfigurationChangeSafetyPlan:
|
||||
field = classify_configuration_field(key)
|
||||
if field is None:
|
||||
return ConfigurationChangeSafetyPlan(
|
||||
key=key,
|
||||
allowed=False,
|
||||
field=None,
|
||||
blockers=("unknown_configuration_field",),
|
||||
policy_explanation="This setting is not in the configuration safety catalog.",
|
||||
)
|
||||
return _unknown_configuration_plan(key)
|
||||
if not include_env_only and not field.ui_managed:
|
||||
return ConfigurationChangeSafetyPlan(
|
||||
key=key,
|
||||
allowed=False,
|
||||
field=field,
|
||||
risk=field.risk,
|
||||
secret_handling=field.secret_handling,
|
||||
blockers=("deployment_managed",),
|
||||
policy_explanation="This setting is deployment-managed and cannot be edited through the UI.",
|
||||
)
|
||||
return _deployment_managed_configuration_plan(key, field)
|
||||
state = _configuration_change_safety_state(
|
||||
field,
|
||||
actor_scopes=actor_scopes,
|
||||
value=value,
|
||||
dry_run=dry_run,
|
||||
maintenance_mode=maintenance_mode,
|
||||
approval_count=approval_count,
|
||||
)
|
||||
return _configuration_change_safety_plan(
|
||||
field,
|
||||
state=state,
|
||||
dry_run=dry_run,
|
||||
maintenance_mode=maintenance_mode,
|
||||
)
|
||||
|
||||
|
||||
def _unknown_configuration_plan(key: str) -> ConfigurationChangeSafetyPlan:
|
||||
return ConfigurationChangeSafetyPlan(
|
||||
key=key,
|
||||
allowed=False,
|
||||
field=None,
|
||||
blockers=("unknown_configuration_field",),
|
||||
policy_explanation="This setting is not in the configuration safety catalog.",
|
||||
)
|
||||
|
||||
|
||||
def _deployment_managed_configuration_plan(key: str, field: ConfigurationFieldSafety) -> ConfigurationChangeSafetyPlan:
|
||||
return ConfigurationChangeSafetyPlan(
|
||||
key=key,
|
||||
allowed=False,
|
||||
field=field,
|
||||
risk=field.risk,
|
||||
secret_handling=field.secret_handling,
|
||||
blockers=("deployment_managed",),
|
||||
policy_explanation="This setting is deployment-managed and cannot be edited through the UI.",
|
||||
)
|
||||
|
||||
|
||||
def _configuration_change_safety_state(
|
||||
field: ConfigurationFieldSafety,
|
||||
*,
|
||||
actor_scopes: tuple[str, ...] | list[str],
|
||||
value: object,
|
||||
dry_run: bool,
|
||||
maintenance_mode: bool,
|
||||
approval_count: int,
|
||||
) -> _ConfigurationChangeSafetyState:
|
||||
blockers: list[str] = []
|
||||
warnings: list[str] = []
|
||||
missing_scopes = tuple(scope for scope in field.required_scopes if not scopes_grant(actor_scopes, scope))
|
||||
@@ -377,24 +420,41 @@ def plan_configuration_change(
|
||||
blockers.append("env_only_secret")
|
||||
if field.rollback_history_required:
|
||||
warnings.append("rollback_history_required")
|
||||
return ConfigurationChangeSafetyPlan(
|
||||
key=field.key,
|
||||
allowed=not blockers,
|
||||
return _ConfigurationChangeSafetyState(
|
||||
field=field,
|
||||
risk=field.risk,
|
||||
missing_scopes=missing_scopes,
|
||||
dry_run_required=field.dry_run_required,
|
||||
dry_run_satisfied=not field.dry_run_required or dry_run,
|
||||
blockers=tuple(dict.fromkeys(blockers)),
|
||||
warnings=tuple(dict.fromkeys(warnings)),
|
||||
approval_required=approval_required,
|
||||
approval_satisfied=approval_satisfied,
|
||||
)
|
||||
|
||||
|
||||
def _configuration_change_safety_plan(
|
||||
field: ConfigurationFieldSafety,
|
||||
*,
|
||||
state: _ConfigurationChangeSafetyState,
|
||||
dry_run: bool,
|
||||
maintenance_mode: bool,
|
||||
) -> ConfigurationChangeSafetyPlan:
|
||||
return ConfigurationChangeSafetyPlan(
|
||||
key=field.key,
|
||||
allowed=not state.blockers,
|
||||
field=field,
|
||||
risk=field.risk,
|
||||
missing_scopes=state.missing_scopes,
|
||||
dry_run_required=field.dry_run_required,
|
||||
dry_run_satisfied=not field.dry_run_required or dry_run,
|
||||
approval_required=state.approval_required,
|
||||
approval_satisfied=state.approval_satisfied,
|
||||
maintenance_required=field.maintenance_required,
|
||||
maintenance_satisfied=not field.maintenance_required or maintenance_mode,
|
||||
rollback_history_required=field.rollback_history_required,
|
||||
secret_handling=field.secret_handling,
|
||||
audit_event=field.audit_event,
|
||||
policy_explanation=_policy_explanation(field),
|
||||
blockers=tuple(dict.fromkeys(blockers)),
|
||||
warnings=tuple(dict.fromkeys(warnings)),
|
||||
blockers=state.blockers,
|
||||
warnings=state.warnings,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -72,6 +72,25 @@ class ConfigValidationResult:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _RuntimeProfile:
|
||||
name: str
|
||||
production: bool
|
||||
production_like: bool
|
||||
local: bool
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _ConfigIssueCollector:
|
||||
strict: bool
|
||||
issues: list[ConfigIssue]
|
||||
|
||||
def add(self, level: ConfigIssueLevel, key: str, message: str, action: str) -> None:
|
||||
if self.strict and level == "warning":
|
||||
level = "error"
|
||||
self.issues.append(ConfigIssue(level=level, key=key, message=message, action=action))
|
||||
|
||||
|
||||
_LOCAL_PROFILES = {"dev", "local", "local-dev", "test"}
|
||||
_PRODUCTION_PROFILES = {"prod", "production", "self-hosted"}
|
||||
_PRODUCTION_LIKE_PROFILES = {"staging", "production-like", "production-like-dev"}
|
||||
@@ -114,95 +133,130 @@ def validate_runtime_configuration(
|
||||
strict: bool = False,
|
||||
) -> ConfigValidationResult:
|
||||
env = dict(os.environ if environ is None else environ)
|
||||
clean_profile = normalize_install_profile(profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV"))
|
||||
issues: list[ConfigIssue] = []
|
||||
runtime = _runtime_profile(env, profile=profile)
|
||||
collector = _ConfigIssueCollector(strict=strict, issues=[])
|
||||
_validate_app_env(env, runtime, collector)
|
||||
_validate_database_settings(env, runtime, collector)
|
||||
_validate_master_key(env, runtime, collector)
|
||||
_validate_enabled_modules(env, runtime, collector)
|
||||
_validate_async_and_auth_settings(env, runtime, collector)
|
||||
_validate_cors_settings(env, runtime, collector)
|
||||
_validate_file_storage_settings(env, runtime, collector)
|
||||
_validate_module_catalog_trust(env, runtime, collector)
|
||||
return ConfigValidationResult(profile=runtime.name, issues=tuple(collector.issues))
|
||||
|
||||
|
||||
def _runtime_profile(env: Mapping[str, str], *, profile: str | None) -> _RuntimeProfile:
|
||||
clean_profile = normalize_install_profile(profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV"))
|
||||
production = clean_profile in _PRODUCTION_PROFILES or env.get("APP_ENV", "").strip().lower() in {"prod", "production"}
|
||||
production_like = production or clean_profile in _PRODUCTION_LIKE_PROFILES
|
||||
local = clean_profile in _LOCAL_PROFILES and not production_like
|
||||
return _RuntimeProfile(
|
||||
name=clean_profile,
|
||||
production=production,
|
||||
production_like=production_like,
|
||||
local=clean_profile in _LOCAL_PROFILES and not production_like,
|
||||
)
|
||||
|
||||
def issue(level: ConfigIssueLevel, key: str, message: str, action: str) -> None:
|
||||
if strict and level == "warning":
|
||||
level = "error"
|
||||
issues.append(ConfigIssue(level=level, key=key, message=message, action=action))
|
||||
|
||||
def _validate_app_env(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||
app_env = _clean(env.get("APP_ENV"))
|
||||
if not app_env and production_like:
|
||||
issue("error", "APP_ENV", "APP_ENV is missing for a production-like install.", "Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.")
|
||||
elif app_env.lower() in {"dev", "test", "local"} and production_like:
|
||||
issue("error", "APP_ENV", f"APP_ENV={app_env!r} is not valid for profile {clean_profile!r}.", "Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.")
|
||||
if not app_env and runtime.production_like:
|
||||
collector.add("error", "APP_ENV", "APP_ENV is missing for a production-like install.", "Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.")
|
||||
elif app_env.lower() in {"dev", "test", "local"} and runtime.production_like:
|
||||
collector.add("error", "APP_ENV", f"APP_ENV={app_env!r} is not valid for profile {runtime.name!r}.", "Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.")
|
||||
|
||||
|
||||
def _validate_database_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||
database_url = _clean(env.get("DATABASE_URL"))
|
||||
if not database_url:
|
||||
issue("error", "DATABASE_URL", "DATABASE_URL is missing.", "Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.")
|
||||
else:
|
||||
backend = _database_backend(database_url)
|
||||
if backend is None:
|
||||
issue("error", "DATABASE_URL", "DATABASE_URL is not a valid SQLAlchemy URL.", "Use a value like postgresql+psycopg://user:password@host:5432/database.")
|
||||
elif backend == "sqlite" and production_like:
|
||||
issue("error", "DATABASE_URL", "SQLite is only supported for disposable local development.", "Use PostgreSQL for production-like and self-hosted installs.")
|
||||
elif backend != "postgresql" and production:
|
||||
issue("warning", "DATABASE_URL", f"Database backend {backend!r} is not the preferred production target.", "Use PostgreSQL unless this deployment has an explicit support decision.")
|
||||
if backend == "postgresql" and not _clean(env.get("GOVOPLAN_DATABASE_URL_PGTOOLS")):
|
||||
issue("warning", "GOVOPLAN_DATABASE_URL_PGTOOLS", "PostgreSQL backup/restore tools URL is missing.", "Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.")
|
||||
collector.add("error", "DATABASE_URL", "DATABASE_URL is missing.", "Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.")
|
||||
return
|
||||
backend = _database_backend(database_url)
|
||||
if backend is None:
|
||||
collector.add("error", "DATABASE_URL", "DATABASE_URL is not a valid SQLAlchemy URL.", "Use a value like postgresql+psycopg://user:password@host:5432/database.")
|
||||
return
|
||||
if backend == "sqlite" and runtime.production_like:
|
||||
collector.add("error", "DATABASE_URL", "SQLite is only supported for disposable local development.", "Use PostgreSQL for production-like and self-hosted installs.")
|
||||
elif backend != "postgresql" and runtime.production:
|
||||
collector.add("warning", "DATABASE_URL", f"Database backend {backend!r} is not the preferred production target.", "Use PostgreSQL unless this deployment has an explicit support decision.")
|
||||
if backend == "postgresql" and not _clean(env.get("GOVOPLAN_DATABASE_URL_PGTOOLS")):
|
||||
collector.add("warning", "GOVOPLAN_DATABASE_URL_PGTOOLS", "PostgreSQL backup/restore tools URL is missing.", "Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.")
|
||||
|
||||
|
||||
def _validate_master_key(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||
master_key = _clean(env.get("MASTER_KEY_B64"))
|
||||
if not master_key and not local:
|
||||
issue("error", "MASTER_KEY_B64", "MASTER_KEY_B64 is required outside local dev/test.", "Generate a Fernet key with `govoplan-config env-template --generate-secrets` or `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` and store it in deployment secrets.")
|
||||
elif master_key:
|
||||
error = _master_key_error(master_key)
|
||||
if error:
|
||||
issue("error", "MASTER_KEY_B64", error, "Replace MASTER_KEY_B64 with a Fernet key or base64-encoded 32-byte key.")
|
||||
elif "change-me" in master_key.lower() or "generate" in master_key.lower():
|
||||
issue("error", "MASTER_KEY_B64", "MASTER_KEY_B64 still looks like a placeholder.", "Generate a real deployment key and store it outside git.")
|
||||
if not master_key and not runtime.local:
|
||||
collector.add("error", "MASTER_KEY_B64", "MASTER_KEY_B64 is required outside local dev/test.", "Generate a Fernet key with `govoplan-config env-template --generate-secrets` or `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` and store it in deployment secrets.")
|
||||
return
|
||||
if not master_key:
|
||||
return
|
||||
error = _master_key_error(master_key)
|
||||
if error:
|
||||
collector.add("error", "MASTER_KEY_B64", error, "Replace MASTER_KEY_B64 with a Fernet key or base64-encoded 32-byte key.")
|
||||
elif "change-me" in master_key.lower() or "generate" in master_key.lower():
|
||||
collector.add("error", "MASTER_KEY_B64", "MASTER_KEY_B64 still looks like a placeholder.", "Generate a real deployment key and store it outside git.")
|
||||
|
||||
|
||||
def _validate_enabled_modules(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||
enabled_modules = _csv(env.get("ENABLED_MODULES"))
|
||||
if not enabled_modules and production_like:
|
||||
issue("error", "ENABLED_MODULES", "ENABLED_MODULES is missing.", "Set ENABLED_MODULES explicitly so startup module composition is intentional.")
|
||||
elif "access" not in enabled_modules and production_like:
|
||||
issue("error", "ENABLED_MODULES", "The access module is not enabled.", "Include `access` unless this deployment has a replacement auth/principal provider.")
|
||||
elif enabled_modules and "admin" not in enabled_modules and production_like:
|
||||
issue("warning", "ENABLED_MODULES", "The admin module is not enabled.", "Keep `admin` enabled for operator UI unless this is a deliberately headless install.")
|
||||
if not enabled_modules and runtime.production_like:
|
||||
collector.add("error", "ENABLED_MODULES", "ENABLED_MODULES is missing.", "Set ENABLED_MODULES explicitly so startup module composition is intentional.")
|
||||
elif "access" not in enabled_modules and runtime.production_like:
|
||||
collector.add("error", "ENABLED_MODULES", "The access module is not enabled.", "Include `access` unless this deployment has a replacement auth/principal provider.")
|
||||
elif enabled_modules and "admin" not in enabled_modules and runtime.production_like:
|
||||
collector.add("warning", "ENABLED_MODULES", "The admin module is not enabled.", "Keep `admin` enabled for operator UI unless this is a deliberately headless install.")
|
||||
|
||||
celery_enabled = _truthy(env.get("CELERY_ENABLED"))
|
||||
if celery_enabled and not _clean(env.get("REDIS_URL")):
|
||||
issue("error", "REDIS_URL", "CELERY_ENABLED=true but REDIS_URL is missing.", "Set REDIS_URL to the Redis broker/result backend used by workers.")
|
||||
|
||||
if production and _truthy(env.get("DEV_BOOTSTRAP_ENABLED")):
|
||||
issue("error", "DEV_BOOTSTRAP_ENABLED", "Development bootstrap is enabled in production.", "Set DEV_BOOTSTRAP_ENABLED=false and create first administrators through the controlled bootstrap path.")
|
||||
def _validate_async_and_auth_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||
if _truthy(env.get("CELERY_ENABLED")) and not _clean(env.get("REDIS_URL")):
|
||||
collector.add("error", "REDIS_URL", "CELERY_ENABLED=true but REDIS_URL is missing.", "Set REDIS_URL to the Redis broker/result backend used by workers.")
|
||||
if runtime.production and _truthy(env.get("DEV_BOOTSTRAP_ENABLED")):
|
||||
collector.add("error", "DEV_BOOTSTRAP_ENABLED", "Development bootstrap is enabled in production.", "Set DEV_BOOTSTRAP_ENABLED=false and create first administrators through the controlled bootstrap path.")
|
||||
if runtime.production and not _truthy(env.get("AUTH_COOKIE_SECURE")):
|
||||
collector.add("error", "AUTH_COOKIE_SECURE", "Secure auth cookies are disabled for production.", "Set AUTH_COOKIE_SECURE=true behind HTTPS.")
|
||||
|
||||
if production and not _truthy(env.get("AUTH_COOKIE_SECURE")):
|
||||
issue("error", "AUTH_COOKIE_SECURE", "Secure auth cookies are disabled for production.", "Set AUTH_COOKIE_SECURE=true behind HTTPS.")
|
||||
|
||||
def _validate_cors_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||
cors_origins = _csv(env.get("CORS_ORIGINS"))
|
||||
if production_like and not cors_origins:
|
||||
issue("error", "CORS_ORIGINS", "CORS_ORIGINS is missing.", "Set CORS_ORIGINS to the exact WebUI origin or origins.")
|
||||
elif "*" in cors_origins and production_like:
|
||||
issue("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.")
|
||||
elif production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
|
||||
issue("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.")
|
||||
if runtime.production_like and not cors_origins:
|
||||
collector.add("error", "CORS_ORIGINS", "CORS_ORIGINS is missing.", "Set CORS_ORIGINS to the exact WebUI origin or origins.")
|
||||
elif "*" in cors_origins and runtime.production_like:
|
||||
collector.add("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.")
|
||||
elif runtime.production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
|
||||
collector.add("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.")
|
||||
|
||||
|
||||
def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||
storage_backend = _clean(env.get("FILE_STORAGE_BACKEND")) or "local"
|
||||
if storage_backend == "local":
|
||||
if not _clean(env.get("FILE_STORAGE_LOCAL_ROOT")) and production_like:
|
||||
issue("error", "FILE_STORAGE_LOCAL_ROOT", "Local file storage root is missing.", "Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.")
|
||||
elif production:
|
||||
issue("warning", "FILE_STORAGE_BACKEND", "Production is configured for local file storage.", "Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.")
|
||||
_validate_local_file_storage(env, runtime, collector)
|
||||
elif storage_backend == "s3":
|
||||
for key in ("FILE_STORAGE_S3_ENDPOINT_URL", "FILE_STORAGE_S3_REGION", "FILE_STORAGE_S3_ACCESS_KEY_ID", "FILE_STORAGE_S3_SECRET_ACCESS_KEY", "FILE_STORAGE_S3_BUCKET"):
|
||||
if not _clean(env.get(key)):
|
||||
issue("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
|
||||
_validate_s3_file_storage(env, collector)
|
||||
else:
|
||||
issue("error", "FILE_STORAGE_BACKEND", f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.", "Use `local` or `s3`.")
|
||||
collector.add("error", "FILE_STORAGE_BACKEND", f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.", "Use `local` or `s3`.")
|
||||
|
||||
|
||||
def _validate_local_file_storage(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||
if not _clean(env.get("FILE_STORAGE_LOCAL_ROOT")) and runtime.production_like:
|
||||
collector.add("error", "FILE_STORAGE_LOCAL_ROOT", "Local file storage root is missing.", "Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.")
|
||||
elif runtime.production:
|
||||
collector.add("warning", "FILE_STORAGE_BACKEND", "Production is configured for local file storage.", "Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.")
|
||||
|
||||
|
||||
def _validate_s3_file_storage(env: Mapping[str, str], collector: _ConfigIssueCollector) -> None:
|
||||
for key in ("FILE_STORAGE_S3_ENDPOINT_URL", "FILE_STORAGE_S3_REGION", "FILE_STORAGE_S3_ACCESS_KEY_ID", "FILE_STORAGE_S3_SECRET_ACCESS_KEY", "FILE_STORAGE_S3_BUCKET"):
|
||||
if not _clean(env.get(key)):
|
||||
collector.add("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
|
||||
|
||||
|
||||
def _validate_module_catalog_trust(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG"))
|
||||
if production and catalog_source:
|
||||
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE")):
|
||||
issue("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "A module catalog source is configured without a trusted keyring file.", "Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.")
|
||||
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL")):
|
||||
issue("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL", "A module catalog source is configured without an approved release channel.", "Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.")
|
||||
|
||||
return ConfigValidationResult(profile=clean_profile, issues=tuple(issues))
|
||||
if not runtime.production or not catalog_source:
|
||||
return
|
||||
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE")):
|
||||
collector.add("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "A module catalog source is configured without a trusted keyring file.", "Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.")
|
||||
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL")):
|
||||
collector.add("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL", "A module catalog source is configured without an approved release channel.", "Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.")
|
||||
|
||||
|
||||
def _self_hosted_env_template(master_key: str) -> str:
|
||||
@@ -299,8 +353,8 @@ def _master_key_error(value: str) -> str | None:
|
||||
try:
|
||||
Fernet(candidate)
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
except (TypeError, ValueError):
|
||||
raw = None
|
||||
try:
|
||||
raw = base64.b64decode(candidate)
|
||||
except Exception:
|
||||
@@ -309,6 +363,6 @@ def _master_key_error(value: str) -> str | None:
|
||||
return "MASTER_KEY_B64 must decode to exactly 32 bytes."
|
||||
try:
|
||||
Fernet(base64.urlsafe_b64encode(raw))
|
||||
except Exception:
|
||||
except (TypeError, ValueError):
|
||||
return "MASTER_KEY_B64 is not usable as a Fernet key."
|
||||
return None
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
125
src/govoplan_core/core/module_installer_notifications.py
Normal file
125
src/govoplan_core/core/module_installer_notifications.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider
|
||||
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
||||
|
||||
|
||||
INSTALLER_NOTIFICATION_ACTION_URL = "/admin?section=modules"
|
||||
|
||||
|
||||
def emit_module_installer_notification(
|
||||
*,
|
||||
session: object,
|
||||
registry: object | None,
|
||||
tenant_id: str | None,
|
||||
request: Mapping[str, object],
|
||||
event_kind: str,
|
||||
subject: str,
|
||||
body_text: str,
|
||||
recipient_id: str | None = None,
|
||||
priority: int = 5,
|
||||
) -> bool:
|
||||
if not tenant_id:
|
||||
return False
|
||||
provider = notification_dispatch_provider(registry)
|
||||
if provider is None:
|
||||
return False
|
||||
request_id = str(request.get("request_id") or "")
|
||||
if not request_id:
|
||||
return False
|
||||
provider.enqueue_notification(
|
||||
session,
|
||||
NotificationDispatchRequest(
|
||||
tenant_id=tenant_id,
|
||||
source_module="core",
|
||||
source_resource_type="module_install_request",
|
||||
source_resource_id=request_id,
|
||||
event_kind=event_kind,
|
||||
channel="inbox",
|
||||
recipient_type="user" if recipient_id else None,
|
||||
recipient_id=recipient_id,
|
||||
subject=subject,
|
||||
body_text=body_text,
|
||||
action_url=INSTALLER_NOTIFICATION_ACTION_URL,
|
||||
priority=priority,
|
||||
payload={
|
||||
"request_id": request_id,
|
||||
"status": request.get("status"),
|
||||
"run_id": _result_run_id(request),
|
||||
},
|
||||
metadata={
|
||||
"trace": request.get("trace") if isinstance(request.get("trace"), Mapping) else None,
|
||||
"retry_of": request.get("retry_of"),
|
||||
},
|
||||
),
|
||||
enqueue_delivery=False,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def build_runtime_notification_registry(settings: object) -> object | None:
|
||||
try:
|
||||
raw_enabled_modules = load_startup_enabled_modules(str(getattr(settings, "enabled_modules", "") or ""))
|
||||
candidate_modules = startup_candidate_module_ids(str(getattr(settings, "enabled_modules", "") or ""), raw_enabled_modules)
|
||||
available_modules = available_module_manifests(enabled_modules=candidate_modules, ignore_load_errors=True)
|
||||
enabled_modules = load_startup_enabled_modules(str(getattr(settings, "enabled_modules", "") or ""), available=available_modules)
|
||||
if "notifications" not in enabled_modules:
|
||||
return None
|
||||
registry = build_platform_registry(enabled_modules)
|
||||
registry.configure_capability_context(ModuleContext(registry=registry, settings=settings))
|
||||
return registry
|
||||
except Exception: # noqa: BLE001 - notification bridge must not block installer work.
|
||||
return None
|
||||
|
||||
|
||||
def installer_notification_subject(event_kind: str, request: Mapping[str, object]) -> str:
|
||||
request_id = str(request.get("request_id") or "unknown")
|
||||
status = str(request.get("status") or event_kind.rsplit(".", 1)[-1])
|
||||
prefixes = {
|
||||
"queued": "Module installer request queued",
|
||||
"running": "Module installer request started",
|
||||
"completed": "Module installer request completed",
|
||||
"failed": "Module installer request failed",
|
||||
"cancelled": "Module installer request cancelled",
|
||||
}
|
||||
prefix = prefixes.get(status, "Module installer request updated")
|
||||
return ": ".join((prefix, request_id))
|
||||
|
||||
|
||||
def _installer_status_sentence(request_id: str, status: str) -> str:
|
||||
return " ".join(("Installer request", request_id, "is", status))
|
||||
|
||||
|
||||
def installer_notification_body(event_kind: str, request: Mapping[str, object]) -> str:
|
||||
status = str(request.get("status") or event_kind.rsplit(".", 1)[-1])
|
||||
request_id = str(request.get("request_id") or "unknown")
|
||||
result = request.get("result") if isinstance(request.get("result"), Mapping) else {}
|
||||
error = str(request.get("error") or result.get("error") or "").strip()
|
||||
sentence = _installer_status_sentence(request_id, status)
|
||||
if error:
|
||||
return ". Error: ".join((sentence, error))
|
||||
run_id = _result_run_id(request)
|
||||
if run_id:
|
||||
return ". Run: ".join((sentence, run_id))
|
||||
return sentence + "."
|
||||
|
||||
|
||||
def installer_notification_priority(status: str) -> int:
|
||||
if status == "failed":
|
||||
return 20
|
||||
if status in {"completed", "cancelled"}:
|
||||
return 8
|
||||
if status == "running":
|
||||
return 4
|
||||
return 5
|
||||
|
||||
|
||||
def _result_run_id(request: Mapping[str, object]) -> str | None:
|
||||
result = request.get("result") if isinstance(request.get("result"), Mapping) else {}
|
||||
run_id: Any = result.get("run_id") if isinstance(result, Mapping) else None
|
||||
return str(run_id) if run_id else None
|
||||
@@ -7,13 +7,11 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
||||
from govoplan_core.security.http_fetch import fetch_http_text
|
||||
|
||||
|
||||
def module_license_decision(required_features: list[str] | tuple[str, ...]) -> dict[str, object]:
|
||||
@@ -258,18 +256,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
|
||||
|
||||
|
||||
def _read_trusted_keys_url(url: str) -> str:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username or parsed.password:
|
||||
raise ValueError("Trusted license key URL must be an absolute HTTP(S) URL without embedded credentials.")
|
||||
cache_path = _configured_trusted_keys_cache_path()
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=15) as response: # noqa: S310 - validated license key HTTP(S) URL. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
||||
body = response.read().decode("utf-8")
|
||||
body = fetch_http_text(url, timeout=15, label="Trusted license key URL")
|
||||
if cache_path is not None:
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_path.write_text(body, encoding="utf-8")
|
||||
return body
|
||||
except (OSError, urllib.error.URLError):
|
||||
except OSError:
|
||||
if cache_path is not None and cache_path.exists():
|
||||
return cache_path.read_text(encoding="utf-8")
|
||||
raise
|
||||
|
||||
@@ -73,6 +73,23 @@ class ModuleInstallPlanItem:
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _NormalizedModuleInstallPlanItem:
|
||||
module_id: str
|
||||
action: str
|
||||
source: str
|
||||
catalog: Mapping[str, object] | None
|
||||
status: str
|
||||
python_package: str | None
|
||||
python_ref: str | None
|
||||
webui_package: str | None
|
||||
webui_ref: str | None
|
||||
artifact_integrity: Mapping[str, object] | None
|
||||
data_safety_acknowledged: bool
|
||||
destroy_data: bool
|
||||
notes: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModuleInstallPlan:
|
||||
items: tuple[ModuleInstallPlanItem, ...] = ()
|
||||
@@ -291,6 +308,28 @@ def desired_modules_after_package_plan(
|
||||
def normalize_module_install_plan_item(
|
||||
item: Mapping[str, object] | ModuleInstallPlanItem,
|
||||
) -> ModuleInstallPlanItem:
|
||||
normalized = _normalized_module_install_plan_item(item)
|
||||
_validate_module_install_plan_item(normalized)
|
||||
return ModuleInstallPlanItem(
|
||||
module_id=normalized.module_id,
|
||||
action=normalized.action,
|
||||
source=normalized.source,
|
||||
catalog=normalized.catalog,
|
||||
python_package=normalized.python_package,
|
||||
python_ref=normalized.python_ref,
|
||||
webui_package=normalized.webui_package,
|
||||
webui_ref=normalized.webui_ref,
|
||||
artifact_integrity=normalized.artifact_integrity,
|
||||
data_safety_acknowledged=normalized.data_safety_acknowledged,
|
||||
destroy_data=normalized.destroy_data,
|
||||
status=normalized.status,
|
||||
notes=normalized.notes,
|
||||
)
|
||||
|
||||
|
||||
def _normalized_module_install_plan_item(
|
||||
item: Mapping[str, object] | ModuleInstallPlanItem,
|
||||
) -> _NormalizedModuleInstallPlanItem:
|
||||
if isinstance(item, ModuleInstallPlanItem):
|
||||
raw = item.as_dict()
|
||||
elif isinstance(item, Mapping):
|
||||
@@ -311,33 +350,12 @@ def normalize_module_install_plan_item(
|
||||
data_safety_acknowledged = _clean_bool(raw.get("data_safety_acknowledged"))
|
||||
destroy_data = _clean_bool(raw.get("destroy_data"))
|
||||
notes = _clean_optional_string(raw.get("notes"))
|
||||
|
||||
if action not in INSTALL_PLAN_ACTIONS:
|
||||
raise ModuleManagementError(f"Unsupported install plan action for {module_id!r}: {action!r}.")
|
||||
if status not in INSTALL_PLAN_STATUSES:
|
||||
raise ModuleManagementError(f"Unsupported install plan status for {module_id!r}: {status!r}.")
|
||||
if source not in INSTALL_PLAN_SOURCES:
|
||||
raise ModuleManagementError(f"Unsupported install plan source for {module_id!r}: {source!r}.")
|
||||
if action in {"install", "update"} and not python_ref:
|
||||
raise ModuleManagementError(f"Install plan item {module_id!r} needs a Python package reference.")
|
||||
if action == "uninstall" and not python_package:
|
||||
raise ModuleManagementError(f"Uninstall plan item {module_id!r} needs a Python package name.")
|
||||
if action != "uninstall" and destroy_data:
|
||||
raise ModuleManagementError(f"Install plan item {module_id!r} can only destroy data during uninstall.")
|
||||
if action in {"install", "update"} and bool(webui_package) != bool(webui_ref):
|
||||
raise ModuleManagementError(f"Install plan item {module_id!r} needs both WebUI package and WebUI reference, or neither.")
|
||||
if action == "uninstall" and webui_ref and not webui_package:
|
||||
raise ModuleManagementError(f"Uninstall plan item {module_id!r} has a WebUI reference but no WebUI package.")
|
||||
if python_ref:
|
||||
_validate_dependency_ref(python_ref, field="python_ref", module_id=module_id)
|
||||
if webui_ref:
|
||||
_validate_dependency_ref(webui_ref, field="webui_ref", module_id=module_id)
|
||||
|
||||
return ModuleInstallPlanItem(
|
||||
return _NormalizedModuleInstallPlanItem(
|
||||
module_id=module_id,
|
||||
action=action,
|
||||
source=source,
|
||||
catalog=catalog,
|
||||
status=status,
|
||||
python_package=python_package,
|
||||
python_ref=python_ref,
|
||||
webui_package=webui_package,
|
||||
@@ -345,11 +363,41 @@ def normalize_module_install_plan_item(
|
||||
artifact_integrity=artifact_integrity,
|
||||
data_safety_acknowledged=data_safety_acknowledged,
|
||||
destroy_data=destroy_data,
|
||||
status=status,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
|
||||
def _validate_module_install_plan_item(item: _NormalizedModuleInstallPlanItem) -> None:
|
||||
if item.action not in INSTALL_PLAN_ACTIONS:
|
||||
raise ModuleManagementError(f"Unsupported install plan action for {item.module_id!r}: {item.action!r}.")
|
||||
if item.status not in INSTALL_PLAN_STATUSES:
|
||||
raise ModuleManagementError(f"Unsupported install plan status for {item.module_id!r}: {item.status!r}.")
|
||||
if item.source not in INSTALL_PLAN_SOURCES:
|
||||
raise ModuleManagementError(f"Unsupported install plan source for {item.module_id!r}: {item.source!r}.")
|
||||
_validate_module_install_plan_item_requirements(item)
|
||||
_validate_module_install_plan_item_refs(item)
|
||||
|
||||
|
||||
def _validate_module_install_plan_item_requirements(item: _NormalizedModuleInstallPlanItem) -> None:
|
||||
if item.action in {"install", "update"} and not item.python_ref:
|
||||
raise ModuleManagementError(f"Install plan item {item.module_id!r} needs a Python package reference.")
|
||||
if item.action == "uninstall" and not item.python_package:
|
||||
raise ModuleManagementError(f"Uninstall plan item {item.module_id!r} needs a Python package name.")
|
||||
if item.action != "uninstall" and item.destroy_data:
|
||||
raise ModuleManagementError(f"Install plan item {item.module_id!r} can only destroy data during uninstall.")
|
||||
if item.action in {"install", "update"} and bool(item.webui_package) != bool(item.webui_ref):
|
||||
raise ModuleManagementError(f"Install plan item {item.module_id!r} needs both WebUI package and WebUI reference, or neither.")
|
||||
if item.action == "uninstall" and item.webui_ref and not item.webui_package:
|
||||
raise ModuleManagementError(f"Uninstall plan item {item.module_id!r} has a WebUI reference but no WebUI package.")
|
||||
|
||||
|
||||
def _validate_module_install_plan_item_refs(item: _NormalizedModuleInstallPlanItem) -> None:
|
||||
if item.python_ref:
|
||||
_validate_dependency_ref(item.python_ref, field="python_ref", module_id=item.module_id)
|
||||
if item.webui_ref:
|
||||
_validate_dependency_ref(item.webui_ref, field="webui_ref", module_id=item.module_id)
|
||||
|
||||
|
||||
def plan_desired_enabled_modules(
|
||||
requested_enabled: Iterable[str],
|
||||
available: Mapping[str, ModuleManifest],
|
||||
|
||||
@@ -3,20 +3,20 @@ from __future__ import annotations
|
||||
import base64
|
||||
import binascii
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
||||
|
||||
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
||||
from govoplan_core.security.http_fetch import fetch_http_text, is_http_url
|
||||
|
||||
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
|
||||
CATALOG_MIGRATION_SAFETY = ("automatic", "requires_review", "forward_only", "destructive")
|
||||
@@ -28,6 +28,20 @@ CATALOG_MIGRATION_TASK_PHASES = (
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _CatalogValidationState:
|
||||
modules: tuple[dict[str, object], ...]
|
||||
channel: str | None
|
||||
sequence: int | None
|
||||
generated_at: str | None
|
||||
not_before: str | None
|
||||
expires_at: str | None
|
||||
signature_state: dict[str, object]
|
||||
freshness: dict[str, object]
|
||||
replay: dict[str, object]
|
||||
read_state: dict[str, object]
|
||||
|
||||
|
||||
def module_package_catalog(
|
||||
path: Path | str | None = None,
|
||||
*,
|
||||
@@ -61,178 +75,143 @@ def validate_module_package_catalog(
|
||||
effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels
|
||||
effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys()
|
||||
if catalog_source is not None and not _catalog_source_exists(catalog_source):
|
||||
return {
|
||||
"valid": False,
|
||||
"configured": True,
|
||||
"path": str(catalog_source),
|
||||
"source": str(catalog_source),
|
||||
"source_type": _catalog_source_type(catalog_source),
|
||||
"cache_used": False,
|
||||
"cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() is not None else None,
|
||||
"modules": [],
|
||||
"channel": None,
|
||||
"sequence": None,
|
||||
"generated_at": None,
|
||||
"not_before": None,
|
||||
"expires_at": None,
|
||||
"signed": False,
|
||||
"trusted": False,
|
||||
"key_id": None,
|
||||
"warnings": [],
|
||||
"error": f"Module package catalog does not exist: {catalog_source}",
|
||||
}
|
||||
warnings: list[str] = []
|
||||
read_state = {"cache_used": False, "cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() is not None else None}
|
||||
return _catalog_error_result(catalog_source, error=f"Module package catalog does not exist: {catalog_source}")
|
||||
try:
|
||||
payload, read_state = _read_catalog_payload_with_metadata(catalog_source)
|
||||
modules = _normalize_catalog_modules(payload)
|
||||
channel = _catalog_channel(payload)
|
||||
sequence = _catalog_sequence(payload)
|
||||
generated_at = _catalog_optional_text(payload, "generated_at")
|
||||
not_before = _catalog_optional_text(payload, "not_before")
|
||||
expires_at = _catalog_optional_text(payload, "expires_at")
|
||||
signature_state = _catalog_signature_state(payload, trusted_keys=effective_trusted_keys)
|
||||
freshness = _catalog_freshness_state(payload)
|
||||
replay = _catalog_replay_state(channel=channel, sequence=sequence)
|
||||
state = _catalog_validation_state(catalog_source, trusted_keys=effective_trusted_keys)
|
||||
except Exception as exc:
|
||||
return {
|
||||
"valid": False,
|
||||
"configured": catalog_source is not None,
|
||||
"path": str(catalog_source) if catalog_source is not None else None,
|
||||
"source": str(catalog_source) if catalog_source is not None else None,
|
||||
"source_type": _catalog_source_type(catalog_source),
|
||||
"cache_used": bool(read_state.get("cache_used")),
|
||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
||||
"modules": [],
|
||||
"channel": None,
|
||||
"sequence": None,
|
||||
"generated_at": None,
|
||||
"not_before": None,
|
||||
"expires_at": None,
|
||||
"signed": False,
|
||||
"trusted": False,
|
||||
"key_id": None,
|
||||
"warnings": [],
|
||||
"error": str(exc),
|
||||
}
|
||||
if signature_state.get("fatal"):
|
||||
return {
|
||||
"valid": False,
|
||||
"configured": catalog_source is not None,
|
||||
"path": str(catalog_source) if catalog_source is not None else None,
|
||||
"source": str(catalog_source) if catalog_source is not None else None,
|
||||
"source_type": _catalog_source_type(catalog_source),
|
||||
"cache_used": bool(read_state.get("cache_used")),
|
||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
||||
"modules": [],
|
||||
"channel": channel,
|
||||
"sequence": sequence,
|
||||
"generated_at": generated_at,
|
||||
"not_before": not_before,
|
||||
"expires_at": expires_at,
|
||||
"signed": signature_state["signed"],
|
||||
"trusted": signature_state["trusted"],
|
||||
"key_id": signature_state["key_id"],
|
||||
"warnings": [],
|
||||
"error": str(signature_state["error"] or "Module package catalog signature is invalid."),
|
||||
}
|
||||
if effective_approved_channels and channel not in effective_approved_channels:
|
||||
return {
|
||||
"valid": False,
|
||||
"configured": catalog_source is not None,
|
||||
"path": str(catalog_source) if catalog_source is not None else None,
|
||||
"source": str(catalog_source) if catalog_source is not None else None,
|
||||
"source_type": _catalog_source_type(catalog_source),
|
||||
"cache_used": bool(read_state.get("cache_used")),
|
||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
||||
"modules": [],
|
||||
"channel": channel,
|
||||
"sequence": sequence,
|
||||
"generated_at": generated_at,
|
||||
"not_before": not_before,
|
||||
"expires_at": expires_at,
|
||||
"signed": signature_state["signed"],
|
||||
"trusted": signature_state["trusted"],
|
||||
"key_id": signature_state["key_id"],
|
||||
"warnings": [],
|
||||
"error": f"Module package catalog channel {channel!r} is not approved. Approved channels: {', '.join(effective_approved_channels)}.",
|
||||
}
|
||||
if effective_require_trusted and not signature_state["trusted"]:
|
||||
return {
|
||||
"valid": False,
|
||||
"configured": catalog_source is not None,
|
||||
"path": str(catalog_source) if catalog_source is not None else None,
|
||||
"source": str(catalog_source) if catalog_source is not None else None,
|
||||
"source_type": _catalog_source_type(catalog_source),
|
||||
"cache_used": bool(read_state.get("cache_used")),
|
||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
||||
"modules": [],
|
||||
"channel": channel,
|
||||
"sequence": sequence,
|
||||
"generated_at": generated_at,
|
||||
"not_before": not_before,
|
||||
"expires_at": expires_at,
|
||||
"signed": signature_state["signed"],
|
||||
"trusted": False,
|
||||
"key_id": signature_state["key_id"],
|
||||
"warnings": [],
|
||||
"error": str(signature_state["error"] or "Module package catalog must be signed by a trusted key."),
|
||||
}
|
||||
if not freshness["valid"]:
|
||||
return _invalid_catalog_result(
|
||||
catalog_source,
|
||||
modules=(),
|
||||
channel=channel,
|
||||
sequence=sequence,
|
||||
generated_at=generated_at,
|
||||
not_before=not_before,
|
||||
expires_at=expires_at,
|
||||
signature_state=signature_state,
|
||||
read_state=read_state,
|
||||
error=str(freshness["error"]),
|
||||
return _catalog_error_result(catalog_source, error=str(exc))
|
||||
policy_error = _catalog_policy_error(
|
||||
catalog_source,
|
||||
state,
|
||||
require_trusted=effective_require_trusted,
|
||||
approved_channels=effective_approved_channels,
|
||||
)
|
||||
if policy_error is not None:
|
||||
return policy_error
|
||||
return _valid_catalog_result(catalog_source, state)
|
||||
|
||||
|
||||
def _catalog_validation_state(
|
||||
source: Path | str | None,
|
||||
*,
|
||||
trusted_keys: dict[str, str],
|
||||
) -> _CatalogValidationState:
|
||||
payload, read_state = _read_catalog_payload_with_metadata(source)
|
||||
modules = _normalize_catalog_modules(payload)
|
||||
channel = _catalog_channel(payload)
|
||||
sequence = _catalog_sequence(payload)
|
||||
return _CatalogValidationState(
|
||||
modules=modules,
|
||||
channel=channel,
|
||||
sequence=sequence,
|
||||
generated_at=_catalog_optional_text(payload, "generated_at"),
|
||||
not_before=_catalog_optional_text(payload, "not_before"),
|
||||
expires_at=_catalog_optional_text(payload, "expires_at"),
|
||||
signature_state=_catalog_signature_state(payload, trusted_keys=trusted_keys),
|
||||
freshness=_catalog_freshness_state(payload),
|
||||
replay=_catalog_replay_state(channel=channel, sequence=sequence),
|
||||
read_state=read_state,
|
||||
)
|
||||
|
||||
|
||||
def _catalog_policy_error(
|
||||
source: Path | str | None,
|
||||
state: _CatalogValidationState,
|
||||
*,
|
||||
require_trusted: bool,
|
||||
approved_channels: tuple[str, ...],
|
||||
) -> dict[str, object] | None:
|
||||
if state.signature_state.get("fatal"):
|
||||
return _invalid_catalog_state_result(source, state, error=str(state.signature_state["error"] or "Module package catalog signature is invalid."))
|
||||
if approved_channels and state.channel not in approved_channels:
|
||||
return _invalid_catalog_state_result(
|
||||
source,
|
||||
state,
|
||||
error=f"Module package catalog channel {state.channel!r} is not approved. Approved channels: {', '.join(approved_channels)}.",
|
||||
)
|
||||
if not replay["valid"]:
|
||||
return _invalid_catalog_result(
|
||||
catalog_source,
|
||||
modules=(),
|
||||
channel=channel,
|
||||
sequence=sequence,
|
||||
generated_at=generated_at,
|
||||
not_before=not_before,
|
||||
expires_at=expires_at,
|
||||
signature_state=signature_state,
|
||||
read_state=read_state,
|
||||
error=str(replay["error"]),
|
||||
)
|
||||
warnings.extend(str(item) for item in freshness.get("warnings", ()) if item)
|
||||
warnings.extend(str(item) for item in replay.get("warnings", ()) if item)
|
||||
warnings.extend(_catalog_interface_warnings(modules))
|
||||
if not signature_state["signed"]:
|
||||
warnings.append("Catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
|
||||
elif not signature_state["trusted"]:
|
||||
warnings.append(str(signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
|
||||
if require_trusted and not state.signature_state["trusted"]:
|
||||
return _invalid_catalog_state_result(source, state, error=str(state.signature_state["error"] or "Module package catalog must be signed by a trusted key."))
|
||||
if not state.freshness["valid"]:
|
||||
return _invalid_catalog_state_result(source, state, error=str(state.freshness["error"]))
|
||||
if not state.replay["valid"]:
|
||||
return _invalid_catalog_state_result(source, state, error=str(state.replay["error"]))
|
||||
return None
|
||||
|
||||
|
||||
def _catalog_error_result(source: Path | str | None, *, error: str) -> dict[str, object]:
|
||||
return _invalid_catalog_result(
|
||||
source,
|
||||
modules=(),
|
||||
channel=None,
|
||||
sequence=None,
|
||||
generated_at=None,
|
||||
not_before=None,
|
||||
expires_at=None,
|
||||
signature_state=_unsigned_catalog_signature_state(),
|
||||
read_state=_default_catalog_read_state(),
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
def _invalid_catalog_state_result(source: Path | str | None, state: _CatalogValidationState, *, error: str) -> dict[str, object]:
|
||||
return _invalid_catalog_result(
|
||||
source,
|
||||
modules=(),
|
||||
channel=state.channel,
|
||||
sequence=state.sequence,
|
||||
generated_at=state.generated_at,
|
||||
not_before=state.not_before,
|
||||
expires_at=state.expires_at,
|
||||
signature_state=state.signature_state,
|
||||
read_state=state.read_state,
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
def _valid_catalog_result(source: Path | str | None, state: _CatalogValidationState) -> dict[str, object]:
|
||||
return {
|
||||
"valid": True,
|
||||
"configured": catalog_source is not None and _catalog_source_exists(catalog_source),
|
||||
"path": str(catalog_source) if catalog_source is not None else None,
|
||||
"source": str(catalog_source) if catalog_source is not None else None,
|
||||
"source_type": _catalog_source_type(catalog_source),
|
||||
"cache_used": bool(read_state.get("cache_used")),
|
||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
||||
"modules": list(modules),
|
||||
"channel": channel,
|
||||
"sequence": sequence,
|
||||
"generated_at": generated_at,
|
||||
"not_before": not_before,
|
||||
"expires_at": expires_at,
|
||||
"signed": signature_state["signed"],
|
||||
"trusted": signature_state["trusted"],
|
||||
"key_id": signature_state["key_id"],
|
||||
"warnings": warnings,
|
||||
"configured": source is not None and _catalog_source_exists(source),
|
||||
"path": str(source) if source is not None else None,
|
||||
"source": str(source) if source is not None else None,
|
||||
"source_type": _catalog_source_type(source),
|
||||
"cache_used": bool(state.read_state.get("cache_used")),
|
||||
"cache_path": state.read_state.get("cache_path") if isinstance(state.read_state.get("cache_path"), str) else None,
|
||||
"modules": list(state.modules),
|
||||
"channel": state.channel,
|
||||
"sequence": state.sequence,
|
||||
"generated_at": state.generated_at,
|
||||
"not_before": state.not_before,
|
||||
"expires_at": state.expires_at,
|
||||
"signed": state.signature_state["signed"],
|
||||
"trusted": state.signature_state["trusted"],
|
||||
"key_id": state.signature_state["key_id"],
|
||||
"warnings": _catalog_validation_warnings(state),
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
def _catalog_validation_warnings(state: _CatalogValidationState) -> list[str]:
|
||||
warnings: list[str] = []
|
||||
warnings.extend(str(item) for item in state.freshness.get("warnings", ()) if item)
|
||||
warnings.extend(str(item) for item in state.replay.get("warnings", ()) if item)
|
||||
warnings.extend(_catalog_interface_warnings(state.modules))
|
||||
if not state.signature_state["signed"]:
|
||||
warnings.append("Catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
|
||||
elif not state.signature_state["trusted"]:
|
||||
warnings.append(str(state.signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
|
||||
return warnings
|
||||
|
||||
|
||||
def _default_catalog_read_state() -> dict[str, object]:
|
||||
cache_path = _configured_catalog_cache_path()
|
||||
return {"cache_used": False, "cache_path": str(cache_path) if cache_path is not None else None}
|
||||
|
||||
|
||||
def _unsigned_catalog_signature_state() -> dict[str, object]:
|
||||
return {"signed": False, "trusted": False, "key_id": None}
|
||||
|
||||
|
||||
def sign_module_package_catalog(
|
||||
*,
|
||||
path: Path,
|
||||
@@ -350,17 +329,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
|
||||
|
||||
|
||||
def _read_trusted_keys_url(url: str) -> str:
|
||||
if not _is_http_url(url):
|
||||
raise ValueError("Trusted catalog key URL must use http:// or https://.")
|
||||
cache_path = _configured_trusted_keys_cache_path()
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=15) as response: # noqa: S310 - validated catalog key HTTP(S) URL. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
||||
body = response.read().decode("utf-8")
|
||||
body = fetch_http_text(url, timeout=15, label="Trusted catalog key URL")
|
||||
if cache_path is not None:
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_path.write_text(body, encoding="utf-8")
|
||||
return body
|
||||
except (OSError, urllib.error.URLError):
|
||||
except OSError:
|
||||
if cache_path is not None and cache_path.exists():
|
||||
return cache_path.read_text(encoding="utf-8")
|
||||
raise
|
||||
@@ -421,13 +397,12 @@ def _read_catalog_url(url: str) -> str:
|
||||
def _read_catalog_url_with_metadata(url: str) -> tuple[str, bool]:
|
||||
cache_path = _configured_catalog_cache_path()
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=15) as response: # noqa: S310 - validated module catalog HTTP(S) URL. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
||||
body = response.read().decode("utf-8")
|
||||
body = fetch_http_text(url, timeout=15, label="Module package catalog URL")
|
||||
if cache_path is not None:
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_path.write_text(body, encoding="utf-8")
|
||||
return body, False
|
||||
except (OSError, urllib.error.URLError):
|
||||
except OSError:
|
||||
if cache_path is not None and cache_path.exists():
|
||||
return cache_path.read_text(encoding="utf-8"), True
|
||||
raise
|
||||
@@ -935,8 +910,7 @@ def _catalog_source_type(source: Path | str | None) -> str | None:
|
||||
|
||||
|
||||
def _is_http_url(value: str) -> bool:
|
||||
parsed = urllib.parse.urlparse(value)
|
||||
return parsed.scheme in {"http", "https"} and bool(parsed.netloc) and not parsed.username and not parsed.password
|
||||
return is_http_url(value)
|
||||
|
||||
|
||||
def _invalid_catalog_result(
|
||||
|
||||
64
src/govoplan_core/core/notifications.py
Normal file
64
src/govoplan_core/core/notifications.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
CAPABILITY_NOTIFICATIONS_DISPATCH = "notifications.dispatch"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NotificationDispatchRequest:
|
||||
tenant_id: str
|
||||
source_module: str
|
||||
source_resource_type: str
|
||||
source_resource_id: str | None
|
||||
event_kind: str
|
||||
channel: str = "inbox"
|
||||
recipient: str | None = None
|
||||
recipient_type: str | None = None
|
||||
recipient_id: str | None = None
|
||||
recipient_label: str | None = None
|
||||
subject: str | None = None
|
||||
body_text: str | None = None
|
||||
body_html: str | None = None
|
||||
action_url: str | None = None
|
||||
priority: int = 0
|
||||
not_before_at: datetime | None = None
|
||||
payload: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class NotificationDispatchProvider(Protocol):
|
||||
def enqueue_notification(
|
||||
self,
|
||||
session: object,
|
||||
request: NotificationDispatchRequest,
|
||||
*,
|
||||
enqueue_delivery: bool = True,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
def deliver_notification(self, session: object, *, notification_id: str) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
def deliver_pending(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
|
||||
def notification_dispatch_provider(registry: object | None) -> NotificationDispatchProvider | None:
|
||||
if registry is None or not hasattr(registry, "has_capability"):
|
||||
return None
|
||||
if not registry.has_capability(CAPABILITY_NOTIFICATIONS_DISPATCH):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_NOTIFICATIONS_DISPATCH)
|
||||
return capability if isinstance(capability, NotificationDispatchProvider) else None
|
||||
@@ -186,46 +186,20 @@ class PlatformRegistry:
|
||||
def validate(self) -> RegistrySnapshot:
|
||||
ordered = tuple(self._topologically_sorted())
|
||||
available_capabilities = set(self._capability_factories)
|
||||
seen_permissions: dict[str, PermissionDefinition] = {}
|
||||
for manifest in ordered:
|
||||
_validate_manifest_shape(manifest)
|
||||
for dependency in manifest.dependencies:
|
||||
if dependency not in self._manifests:
|
||||
raise RegistryError(f"Module {manifest.id!r} depends on unknown module {dependency!r}")
|
||||
for capability in manifest.required_capabilities:
|
||||
if capability not in available_capabilities:
|
||||
raise RegistryError(f"Module {manifest.id!r} requires unavailable capability {capability!r}")
|
||||
for dependency in manifest.optional_dependencies:
|
||||
if dependency == manifest.id:
|
||||
raise RegistryError(f"Module {manifest.id!r} cannot list itself as an optional dependency")
|
||||
for permission in manifest.permissions:
|
||||
if permission.module_id != manifest.id:
|
||||
raise RegistryError(f"Permission {permission.scope!r} has mismatched module id {permission.module_id!r}")
|
||||
if not _SCOPE_RE.match(permission.scope):
|
||||
raise RegistryError(f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}")
|
||||
expected_prefix = f"{permission.module_id}:{permission.resource}:"
|
||||
if not permission.scope.startswith(expected_prefix) or permission.scope.rsplit(":", 1)[-1] != permission.action:
|
||||
raise RegistryError(f"Permission fields do not match scope {permission.scope!r}")
|
||||
if permission.scope in seen_permissions:
|
||||
raise RegistryError(f"Duplicate permission scope: {permission.scope}")
|
||||
seen_permissions[permission.scope] = permission
|
||||
|
||||
_validate_manifest_relationships(
|
||||
manifest,
|
||||
known_modules=self._manifests,
|
||||
available_capabilities=available_capabilities,
|
||||
)
|
||||
permissions = _collect_manifest_permissions(ordered)
|
||||
_validate_interface_closure(ordered)
|
||||
|
||||
known_scopes = set(seen_permissions)
|
||||
for manifest in ordered:
|
||||
for template in manifest.role_templates:
|
||||
for scope in template.permissions:
|
||||
if scope in {"*", "tenant:*", "system:*"}:
|
||||
continue
|
||||
if _WILDCARD_RE.match(scope):
|
||||
continue
|
||||
if scope not in known_scopes:
|
||||
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
|
||||
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
|
||||
|
||||
return RegistrySnapshot(
|
||||
manifests=ordered,
|
||||
permissions=tuple(seen_permissions.values()),
|
||||
permissions=tuple(permissions.values()),
|
||||
role_templates=tuple(template for manifest in ordered for template in manifest.role_templates),
|
||||
nav_items=self.nav_items(),
|
||||
)
|
||||
@@ -299,7 +273,80 @@ def _attribute_delete_veto_issue(
|
||||
return replace(issue, module_id=issue.module_id or registration.module_id, details=details)
|
||||
|
||||
|
||||
def _validate_manifest_relationships(
|
||||
manifest: ModuleManifest,
|
||||
*,
|
||||
known_modules: Mapping[str, ModuleManifest],
|
||||
available_capabilities: set[str],
|
||||
) -> None:
|
||||
for dependency in manifest.dependencies:
|
||||
if dependency not in known_modules:
|
||||
raise RegistryError(f"Module {manifest.id!r} depends on unknown module {dependency!r}")
|
||||
for capability in manifest.required_capabilities:
|
||||
if capability not in available_capabilities:
|
||||
raise RegistryError(f"Module {manifest.id!r} requires unavailable capability {capability!r}")
|
||||
for dependency in manifest.optional_dependencies:
|
||||
if dependency == manifest.id:
|
||||
raise RegistryError(f"Module {manifest.id!r} cannot list itself as an optional dependency")
|
||||
|
||||
|
||||
def _collect_manifest_permissions(manifests: tuple[ModuleManifest, ...]) -> dict[str, PermissionDefinition]:
|
||||
permissions: dict[str, PermissionDefinition] = {}
|
||||
for manifest in manifests:
|
||||
for permission in manifest.permissions:
|
||||
_validate_manifest_permission(manifest, permission, permissions)
|
||||
permissions[permission.scope] = permission
|
||||
return permissions
|
||||
|
||||
|
||||
def _validate_manifest_permission(
|
||||
manifest: ModuleManifest,
|
||||
permission: PermissionDefinition,
|
||||
seen_permissions: Mapping[str, PermissionDefinition],
|
||||
) -> None:
|
||||
if permission.module_id != manifest.id:
|
||||
raise RegistryError(f"Permission {permission.scope!r} has mismatched module id {permission.module_id!r}")
|
||||
if not _SCOPE_RE.match(permission.scope):
|
||||
raise RegistryError(f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}")
|
||||
expected_prefix = f"{permission.module_id}:{permission.resource}:"
|
||||
if not permission.scope.startswith(expected_prefix) or permission.scope.rsplit(":", 1)[-1] != permission.action:
|
||||
raise RegistryError(f"Permission fields do not match scope {permission.scope!r}")
|
||||
if permission.scope in seen_permissions:
|
||||
raise RegistryError(f"Duplicate permission scope: {permission.scope}")
|
||||
|
||||
|
||||
def _validate_role_template_scopes(
|
||||
manifests: tuple[ModuleManifest, ...],
|
||||
*,
|
||||
known_scopes: set[str],
|
||||
) -> None:
|
||||
for manifest in manifests:
|
||||
for template in manifest.role_templates:
|
||||
for scope in template.permissions:
|
||||
if _role_template_scope_known(scope, known_scopes):
|
||||
continue
|
||||
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
|
||||
|
||||
|
||||
def _role_template_scope_known(scope: str, known_scopes: set[str]) -> bool:
|
||||
if scope in {"*", "tenant:*", "system:*"}:
|
||||
return True
|
||||
if _WILDCARD_RE.match(scope):
|
||||
return True
|
||||
return scope in known_scopes
|
||||
|
||||
|
||||
def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
||||
_validate_manifest_identity(manifest)
|
||||
_validate_manifest_contract_lists(manifest)
|
||||
_validate_manifest_overlaps(manifest)
|
||||
_validate_manifest_migration_spec(manifest)
|
||||
_validate_manifest_frontend(manifest)
|
||||
for item in manifest.nav_items:
|
||||
_validate_nav_item(manifest.id, item)
|
||||
|
||||
|
||||
def _validate_manifest_identity(manifest: ModuleManifest) -> None:
|
||||
if not _MODULE_ID_RE.match(manifest.id):
|
||||
raise RegistryError(f"Module manifest id must match {_MODULE_ID_RE.pattern}: {manifest.id!r}")
|
||||
if not manifest.name.strip():
|
||||
@@ -313,12 +360,17 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
||||
f"{SUPPORTED_MANIFEST_CONTRACT_VERSION!r}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_manifest_contract_lists(manifest: ModuleManifest) -> None:
|
||||
_validate_dependency_list(manifest.id, "dependencies", manifest.dependencies)
|
||||
_validate_dependency_list(manifest.id, "optional_dependencies", manifest.optional_dependencies)
|
||||
_validate_capability_list(manifest.id, "required_capabilities", manifest.required_capabilities)
|
||||
_validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities)
|
||||
_validate_interface_providers(manifest.id, manifest.provides_interfaces)
|
||||
_validate_interface_requirements(manifest.id, manifest.requires_interfaces)
|
||||
|
||||
|
||||
def _validate_manifest_overlaps(manifest: ModuleManifest) -> None:
|
||||
overlap = set(manifest.dependencies) & set(manifest.optional_dependencies)
|
||||
if overlap:
|
||||
joined = ", ".join(sorted(overlap))
|
||||
@@ -328,36 +380,42 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
||||
joined = ", ".join(sorted(capability_overlap))
|
||||
raise RegistryError(f"Module {manifest.id!r} lists capabilities as both required and optional: {joined}")
|
||||
|
||||
|
||||
def _validate_manifest_migration_spec(manifest: ModuleManifest) -> None:
|
||||
if manifest.migration_spec is not None:
|
||||
if manifest.migration_spec.module_id != manifest.id:
|
||||
raise RegistryError(f"Module {manifest.id!r} has migration spec for {manifest.migration_spec.module_id!r}")
|
||||
if manifest.migration_spec.metadata is None and not manifest.migration_spec.script_location:
|
||||
raise RegistryError(f"Module {manifest.id!r} migration spec must declare metadata or script location")
|
||||
|
||||
if manifest.frontend is not None:
|
||||
frontend = manifest.frontend
|
||||
if frontend.module_id != manifest.id:
|
||||
raise RegistryError(f"Module {manifest.id!r} has frontend metadata for {frontend.module_id!r}")
|
||||
if frontend.asset_manifest_contract_version != SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION:
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} uses unsupported frontend asset manifest contract version "
|
||||
f"{frontend.asset_manifest_contract_version!r}; supported version is "
|
||||
f"{SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION!r}"
|
||||
)
|
||||
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
|
||||
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
|
||||
for route in (*frontend.routes, *frontend.settings_routes):
|
||||
if not route.path.startswith("/"):
|
||||
raise RegistryError(f"Frontend route for module {manifest.id!r} must start with '/': {route.path!r}")
|
||||
if not route.component.strip():
|
||||
raise RegistryError(f"Frontend route {route.path!r} for module {manifest.id!r} must declare a component")
|
||||
for item in frontend.nav_items:
|
||||
_validate_nav_item(manifest.id, item)
|
||||
|
||||
for item in manifest.nav_items:
|
||||
def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
|
||||
if manifest.frontend is None:
|
||||
return
|
||||
frontend = manifest.frontend
|
||||
if frontend.module_id != manifest.id:
|
||||
raise RegistryError(f"Module {manifest.id!r} has frontend metadata for {frontend.module_id!r}")
|
||||
if frontend.asset_manifest_contract_version != SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION:
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} uses unsupported frontend asset manifest contract version "
|
||||
f"{frontend.asset_manifest_contract_version!r}; supported version is "
|
||||
f"{SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION!r}"
|
||||
)
|
||||
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
|
||||
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
|
||||
for route in (*frontend.routes, *frontend.settings_routes):
|
||||
_validate_frontend_route(manifest.id, route.path, route.component)
|
||||
for item in frontend.nav_items:
|
||||
_validate_nav_item(manifest.id, item)
|
||||
|
||||
|
||||
def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
|
||||
if not path.startswith("/"):
|
||||
raise RegistryError(f"Frontend route for module {module_id!r} must start with '/': {path!r}")
|
||||
if not component.strip():
|
||||
raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component")
|
||||
|
||||
|
||||
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
|
||||
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list)
|
||||
for manifest in manifests:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
|
||||
_context: ModuleContext | None = None
|
||||
@@ -21,3 +23,41 @@ def get_runtime_context() -> ModuleContext | None:
|
||||
|
||||
def get_registry() -> object | None:
|
||||
return _context.registry if _context is not None else None
|
||||
|
||||
|
||||
class ModuleSettingsProxy:
|
||||
def __init__(self, runtime: ModuleRuntimeState) -> None:
|
||||
self._runtime = runtime
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._runtime.get_settings(), name)
|
||||
|
||||
|
||||
class ModuleRuntimeState:
|
||||
def __init__(self, module_name: str) -> None:
|
||||
self.module_name = module_name
|
||||
self.settings = ModuleSettingsProxy(self)
|
||||
self._registry: object | None = None
|
||||
self._settings: object | None = None
|
||||
|
||||
def configure_runtime(self, *, registry: object | None = None, settings: object | None = None) -> None:
|
||||
if registry is not None:
|
||||
self._registry = registry
|
||||
if settings is not None:
|
||||
self._settings = settings
|
||||
|
||||
def clear_runtime(self) -> None:
|
||||
self._registry = None
|
||||
self._settings = None
|
||||
|
||||
def get_registry(self) -> object | None:
|
||||
return self._registry
|
||||
|
||||
def get_settings(self) -> object:
|
||||
if self._settings is not None:
|
||||
return self._settings
|
||||
try:
|
||||
from govoplan_core.settings import settings as legacy_settings
|
||||
except ModuleNotFoundError as exc:
|
||||
raise RuntimeError(f"GovOPlaN {self.module_name} runtime settings are not configured") from exc
|
||||
return legacy_settings
|
||||
|
||||
68
src/govoplan_core/core/sqlalchemy_change_tracking.py
Normal file
68
src/govoplan_core/core/sqlalchemy_change_tracking.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Literal
|
||||
|
||||
from sqlalchemy import inspect
|
||||
|
||||
ChangeOperation = Literal["created", "updated", "deleted"]
|
||||
|
||||
|
||||
def object_state(obj: object) -> Any:
|
||||
return inspect(obj)
|
||||
|
||||
|
||||
def has_attr_changes(state: Any, attrs: tuple[str, ...]) -> bool:
|
||||
return any(name in state.attrs and state.attrs[name].history.has_changes() for name in attrs)
|
||||
|
||||
|
||||
def previous_value(obj: object, attr_name: str) -> str | None:
|
||||
state = object_state(obj)
|
||||
if attr_name not in state.attrs:
|
||||
return None
|
||||
history = state.attrs[attr_name].history
|
||||
if not history.has_changes() or not history.deleted:
|
||||
return None
|
||||
value = history.deleted[0]
|
||||
return str(value) if value is not None else None
|
||||
|
||||
|
||||
def ensure_object_id(obj: object, new_id: Callable[[], str]) -> str:
|
||||
resource_id = getattr(obj, "id", None)
|
||||
if resource_id:
|
||||
return str(resource_id)
|
||||
resource_id = new_id()
|
||||
setattr(obj, "id", resource_id)
|
||||
return resource_id
|
||||
|
||||
|
||||
def operation_for_object(obj: object, *, changed_attrs: tuple[str, ...]) -> ChangeOperation | None:
|
||||
state = object_state(obj)
|
||||
if state.pending:
|
||||
return "created"
|
||||
if state.deleted:
|
||||
return "deleted"
|
||||
if not has_attr_changes(state, changed_attrs):
|
||||
return None
|
||||
return "updated"
|
||||
|
||||
|
||||
def operation_for_soft_deletable(
|
||||
obj: object,
|
||||
*,
|
||||
changed_attrs: tuple[str, ...],
|
||||
deleted_attr: str = "deleted_at",
|
||||
) -> ChangeOperation | None:
|
||||
state = object_state(obj)
|
||||
if state.pending:
|
||||
return "created"
|
||||
if not has_attr_changes(state, changed_attrs):
|
||||
return None
|
||||
if deleted_attr in state.attrs:
|
||||
history = state.attrs[deleted_attr].history
|
||||
if history.has_changes():
|
||||
if any(value is not None for value in history.added):
|
||||
return "deleted"
|
||||
if any(value is not None for value in history.deleted):
|
||||
return "created"
|
||||
return "updated"
|
||||
@@ -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,
|
||||
|
||||
100
src/govoplan_core/db/query_metrics.py
Normal file
100
src/govoplan_core/db/query_metrics.py
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
83
src/govoplan_core/privacy/schemas.py
Normal file
83
src/govoplan_core/privacy/schemas.py
Normal 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",
|
||||
]
|
||||
66
src/govoplan_core/security/http_fetch.py
Normal file
66
src/govoplan_core/security/http_fetch.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
55
src/govoplan_core/security/scope_aliases.py
Normal file
55
src/govoplan_core/security/scope_aliases.py
Normal 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",
|
||||
}),
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()]
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user