diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0f378eb --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +# GovOPlaN self-hosted install configuration. +# Copy to a deployment-local .env or secret store. Do not commit populated secrets. + +APP_ENV=production +GOVOPLAN_INSTALL_PROFILE=self-hosted +MASTER_KEY_B64= + +DATABASE_URL=postgresql+psycopg://govoplan:change-me@127.0.0.1:5432/govoplan +GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:change-me@127.0.0.1:5432/govoplan + +ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,files,mail,campaigns,calendar,docs,ops + +CELERY_ENABLED=true +REDIS_URL=redis://127.0.0.1:6379/0 +CELERY_QUEUES=send_email,append_sent,default + +CORS_ORIGINS=https://govoplan.example.org +AUTH_COOKIE_SECURE=true +AUTH_COOKIE_SAMESITE=lax +AUTH_COOKIE_DOMAIN= + +FILE_STORAGE_BACKEND=local +FILE_STORAGE_LOCAL_ROOT=/var/lib/govoplan/files +FILE_STORAGE_LOCAL_FALLBACK_ROOTS= + +DEV_AUTO_MIGRATE_ENABLED=false +DEV_BOOTSTRAP_ENABLED=false +DEV_MAILBOX_API_ENABLED=false + +GOVOPLAN_MODULE_PACKAGE_CATALOG_URL=https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json +GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE=/etc/govoplan/catalog-keyring.json +GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable diff --git a/.gitea/workflows/dependency-audit.yml b/.gitea/workflows/dependency-audit.yml index 794e769..b9703cc 100644 --- a/.gitea/workflows/dependency-audit.yml +++ b/.gitea/workflows/dependency-audit.yml @@ -19,6 +19,24 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "22" + - name: Configure SSH for release dependencies + env: + GOVOPLAN_RELEASE_SSH_KEY: ${{ secrets.GOVOPLAN_RELEASE_SSH_KEY }} + run: | + mkdir -p ~/.ssh + chmod 700 ~/.ssh + if [ -z "${GOVOPLAN_RELEASE_SSH_KEY:-}" ]; then + echo "GOVOPLAN_RELEASE_SSH_KEY secret is required for git+ssh release dependencies." + exit 1 + fi + printf '%s\n' "$GOVOPLAN_RELEASE_SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + if ! command -v ssh-keyscan >/dev/null 2>&1; then + apt-get update + apt-get install -y openssh-client + fi + ssh-keyscan -4 -H git.add-ideas.de >> ~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts - name: Install backend dev audit dependencies run: | python -m venv .venv diff --git a/.gitea/workflows/module-matrix.yml b/.gitea/workflows/module-matrix.yml index 194c941..ec6cf98 100644 --- a/.gitea/workflows/module-matrix.yml +++ b/.gitea/workflows/module-matrix.yml @@ -17,6 +17,24 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "22" + - name: Configure SSH for release dependencies + env: + GOVOPLAN_RELEASE_SSH_KEY: ${{ secrets.GOVOPLAN_RELEASE_SSH_KEY }} + run: | + mkdir -p ~/.ssh + chmod 700 ~/.ssh + if [ -z "${GOVOPLAN_RELEASE_SSH_KEY:-}" ]; then + echo "GOVOPLAN_RELEASE_SSH_KEY secret is required for git+ssh release dependencies." + exit 1 + fi + printf '%s\n' "$GOVOPLAN_RELEASE_SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + if ! command -v ssh-keyscan >/dev/null 2>&1; then + apt-get update + apt-get install -y openssh-client + fi + ssh-keyscan -4 -H git.add-ideas.de >> ~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts - name: Install backend release dependencies run: | python -m venv .venv diff --git a/README.md b/README.md index 2d5bab1..2d2f79e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +[![Module Matrix](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/module-matrix.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/module-matrix.yml) +[![Dependency Audit](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/dependency-audit.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/dependency-audit.yml) + # govoplan-core GovOPlaN core is the platform runner and shared foundation. It owns the server entry point, database/session primitives, module discovery, migration orchestration, capability contracts, install/uninstall orchestration, and the shared WebUI shell. Platform and feature behavior is supplied by installed modules. @@ -90,6 +93,13 @@ The smoke mode prints the effective config, runtime root, database URL, modules, `requirements-dev.txt` links local GovOPlaN module checkouts for development. `requirements-release.txt` installs the packaged modules from tagged git refs for release builds. See [RELEASE_DEPENDENCIES.md](docs/RELEASE_DEPENDENCIES.md). For the install/runtime configuration contract and operator deployment flow, see [DEPLOYMENT_OPERATOR_GUIDE.md](docs/DEPLOYMENT_OPERATOR_GUIDE.md). +For self-hosted config bootstrap and validation: + +```bash +cd /mnt/DATA/git/govoplan-core +./.venv/bin/python -m govoplan_core.commands.config env-template --profile self-hosted --generate-secrets +./.venv/bin/python -m govoplan_core.commands.config validate --profile self-hosted +``` ## WebUI development diff --git a/dev/production-like/.env.example b/dev/production-like/.env.example index 31d05b3..8390fa9 100644 --- a/dev/production-like/.env.example +++ b/dev/production-like/.env.example @@ -1,3 +1,7 @@ +APP_ENV=staging +GOVOPLAN_INSTALL_PROFILE=production-like +MASTER_KEY_B64= + GOVOPLAN_PRODUCTION_LIKE_POSTGRES_DB=govoplan GOVOPLAN_PRODUCTION_LIKE_POSTGRES_USER=govoplan GOVOPLAN_PRODUCTION_LIKE_POSTGRES_PASSWORD=govoplan-dev @@ -7,3 +11,17 @@ GOVOPLAN_PRODUCTION_LIKE_REDIS_PORT=56379 GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan GOVOPLAN_PRODUCTION_LIKE_REDIS_URL=redis://127.0.0.1:56379/0 + +DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan +GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan +REDIS_URL=redis://127.0.0.1:56379/0 +CELERY_ENABLED=true +CELERY_QUEUES=send_email,append_sent,default + +ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops +CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173 +AUTH_COOKIE_SECURE=false +FILE_STORAGE_BACKEND=local +FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files +DEV_AUTO_MIGRATE_ENABLED=false +DEV_BOOTSTRAP_ENABLED=true diff --git a/dev/production-like/README.md b/dev/production-like/README.md index b4d12cf..a0698bf 100644 --- a/dev/production-like/README.md +++ b/dev/production-like/README.md @@ -18,6 +18,17 @@ cd /mnt/DATA/git/govoplan-core scripts/launch-production-like-dev.sh ``` +The helper wrapper exposes repeatable lifecycle commands: + +```bash +cd /mnt/DATA/git/govoplan-core +scripts/production-like-dev.sh validate-config +scripts/production-like-dev.sh seed +scripts/production-like-dev.sh start +scripts/production-like-dev.sh stop +scripts/production-like-dev.sh reset --yes +``` + The launcher uses `dev/production-like/.env` when present, otherwise `dev/production-like/.env.example`. Copy the example when you want local port or password changes: @@ -44,6 +55,6 @@ GOVOPLAN_STOP_PROFILE_DEPENDENCIES_ON_EXIT=1 scripts/launch-production-like-dev. To remove all profile data: ```bash -cd /mnt/DATA/git/govoplan-core/dev/production-like -docker compose --env-file .env.example down -v +cd /mnt/DATA/git/govoplan-core +scripts/production-like-dev.sh reset --yes ``` diff --git a/docs/DEPLOYMENT_OPERATOR_GUIDE.md b/docs/DEPLOYMENT_OPERATOR_GUIDE.md index f3e2a2b..e6c3ec2 100644 --- a/docs/DEPLOYMENT_OPERATOR_GUIDE.md +++ b/docs/DEPLOYMENT_OPERATOR_GUIDE.md @@ -7,6 +7,30 @@ files. ## Runtime Configuration Contract +Self-hosted installability follows the staged approach documented in +`SELF_HOSTED_INSTALLABILITY.md`: generate an explicit env template, validate it, +run production-like rehearsal with Compose-backed dependencies, then use the +installer CLI/daemon for package mutation under maintenance mode. + +Generate a deployment-local template: + +```bash +cd /mnt/DATA/git/govoplan-core +./.venv/bin/python -m govoplan_core.commands.config env-template \ + --profile self-hosted \ + --generate-secrets \ + --output .env.self-hosted +``` + +Validate the active shell environment before migration or startup: + +```bash +set -a +. .env.self-hosted +set +a +./.venv/bin/python -m govoplan_core.commands.config validate --profile self-hosted +``` + ### Required Runtime Identity | Setting | Required outside dev | Purpose | @@ -231,6 +255,16 @@ cd /mnt/DATA/git/govoplan-core scripts/launch-production-like-dev.sh ``` +The helper wrapper provides explicit lifecycle commands: + +```bash +scripts/production-like-dev.sh validate-config +scripts/production-like-dev.sh seed +scripts/production-like-dev.sh start +scripts/production-like-dev.sh stop +scripts/production-like-dev.sh reset --yes +``` + The launcher uses `dev/production-like/.env` when present, otherwise the checked in `.env.example`. It runs: diff --git a/docs/DOCUMENTATION_MAP.md b/docs/DOCUMENTATION_MAP.md index 964dc73..890adfc 100644 --- a/docs/DOCUMENTATION_MAP.md +++ b/docs/DOCUMENTATION_MAP.md @@ -21,6 +21,7 @@ operator, and roadmap pages. | Topic | Canonical document | Notes | | --- | --- | --- | | Runtime configuration and operator flow | `DEPLOYMENT_OPERATOR_GUIDE.md` | Production/staging configuration, migrations, backups, installer operation, and rollback drill. | +| Self-hosted installability | `SELF_HOSTED_INSTALLABILITY.md` | Packaging decision, generated env templates, config validation, production-like dev stack commands, and boundary gate. | | Release dependencies and catalogs | `RELEASE_DEPENDENCIES.md` | Release package refs, migration baselines, release lockfiles, catalog trust/licensing, catalog publishing, and release checklist. | | Dependency vulnerability audits | `DEPENDENCY_AUDITS.md` | Local and CI audit commands plus dated audit result notes. | | Remote WebUI bundle design | `REMOTE_WEBUI_BUNDLES.md` | Experimental controlled-deployment design; normal releases still use package builds. | diff --git a/docs/MODULE_ARCHITECTURE.md b/docs/MODULE_ARCHITECTURE.md index 6f38e92..f2b850a 100644 --- a/docs/MODULE_ARCHITECTURE.md +++ b/docs/MODULE_ARCHITECTURE.md @@ -80,6 +80,7 @@ The following contracts are the baseline API that modules can rely on: - `ModuleManifest` - `ModuleCompatibility` +- named interface contract provider/requirement metadata - module uninstall guard provider contract - `MigrationSpec` - route factory contract @@ -125,6 +126,50 @@ Feature modules should prefer these capabilities over direct reads of access/tenant ORM models when they need labels, group membership, default access provisioning, counts, audit actor labels, or tenant metadata. +### Named Interface Contracts + +Capabilities are runtime objects. Named interface contracts are compatibility +metadata. A module uses them when it depends on a versioned cross-module API +shape but should not hard-code a package or repository release line. + +Manifest fields: + +- `provides_interfaces`: contracts this module provides, each with `name` and + `version` +- `requires_interfaces`: contracts this module needs, each with `name`, + optional `version_min`, optional `version_max_exclusive`, and optional + `optional: true` + +Interface names use dot-separated lower-case identifiers such as +`files.spaces` or `mail.delivery`. A requirement range is interpreted as +`>= version_min` and `< version_max_exclusive`; the exclusive upper bound is +intended for SemVer major-version lines. Missing optional interfaces are +allowed, but an installed provider with an incompatible version blocks +activation because the integration would otherwise bind to an unsafe API. + +Current named interfaces: + +- `files.campaign_attachments` +- `mail.campaign_delivery` +- `campaigns.access` +- `campaigns.delivery_tasks` +- `campaigns.mail_policy_context` +- `campaigns.policy_context` +- `campaigns.retention` + +Core validates named interface contracts in three places: + +- registry activation rejects missing required interfaces and incompatible + providers +- installer preflight reports the same failures before a module set is + activated +- signed catalog validation normalizes the metadata and warns when catalog + entries cannot satisfy each other's ranges + +Module-id dependencies still decide startup ordering and mandatory package +presence. Named interfaces decide whether the versions in the active module +set are compatible. + FastAPI route dependencies for authenticated endpoints are imported from the core `govoplan_core.auth` facade. Routers may import that public API for `ApiPrincipal`, `get_api_principal`, `has_scope`, `require_scope`, and @@ -450,6 +495,8 @@ Modules should provide: - pinned backend and WebUI package refs for official catalog entries - compatibility metadata in the module manifest +- named interface contracts in the manifest and catalog entry when the module + provides or consumes cross-module APIs - lifecycle hooks when a runtime enable/disable action needs module-specific work - uninstall guards for persistent data, active workers, schedulers, or external @@ -621,6 +668,10 @@ The repository includes `scripts/check_dependency_boundaries.py`. It enforces th - access source may not import files/mail/campaign internals - feature modules may not import access implementation internals - feature modules may not add new direct imports of sibling feature modules +- feature WebUI packages may not depend on or import sibling feature WebUI packages +- core WebUI may list module packages as host dependencies, but core WebUI source + may not import feature WebUI internals directly; module loading stays + declarative through the module contribution contract - FastAPI routers import the core `govoplan_core.auth` dependency facade - the transitional allowlist is expected to stay empty diff --git a/docs/POLICY_CONTRACTS.md b/docs/POLICY_CONTRACTS.md index 04fab8b..ca34936 100644 --- a/docs/POLICY_CONTRACTS.md +++ b/docs/POLICY_CONTRACTS.md @@ -8,7 +8,7 @@ consistent while each module still owns its domain rules. | Policy area | Current owner | Runtime surface | Notes | | --- | --- | --- | --- | -| Privacy retention | `govoplan-policy` routes with compatibility helpers in core | `/api/v1/admin/privacy-retention/policies/{scope}` and `/explain` | System, tenant, user, group, and campaign sources merge into the effective retention policy. Parent locks block lower-level widening. | +| Privacy retention | `govoplan-policy` implementation and routes, with compatibility helpers in core | `/api/v1/admin/privacy-retention/policies/{scope}` and `/explain`; capability `policy.privacyRetention` | System, tenant, user, group, and campaign sources merge into the effective retention policy. Parent locks block lower-level widening. | | Mail profile policy | `govoplan-mail` | `/api/v1/mail/policies/{scope}` | Uses the same source-step path format for system, tenant, owner, and campaign provenance. | | RBAC/access policy | `govoplan-access` | access capabilities in `govoplan_core.core.access` | Permission decisions should use access capability contracts. Explain responses should adopt `PolicyDecision` when an API-level explanation is added. | | Governance defaults | `govoplan-admin` plus `govoplan-access` materializer | admin settings, governance template routes, access materialization capability | System governance can block tenant-local groups, roles, and API keys. | @@ -87,6 +87,16 @@ path. For lower-level scopes, `blocked_fields` is derived from the parent policy's `allow_lower_level_limits`; clients can use it to disable local controls before attempting a write. +The retention implementation lives in `govoplan-policy` +(`govoplan_policy.backend.retention`). Core keeps +`govoplan_core.privacy.retention` only as a compatibility facade for older +imports. Effective/scoped retention behavior dispatches through the +`policy.privacyRetention` capability; core does not import policy implementation +code as a hidden fallback when the module is disabled or no runtime is active. +New backend code should import policy-owned retention behavior from +`govoplan-policy` or request the capability, not add new implementation logic +to core. + ## Frontend Contract Policy UIs must: diff --git a/docs/RELEASE_DEPENDENCIES.md b/docs/RELEASE_DEPENDENCIES.md index 06f8cfe..7fc4913 100644 --- a/docs/RELEASE_DEPENDENCIES.md +++ b/docs/RELEASE_DEPENDENCIES.md @@ -107,6 +107,15 @@ cd /mnt/DATA/git/govoplan-core scripts/push-release-tag.sh --version 0.1.6 ``` +`scripts/generate-release-catalog.py` reads installed/discovered +`ModuleManifest` objects while writing catalog entries. When a manifest is +available, the catalog entry uses the manifest version, points package refs at +`v`, and copies `provides_interfaces` / +`requires_interfaces` from the manifest. If a manifest cannot be discovered, +the entry falls back to the release version passed with `--version` and omits +interface metadata. This keeps the catalog aligned with independently +versioned module packages instead of relying on a hardcoded compatibility table. + The script also includes GovOPlaN roadmap/scaffold module repositories that do not yet have package metadata. Those repositories are committed, tagged, and pushed with the same release tag, but they are tag-only until they contain @@ -196,10 +205,109 @@ Each module entry can declare: - WebUI package name and pinned install reference - display metadata and tags - `license_features`, the feature entitlements required to plan that install +- `provides_interfaces`, named interface contracts exported by this module +- `requires_interfaces`, named interface contracts and version ranges required + by this module The signature is Ed25519 over canonical JSON with both `signature` and `signatures` removed. Core accepts the legacy single `signature` field and the new `signatures` array. +When `APP_ENV` is `prod` or `production`, module package catalog signature +verification is required by default unless +`GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE=false` is set explicitly. + +### Independent Module Versions And Interface Ranges + +Modules do not need to ship on the same version number. Release catalogs should +pin each package to the exact backend/WebUI ref being installed and declare any +cross-module API compatibility through named interfaces. + +Provider shape: + +```json +"provides_interfaces": [ + { "name": "files.campaign_attachments", "version": "1.4.0" } +] +``` + +Requirement shape: + +```json +"requires_interfaces": [ + { + "name": "files.campaign_attachments", + "version_min": "1.0.0", + "version_max_exclusive": "2.0.0" + } +] +``` + +`version_min` is inclusive. `version_max_exclusive` is exclusive, so the range +above means `>= 1.0.0` and `< 2.0.0`. Use this for SemVer major-version +compatibility lines. Set `"optional": true` only when the module can operate +without that interface being present. If a provider is installed but its +version is outside the declared optional range, activation is still blocked. + +Catalog validation normalizes these fields and warns when catalog entries do +not satisfy each other's ranges. Registry activation and installer preflight +perform the blocking checks against the discovered installed manifests before +the desired module set is activated. +The admin module-management UI shows catalog warnings in the package catalog +section and repeats them as warning-level installer preflight issues while a +package install is planned. + +Install-plan items carry a `source` field. Manually entered items use +`source: "manual"`; entries planned from the package catalog use +`source: "catalog"`. Catalog-sourced items also carry a `catalog` metadata +object with the validation snapshot used when the item was planned: catalog +source/path, source type, cache path, channel, sequence, generated/validity +timestamps, signature state, trusted key id, and cache state where available. +Catalog provenance changes preflight severity: + +- catalog-sourced installs require a configured, valid package catalog before + activation +- invalid, untrusted, expired, not-yet-valid, replayed, or unapproved-channel + catalogs block catalog-sourced installs +- the same catalog validation failures remain warnings for manual install + plans, so operators can still use offline or emergency package refs +- valid-catalog warnings, such as intentionally unsigned local catalogs when + signature enforcement is disabled, remain warnings +- selected catalog entries with unsatisfied non-optional named interface ranges + block activation before the installer runs + +### Update Paths + +Package updates are target-state operations, not one-module-at-a-time runtime +toggles. The safe unit of planning is a desired module version set plus a +catalog validation snapshot. The installer may install multiple packages into +the environment before activation, then validate the discovered manifests and +activate the resulting set together. + +This avoids circular "upgrade A first / upgrade B first" traps: named interface +requirements are solved against the target set, not against each intermediate +package-install moment. If the target set cannot satisfy all non-optional +interfaces and module dependencies at once, the plan is invalid. Operators +should add the necessary module updates to the same plan instead of trying to +force an order. + +Live data upgrades need an even stricter rule: + +- migrations must be idempotent and ordered by module migration metadata +- destructive schema/data changes need an explicit retirement or cleanup plan, + not an automatic package update side effect +- cross-module data migrations must be compatible with both the old and target + provider interface until activation finishes +- rollback must restore the package set and database state together, or be + documented as forward-only with a tested recovery procedure +- if two modules require mutually incompatible live-data states, the catalog + must publish an intermediate compatibility release rather than a circular + update chain + +In practice, circular dependencies are avoided by designing interfaces with +compatibility windows and by publishing bridge releases. A bridge release keeps +the old interface while introducing the new one, allowing dependent modules to +move first; a later release can retire the old interface after every dependent +module has a compatible target version. Trusted catalog keys are configured locally: diff --git a/docs/SELF_HOSTED_INSTALLABILITY.md b/docs/SELF_HOSTED_INSTALLABILITY.md new file mode 100644 index 0000000..c6507c7 --- /dev/null +++ b/docs/SELF_HOSTED_INSTALLABILITY.md @@ -0,0 +1,80 @@ +# Self-Hosted Installability + +GovOPlaN uses a staged self-hosted installability path. + +## Packaging Decision + +The early packaging approach is a staged combination: + +1. Generate an explicit environment template and validate it before startup. +2. Use a Compose-backed production-like development profile for local rehearsal. +3. Use the deployment operator guide as the runbook for migrations, workers, + backups, health checks, and module installer rollback drills. +4. Use the module installer CLI/daemon for package mutation once the runtime is + already installed and under maintenance mode. + +This keeps first installation understandable while still preserving the later +goal of install/update/uninstall through signed catalogs and the installer +daemon. The API server must not run package managers from request handlers. + +## Config Bootstrap + +Generate a self-hosted template: + +```bash +cd /mnt/DATA/git/govoplan-core +./.venv/bin/python -m govoplan_core.commands.config env-template \ + --profile self-hosted \ + --generate-secrets \ + --output .env.self-hosted +``` + +Validate the current shell environment: + +```bash +set -a +. .env.self-hosted +set +a +./.venv/bin/python -m govoplan_core.commands.config validate --profile self-hosted +``` + +The command reports all known blockers at once. Production-like/self-hosted +profiles require explicit `APP_ENV`, `DATABASE_URL`, `MASTER_KEY_B64`, +`ENABLED_MODULES`, and `CORS_ORIGINS`. Production rejects SQLite, development +bootstrap, insecure auth cookies, and unsigned catalog trust roots when a +catalog source is configured. + +## Production-Like Dev Stack + +Use the local production-like wrapper for repeatable rehearsal: + +```bash +cd /mnt/DATA/git/govoplan-core +scripts/production-like-dev.sh validate-config +scripts/production-like-dev.sh seed +scripts/production-like-dev.sh start +``` + +Stop Docker dependencies: + +```bash +scripts/production-like-dev.sh stop +``` + +Reset all profile data: + +```bash +scripts/production-like-dev.sh reset --yes +``` + +The start command delegates to `scripts/launch-production-like-dev.sh`, which +runs API, worker, and WebUI in the foreground. Stop those processes with +`Ctrl+C` in the launcher terminal. + +## Module Boundary Gate + +`scripts/check_dependency_boundaries.py` is part of the focused verification +path. It checks backend imports and WebUI package/source imports so modules do +not grow hidden runtime dependencies on each other. Feature modules should +integrate through core capabilities, backend APIs/events, route contributions, +or explicit UI extension points. diff --git a/docs/module-package-catalog.example.json b/docs/module-package-catalog.example.json index 888f7e2..99e4d63 100644 --- a/docs/module-package-catalog.example.json +++ b/docs/module-package-catalog.example.json @@ -15,6 +15,20 @@ "python_ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.4", "webui_package": "@govoplan/files-webui", "webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4", + "provides_interfaces": [ + { + "name": "files.campaign_attachments", + "version": "1.4.0" + } + ], + "requires_interfaces": [ + { + "name": "campaigns.access", + "version_min": "1.0.0", + "version_max_exclusive": "2.0.0", + "optional": true + } + ], "artifact_integrity": { "python": { "ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.4", @@ -44,6 +58,12 @@ "python_ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.4", "webui_package": "@govoplan/mail-webui", "webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4", + "provides_interfaces": [ + { + "name": "mail.campaign_delivery", + "version": "1.4.0" + } + ], "artifact_integrity": { "python": { "ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.4", diff --git a/pyproject.toml b/pyproject.toml index 3033ae0..1ee7786 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ where = ["src"] govoplan_core = ["py.typed"] [project.scripts] +govoplan-config = "govoplan_core.commands.config:main" govoplan-devserver = "govoplan_core.devserver:main" govoplan-module-install-plan = "govoplan_core.commands.module_install_plan:main" govoplan-module-installer = "govoplan_core.commands.module_installer:main" diff --git a/scripts/check_dependency_boundaries.py b/scripts/check_dependency_boundaries.py index f632b7f..5058b6d 100644 --- a/scripts/check_dependency_boundaries.py +++ b/scripts/check_dependency_boundaries.py @@ -2,6 +2,8 @@ from __future__ import annotations import ast +import json +import re from dataclasses import dataclass from pathlib import Path @@ -36,6 +38,23 @@ PREFIXES = { } FEATURE_OWNERS = ("files", "mail", "campaign") PLATFORM_OWNERS = ("admin", "tenancy", "organizations", "identity", "policy", "audit", "dashboard") +WEBUI_REPOS = { + "core": ROOT / "webui", + "files": ROOT.parent / "govoplan-files" / "webui", + "mail": ROOT.parent / "govoplan-mail" / "webui", + "campaign": ROOT.parent / "govoplan-campaign" / "webui", +} +WEBUI_PACKAGES = { + "core": "@govoplan/core-webui", + "files": "@govoplan/files-webui", + "mail": "@govoplan/mail-webui", + "campaign": "@govoplan/campaign-webui", +} +WEBUI_IMPORT_RE = re.compile( + r"(?:from\s+|import\s*\(\s*|require\s*\(\s*|export\s+[^;]*?\s+from\s+)" + r"[\"'](?P@govoplan/[a-z0-9_-]+-webui)(?:/[A-Za-z0-9_./-]+)?[\"']" + r"|import\s+[\"'](?P@govoplan/[a-z0-9_-]+-webui)(?:/[A-Za-z0-9_./-]+)?[\"']" +) @dataclass(frozen=True) @@ -54,6 +73,7 @@ ALLOWLIST_KEYS = {(item.owner, item.relative_path, item.imported_owner) for item @dataclass(frozen=True) class Violation: + kind: str owner: str path: Path lineno: int @@ -102,17 +122,97 @@ def _violations() -> list[Violation]: continue if owner == "core" and imported_owner in FEATURE_OWNERS: if not _allowed(owner, path, imported_owner): - violations.append(Violation(owner, path, lineno, imported, imported_owner)) + violations.append(Violation("python", owner, path, lineno, imported, imported_owner)) elif owner == "access" and imported_owner in FEATURE_OWNERS: - violations.append(Violation(owner, path, lineno, imported, imported_owner)) + violations.append(Violation("python", owner, path, lineno, imported, imported_owner)) elif owner in PLATFORM_OWNERS and imported_owner == "access": if not _allowed(owner, path, imported_owner): - violations.append(Violation(owner, path, lineno, imported, imported_owner)) + violations.append(Violation("python", owner, path, lineno, imported, imported_owner)) elif owner in FEATURE_OWNERS and imported_owner in FEATURE_OWNERS: if not _allowed(owner, path, imported_owner): - violations.append(Violation(owner, path, lineno, imported, imported_owner)) + violations.append(Violation("python", owner, path, lineno, imported, imported_owner)) elif owner in FEATURE_OWNERS and imported_owner == "access": - violations.append(Violation(owner, path, lineno, imported, imported_owner)) + violations.append(Violation("python", owner, path, lineno, imported, imported_owner)) + violations.extend(_webui_violations()) + return violations + + +def _webui_package_owner(package_name: str) -> str | None: + for owner, package in WEBUI_PACKAGES.items(): + if package_name == package: + return owner + return None + + +def _webui_source_files(root: Path) -> list[Path]: + source_root = root / "src" + if not source_root.exists(): + return [] + return [ + path + for path in source_root.rglob("*") + if path.suffix in {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"} + and "node_modules" not in path.parts + and "dist" not in path.parts + ] + + +def _webui_dependency_fields(payload: object) -> dict[str, object]: + if not isinstance(payload, dict): + return {} + result: dict[str, object] = {} + for key in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"): + value = payload.get(key) + if isinstance(value, dict): + result.update(value) + return result + + +def _webui_package_json_violations(owner: str, root: Path) -> list[Violation]: + violations: list[Violation] = [] + package_paths = [root / "package.json"] + if root.name == "webui": + package_paths.append(root.parent / "package.json") + for package_path in tuple(dict.fromkeys(package_paths)): + if not package_path.exists(): + continue + try: + payload = json.loads(package_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + return [Violation("webui-package", owner, package_path, exc.lineno, "invalid package.json", owner)] + for package_name in _webui_dependency_fields(payload): + imported_owner = _webui_package_owner(str(package_name)) + if imported_owner is None or imported_owner == owner: + continue + if owner in FEATURE_OWNERS and imported_owner in FEATURE_OWNERS: + violations.append(Violation("webui-package", owner, package_path, 1, str(package_name), imported_owner)) + return violations + + +def _webui_source_violations(owner: str, root: Path) -> list[Violation]: + violations: list[Violation] = [] + for path in _webui_source_files(root): + text = path.read_text(encoding="utf-8") + for match in WEBUI_IMPORT_RE.finditer(text): + package_name = match.group("package") or match.group("side_effect_package") + imported_owner = _webui_package_owner(package_name) + if imported_owner is None or imported_owner == owner: + continue + lineno = text.count("\n", 0, match.start()) + 1 + if owner == "core" and imported_owner in FEATURE_OWNERS: + violations.append(Violation("webui-source", owner, path, lineno, package_name, imported_owner)) + elif owner in FEATURE_OWNERS and imported_owner in FEATURE_OWNERS: + violations.append(Violation("webui-source", owner, path, lineno, package_name, imported_owner)) + return violations + + +def _webui_violations() -> list[Violation]: + violations: list[Violation] = [] + for owner, root in WEBUI_REPOS.items(): + if not root.exists(): + continue + violations.extend(_webui_package_json_violations(owner, root)) + violations.extend(_webui_source_violations(owner, root)) return violations @@ -125,7 +225,7 @@ def main() -> int: print("Dependency boundary violations:") for item in violations: print( - f"- {item.path}:{item.lineno}: {item.owner} imports {item.imported} " + f"- {item.path}:{item.lineno}: {item.owner} {item.kind} imports {item.imported} " f"({item.imported_owner}); use kernel capability/API/event contracts instead" ) print("\nIf this is unavoidable transitional debt, add a reasoned ALLOWLIST entry.") diff --git a/scripts/generate-release-catalog.py b/scripts/generate-release-catalog.py index 4d3ac0e..7598c9f 100644 --- a/scripts/generate-release-catalog.py +++ b/scripts/generate-release-catalog.py @@ -9,11 +9,17 @@ from dataclasses import dataclass from datetime import UTC, datetime, timedelta import json from pathlib import Path +import sys from typing import Any from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from govoplan_core.core.modules import ModuleManifest # noqa: E402 +from govoplan_core.server.registry import available_module_manifests # noqa: E402 + GITEA_BASE = "git+ssh://git@git.add-ideas.de/add-ideas" @@ -27,6 +33,8 @@ class CatalogModule: description: str tags: tuple[str, ...] webui_package: str | None = None + provides_interfaces: tuple[dict[str, object], ...] = () + requires_interfaces: tuple[dict[str, object], ...] = () CATALOG_MODULES = ( @@ -225,22 +233,32 @@ def _catalog_payload( repository_base: str, public_base_url: str, ) -> dict[str, Any]: + manifests = _discovered_catalog_manifests() modules: list[dict[str, Any]] = [] for module in CATALOG_MODULES: + manifest = manifests.get(module.module_id) + module_version = manifest.version if manifest is not None else version + module_tag = f"v{module_version.removeprefix('v')}" entry: dict[str, Any] = { "module_id": module.module_id, "name": module.name, "description": module.description, - "version": version, + "version": module_version, "action": "install", "python_package": module.python_package, - "python_ref": f"{module.python_package} @ {repository_base}/{module.repo}.git@{tag}", + "python_ref": f"{module.python_package} @ {repository_base}/{module.repo}.git@{module_tag}", "license_features": [f"module.{module.module_id}"], "tags": list(module.tags), } if module.webui_package: entry["webui_package"] = module.webui_package - entry["webui_ref"] = f"{repository_base}/{module.repo}.git#{tag}" + entry["webui_ref"] = f"{repository_base}/{module.repo}.git#{module_tag}" + manifest_interfaces = _manifest_interface_metadata(manifest) + entry.update(manifest_interfaces) + if module.provides_interfaces: + entry["provides_interfaces"] = [dict(item) for item in module.provides_interfaces] + if module.requires_interfaces: + entry["requires_interfaces"] = [dict(item) for item in module.requires_interfaces] modules.append(entry) return { @@ -267,6 +285,38 @@ def _catalog_payload( } +def _discovered_catalog_manifests() -> dict[str, ModuleManifest]: + try: + return available_module_manifests(ignore_load_errors=True) + except Exception: + return {} + + +def _manifest_interface_metadata(manifest: ModuleManifest | None) -> dict[str, object]: + if manifest is None: + return {} + payload: dict[str, object] = {} + if manifest.provides_interfaces: + payload["provides_interfaces"] = [ + {"name": item.name, "version": item.version} + for item in manifest.provides_interfaces + ] + if manifest.requires_interfaces: + requirements: list[dict[str, object]] = [] + for item in manifest.requires_interfaces: + requirement: dict[str, object] = { + "name": item.name, + "optional": item.optional, + } + if item.version_min is not None: + requirement["version_min"] = item.version_min + if item.version_max_exclusive is not None: + requirement["version_max_exclusive"] = item.version_max_exclusive + requirements.append(requirement) + payload["requires_interfaces"] = requirements + return payload + + def _parse_signing_key(value: str) -> tuple[str, Ed25519PrivateKey]: key_id, separator, path_text = value.partition("=") if not separator or not key_id.strip() or not path_text.strip(): diff --git a/scripts/launch-production-like-dev.sh b/scripts/launch-production-like-dev.sh index 216c7fa..527c6d4 100644 --- a/scripts/launch-production-like-dev.sh +++ b/scripts/launch-production-like-dev.sh @@ -45,7 +45,8 @@ set -a set +a export APP_ENV="${APP_ENV:-staging}" -export ENABLED_MODULES="${ENABLED_MODULES:-tenancy,access,admin,policy,audit,campaigns,files,mail,calendar,docs,ops}" +export GOVOPLAN_INSTALL_PROFILE="${GOVOPLAN_INSTALL_PROFILE:-production-like}" +export ENABLED_MODULES="${ENABLED_MODULES:-tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops}" export DATABASE_URL="${DATABASE_URL:-${GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL:-postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan}}" export GOVOPLAN_DATABASE_URL_PGTOOLS="${GOVOPLAN_DATABASE_URL_PGTOOLS:-${GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL_PGTOOLS:-postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan}}" export REDIS_URL="${REDIS_URL:-${GOVOPLAN_PRODUCTION_LIKE_REDIS_URL:-redis://127.0.0.1:56379/0}}" @@ -66,6 +67,8 @@ PY export MASTER_KEY_B64 fi +"$PYTHON" -m govoplan_core.commands.config validate --profile production-like + compose() { "$DOCKER" compose --env-file "$PROFILE_ENV" -f "$PROFILE_ROOT/docker-compose.yml" "$@" } diff --git a/scripts/production-like-dev.sh b/scripts/production-like-dev.sh new file mode 100644 index 0000000..802003b --- /dev/null +++ b/scripts/production-like-dev.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROFILE_ROOT="$ROOT/dev/production-like" +PROFILE_ENV="${GOVOPLAN_PRODUCTION_LIKE_ENV:-$PROFILE_ROOT/.env}" +if [ ! -f "$PROFILE_ENV" ]; then + PROFILE_ENV="$PROFILE_ROOT/.env.example" +fi + +PYTHON="${PYTHON:-$ROOT/.venv/bin/python}" +DOCKER="${DOCKER:-docker}" + +usage() { + cat <<'EOF' +Usage: scripts/production-like-dev.sh [--yes] + +Commands: + start Start the production-like API, worker, WebUI, PostgreSQL, and Redis profile. + stop Stop PostgreSQL and Redis profile containers. + status Show profile container status and validate the effective environment. + seed Run migrations and seed development data against the profile database. + reset --yes Stop containers, remove profile volumes, and remove runtime/production-like data. + validate-config Validate the profile environment only. + +The start command delegates to scripts/launch-production-like-dev.sh. Stop/reset +operate on Docker dependencies; API/WebUI/worker processes started by the +launcher are stopped with Ctrl+C in that launcher terminal. +EOF +} + +fail() { + printf 'production-like-dev: %s\n' "$*" >&2 + exit 1 +} + +command="${1:-}" +if [ -z "$command" ] || [ "$command" = "-h" ] || [ "$command" = "--help" ]; then + usage + exit 0 +fi +shift || true + +yes=0 +for arg in "$@"; do + case "$arg" in + --yes) yes=1 ;; + *) fail "unknown argument: $arg" ;; + esac +done + +[ -f "$PROFILE_ENV" ] || fail "profile env file not found: $PROFILE_ENV" +[ -x "$PYTHON" ] || fail "Python virtualenv not found at $PYTHON. Run: cd $ROOT && ./.venv/bin/python -m pip install -r requirements-dev.txt" + +set -a +# shellcheck disable=SC1090 +. "$PROFILE_ENV" +set +a + +export APP_ENV="${APP_ENV:-staging}" +export GOVOPLAN_INSTALL_PROFILE="${GOVOPLAN_INSTALL_PROFILE:-production-like}" +export DATABASE_URL="${DATABASE_URL:-${GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL:-postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan}}" +export GOVOPLAN_DATABASE_URL_PGTOOLS="${GOVOPLAN_DATABASE_URL_PGTOOLS:-${GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL_PGTOOLS:-postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan}}" +export REDIS_URL="${REDIS_URL:-${GOVOPLAN_PRODUCTION_LIKE_REDIS_URL:-redis://127.0.0.1:56379/0}}" +export CELERY_ENABLED="${CELERY_ENABLED:-true}" +export ENABLED_MODULES="${ENABLED_MODULES:-tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops}" +export FILE_STORAGE_BACKEND="${FILE_STORAGE_BACKEND:-local}" +export FILE_STORAGE_LOCAL_ROOT="${FILE_STORAGE_LOCAL_ROOT:-$ROOT/runtime/production-like/files}" +export DEV_AUTO_MIGRATE_ENABLED="${DEV_AUTO_MIGRATE_ENABLED:-false}" +export DEV_BOOTSTRAP_ENABLED="${DEV_BOOTSTRAP_ENABLED:-true}" +export CORS_ORIGINS="${CORS_ORIGINS:-http://127.0.0.1:5173,http://localhost:5173}" + +if [ -z "${MASTER_KEY_B64:-}" ]; then + MASTER_KEY_B64="$("$PYTHON" -m govoplan_core.commands.config env-template --profile production-like --generate-secrets | sed -n 's/^MASTER_KEY_B64=//p' | head -n 1)" + export MASTER_KEY_B64 +fi + +compose() { + "$DOCKER" compose --env-file "$PROFILE_ENV" -f "$PROFILE_ROOT/docker-compose.yml" "$@" +} + +validate_config() { + "$PYTHON" -m govoplan_core.commands.config validate --profile production-like +} + +case "$command" in + start) + exec "$ROOT/scripts/launch-production-like-dev.sh" + ;; + stop) + compose down + ;; + status) + compose ps + validate_config + ;; + seed) + validate_config + "$PYTHON" -m govoplan_core.commands.init_db --database-url "$DATABASE_URL" --with-dev-data + ;; + reset) + [ "$yes" = "1" ] || fail "reset is destructive; rerun with --yes" + compose down -v + rm -rf "$ROOT/runtime/production-like" + ;; + validate-config) + validate_config + ;; + *) + usage >&2 + fail "unknown command: $command" + ;; +esac diff --git a/scripts/push-release-tag.sh b/scripts/push-release-tag.sh index 715c0b0..2b08046 100644 --- a/scripts/push-release-tag.sh +++ b/scripts/push-release-tag.sh @@ -58,6 +58,7 @@ Repos: govoplan-tenancy govoplan-organizations govoplan-identity + govoplan-idm govoplan-policy govoplan-audit govoplan-dashboard @@ -70,7 +71,7 @@ Repos: Roadmap/scaffold module repositories are tag-only until they have package metadata: addresses, appointments, cases, connectors, dms, erp, - fit-connect, forms, identity-trust, idm, ledger, notifications, payments, + fit-connect, forms, identity-trust, ledger, notifications, payments, portal, reporting, scheduling, search, tasks, templates, workflow, xoev, xrechnung, and xta-osci. @@ -94,6 +95,7 @@ PACKAGE_MODULE_REPOS=( "$PARENT/govoplan-tenancy" "$PARENT/govoplan-organizations" "$PARENT/govoplan-identity" + "$PARENT/govoplan-idm" "$PARENT/govoplan-policy" "$PARENT/govoplan-audit" "$PARENT/govoplan-dashboard" @@ -113,7 +115,6 @@ TAG_ONLY_MODULE_REPOS=( "$PARENT/govoplan-fit-connect" "$PARENT/govoplan-forms" "$PARENT/govoplan-identity-trust" - "$PARENT/govoplan-idm" "$PARENT/govoplan-ledger" "$PARENT/govoplan-notifications" "$PARENT/govoplan-payments" @@ -301,6 +302,9 @@ update_manifest_version() { govoplan-identity) manifest_path="$repo/src/govoplan_identity/backend/manifest.py" ;; + govoplan-idm) + manifest_path="$repo/src/govoplan_idm/backend/manifest.py" + ;; govoplan-policy) manifest_path="$repo/src/govoplan_policy/backend/manifest.py" ;; diff --git a/src/govoplan_core/audit/logging.py b/src/govoplan_core/audit/logging.py index 5a90cff..0cb6e4a 100644 --- a/src/govoplan_core/audit/logging.py +++ b/src/govoplan_core/audit/logging.py @@ -1,12 +1,14 @@ from __future__ import annotations from collections.abc import Mapping +from uuid import uuid4 from typing import Any from sqlalchemy.orm import Session from govoplan_core.auth import ApiPrincipal from govoplan_core.core.change_sequence import record_change +from govoplan_core.core.access import AuditEvent, AuditRecordRef, AuditRecorder, CAPABILITY_AUDIT_RECORDER from govoplan_core.core.events import ( EventActorRef, EventObjectRef, @@ -16,8 +18,8 @@ from govoplan_core.core.events import ( normalize_trace_id, publish_platform_event, ) +from govoplan_core.core.runtime import get_registry from govoplan_core.privacy.retention import sanitize_audit_details_for_policy -from govoplan_audit.backend.db.models import AuditLog AUDIT_MODULE_ID = "audit" @@ -47,6 +49,7 @@ _ACTION_MODULE_PREFIXES = { "configuration_change": "access", "configuration_package": "access", "group": "access", + "idm": "idm", "profile": "access", "role": "access", "system_access": "access", @@ -169,7 +172,33 @@ def _module_id_for_audit_action(action: str) -> str: return _ACTION_MODULE_PREFIXES.get(prefix, prefix or AUDIT_MODULE_ID) -def _publish_audit_platform_event(item: AuditLog) -> None: +def _audit_recorder() -> AuditRecorder: + registry = get_registry() + if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_AUDIT_RECORDER): + return _NullAuditRecorder() + capability = registry.require_capability(CAPABILITY_AUDIT_RECORDER) + if not isinstance(capability, AuditRecorder): + raise RuntimeError("Audit recorder capability is invalid") + return capability + + +class _NullAuditRecorder: + def record_event(self, session: object, event: AuditEvent) -> AuditRecordRef: + del session + return AuditRecordRef( + id=str(uuid4()), + scope=event.scope, + tenant_id=event.tenant_id, + user_id=event.user_id, + api_key_id=event.api_key_id, + action=event.action, + object_type=event.resource_type, + object_id=event.resource_id, + details=dict(event.details or {}), + ) + + +def _publish_audit_platform_event(item: AuditRecordRef) -> None: trace = _compact_trace(item.details.get("_trace") if isinstance(item.details, Mapping) else None) publish_platform_event( PlatformEvent( @@ -206,7 +235,7 @@ def audit_event( correlation_id: str | None = None, causation_id: str | None = None, commit: bool = False, -) -> AuditLog: +) -> AuditRecordRef: """Persist one audit event. The function deliberately accepts primitive IDs so it can be used from API @@ -227,18 +256,17 @@ def audit_event( trace, ) - item = AuditLog( + item = _audit_recorder().record_event(session, AuditEvent( + event_type=action, + action=action, scope=scope, tenant_id=tenant_id, user_id=user_id, api_key_id=api_key_id, - action=action, - object_type=object_type, - object_id=object_id, + resource_type=object_type, + resource_id=object_id, details=stored_details, - ) - session.add(item) - session.flush() + )) record_change( session, module_id=AUDIT_MODULE_ID, @@ -269,7 +297,7 @@ def audit_from_principal( correlation_id: str | None = None, causation_id: str | None = None, commit: bool = False, -) -> AuditLog: +) -> AuditRecordRef: return audit_event( session, tenant_id=principal.tenant_id, diff --git a/src/govoplan_core/auth/__init__.py b/src/govoplan_core/auth/__init__.py index 6f9f59b..01eda34 100644 --- a/src/govoplan_core/auth/__init__.py +++ b/src/govoplan_core/auth/__init__.py @@ -3,11 +3,167 @@ from __future__ import annotations """Core auth dependency facade. Routers depend on this module instead of a concrete access-provider package. -The current implementation delegates to the access module; replacing this -facade is the remaining step toward a fully provider-neutral auth kernel. +The active auth module provides the request principal through the platform +capability registry. """ -from govoplan_access.auth import ApiPrincipal, get_api_principal, has_scope, require_any_scope, require_scope +from dataclasses import dataclass + +from fastapi import Depends, Header, HTTPException, Request, status +from sqlalchemy.orm import Session + +from govoplan_core.core.access import ( + CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER, + ApiPrincipalProvider, + PermissionEvaluator, + PrincipalRef, +) +from govoplan_core.core.registry import PlatformRegistry +from govoplan_core.db.session import get_session +from govoplan_core.security.module_permissions import scopes_grant_compatible + + +@dataclass(slots=True) +class ApiPrincipal: + """Request principal exposed to API routers. + + The stable fields live in ``principal``. ``account``, ``user``, + ``api_key``, and ``auth_session`` are provider-owned objects kept for + current routers that still need concrete account/session state. + """ + + principal: PrincipalRef + account: object + user: object + api_key: object | None = None + auth_session: object | None = None + permission_evaluator: PermissionEvaluator | None = None + + @property + def account_id(self) -> str: + return self.principal.account_id + + @property + def membership_id(self) -> str | None: + return self.principal.membership_id + + @property + def tenant_id(self) -> str: + if self.principal.tenant_id is None: + raise RuntimeError("Tenant principal has no active tenant id.") + return self.principal.tenant_id + + @property + def scopes(self) -> frozenset[str]: + return self.principal.scopes + + @property + def group_ids(self) -> frozenset[str]: + return self.principal.group_ids + + @property + def role_ids(self) -> frozenset[str]: + return self.principal.role_ids + + @property + def function_assignment_ids(self) -> frozenset[str]: + return self.principal.function_assignment_ids + + @property + def delegation_ids(self) -> frozenset[str]: + return self.principal.delegation_ids + + @property + def identity_id(self) -> str | None: + return self.principal.identity_id + + @property + def acting_for_account_id(self) -> str | None: + return self.principal.acting_for_account_id + + @property + def auth_method(self) -> str: + return self.principal.auth_method + + @property + def api_key_id(self) -> str | None: + return self.principal.api_key_id + + @property + def session_id(self) -> str | None: + return self.principal.session_id + + @property + def email(self) -> str | None: + return self.principal.email + + @property + def display_name(self) -> str | None: + return self.principal.display_name + + def has(self, required_scope: str) -> bool: + if self.permission_evaluator is not None: + return self.permission_evaluator.has_scope(self.principal, required_scope) + return scopes_grant_compatible(self.scopes, required_scope) + + def to_platform_principal(self) -> PrincipalRef: + return self.principal + + +def _registry_from_request(request: Request) -> PlatformRegistry | None: + registry = getattr(request.app.state, "govoplan_registry", None) + return registry if isinstance(registry, PlatformRegistry) else None + + +def _api_principal_provider_from_request(request: Request) -> ApiPrincipalProvider: + registry = _registry_from_request(request) + if registry is None or not registry.has_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER): + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Auth provider is not available") + capability = registry.require_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER) + if not isinstance(capability, ApiPrincipalProvider): + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid auth provider capability") + return capability + + +def get_api_principal( + request: Request, + session: Session = Depends(get_session), + authorization: str | None = Header(default=None), + x_api_key: str | None = Header(default=None, alias="X-API-Key"), +) -> ApiPrincipal: + principal = _api_principal_provider_from_request(request).resolve_api_principal( + request, + session, + authorization=authorization, + x_api_key=x_api_key, + ) + if not isinstance(principal, ApiPrincipal): + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid API principal") + return principal + + +def has_scope(principal: ApiPrincipal, required_scope: str) -> bool: + return principal.has(required_scope) + + +def require_scope(required_scope: str): + def dependency(principal: ApiPrincipal = Depends(get_api_principal)) -> ApiPrincipal: + if not has_scope(principal, required_scope): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {required_scope}") + return principal + + return dependency + + +def require_any_scope(*required_scopes: str): + def dependency(principal: ApiPrincipal = Depends(get_api_principal)) -> ApiPrincipal: + if not any(has_scope(principal, required) for required in required_scopes): + joined = ", ".join(required_scopes) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Requires one of: {joined}") + return principal + + return dependency + __all__ = [ "ApiPrincipal", diff --git a/src/govoplan_core/commands/config.py b/src/govoplan_core/commands/config.py new file mode 100644 index 0000000..496cd9d --- /dev/null +++ b/src/govoplan_core/commands/config.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from typing import Sequence + +from govoplan_core.core.install_config import env_template, validate_runtime_configuration + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Generate and validate GovOPlaN install/runtime configuration.") + subparsers = parser.add_subparsers(dest="command", required=True) + + env_parser = subparsers.add_parser("env-template", help="Print or write an install .env template.") + env_parser.add_argument("--profile", default="self-hosted", choices=("self-hosted", "production-like"), help="Template profile to generate.") + env_parser.add_argument("--output", type=Path, help="Write the template to this path instead of stdout.") + env_parser.add_argument("--overwrite", action="store_true", help="Overwrite an existing output path.") + env_parser.add_argument("--generate-secrets", action="store_true", help="Generate a fresh MASTER_KEY_B64 in the template.") + + validate_parser = subparsers.add_parser("validate", help="Validate the current process environment.") + validate_parser.add_argument("--profile", help="Install profile. Defaults to GOVOPLAN_INSTALL_PROFILE or APP_ENV.") + validate_parser.add_argument("--strict", action="store_true", help="Treat warnings as errors.") + validate_parser.add_argument("--format", choices=("text", "json"), default="text", help="Output format.") + + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(argv) + if args.command == "env-template": + payload = env_template(profile=args.profile, generate_secrets=args.generate_secrets) + if args.output is None: + print(payload, end="") + return 0 + output = args.output.expanduser() + if output.exists() and not args.overwrite: + print(f"Refusing to overwrite existing file: {output}", file=sys.stderr) + return 2 + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(payload, encoding="utf-8") + print(f"Wrote {output}") + return 0 + + if args.command == "validate": + result = validate_runtime_configuration(profile=args.profile, strict=args.strict) + print(result.to_json() if args.format == "json" else result.to_text()) + return 0 if result.ok else 1 + + raise AssertionError(f"Unhandled command: {args.command}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/govoplan_core/core/access.py b/src/govoplan_core/core/access.py index 29d484d..c5e19cb 100644 --- a/src/govoplan_core/core/access.py +++ b/src/govoplan_core/core/access.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Iterable, Mapping, Sequence +from collections.abc import Callable, Iterable, Mapping, Sequence from dataclasses import dataclass, field from datetime import datetime from typing import Literal, Protocol, cast, runtime_checkable @@ -24,9 +24,13 @@ CAPABILITY_ACCESS_ADMINISTRATION = "access.administration" CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER = "access.governanceMaterializer" CAPABILITY_TENANCY_TENANT_RESOLVER = "tenancy.tenantResolver" CAPABILITY_AUDIT_SINK = "audit.sink" +CAPABILITY_AUDIT_RECORDER = "audit.recorder" +CAPABILITY_AUDIT_RETENTION = "audit.retention" +CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER = "auth.apiPrincipalProvider" CAPABILITY_AUTH_PRINCIPAL_RESOLVER = "auth.principalResolver" CAPABILITY_AUTH_PERMISSION_EVALUATOR = "auth.permissionEvaluator" +CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER = "auth.tenantContextSwitcher" ACCESS_CAPABILITY_NAMES = frozenset( { @@ -41,15 +45,21 @@ ACCESS_CAPABILITY_NAMES = frozenset( CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER, CAPABILITY_TENANCY_TENANT_RESOLVER, CAPABILITY_AUDIT_SINK, + CAPABILITY_AUDIT_RECORDER, + CAPABILITY_AUDIT_RETENTION, + CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR, + CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER, } ) AUTH_CAPABILITY_NAMES = frozenset( { + CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR, + CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER, } ) @@ -302,6 +312,26 @@ class TenantOwnerCandidateRef: display_name: str | None = None +@dataclass(frozen=True, slots=True) +class CreatedApiKeyRef: + id: str + secret: str + + +@dataclass(frozen=True, slots=True) +class DevelopmentBootstrapRef: + user: UserRef + created_api_key: CreatedApiKeyRef | None = None + + +@dataclass(frozen=True, slots=True) +class TenantContextSwitchRef: + account_id: str + membership_id: str + tenant_id: str + session_id: str | None = None + + @dataclass(frozen=True, slots=True) class GovernanceTemplateMaterialization: template_id: str @@ -321,7 +351,10 @@ class AuditEvent: action: str outcome: AuditOutcome = "unknown" actor: PrincipalRef | None = None + scope: Literal["tenant", "system"] = "tenant" tenant_id: str | None = None + user_id: str | None = None + api_key_id: str | None = None resource_type: str | None = None resource_id: str | None = None occurred_at: datetime | None = None @@ -330,12 +363,45 @@ class AuditEvent: details: Mapping[str, object] = field(default_factory=dict) +@dataclass(frozen=True, slots=True) +class AuditRecordRef: + id: str + scope: str + tenant_id: str | None + user_id: str | None + api_key_id: str | None + action: str + object_type: str | None = None + object_id: str | None = None + details: Mapping[str, object] = field(default_factory=dict) + created_at: datetime | None = None + + @runtime_checkable class PrincipalResolver(Protocol): def resolve_request(self, request: object, *, session: object | None = None) -> PrincipalRef: ... +@runtime_checkable +class ApiPrincipalProvider(Protocol): + def resolve_api_principal( + self, + request: object, + session: object, + *, + authorization: str | None = None, + x_api_key: str | None = None, + ) -> object: + ... + + +@runtime_checkable +class TenantContextSwitcher(Protocol): + def switch_tenant_context(self, session: object, *, principal: object, tenant_id: str) -> TenantContextSwitchRef: + ... + + @runtime_checkable class TenantResolver(Protocol): def get_tenant(self, tenant_id: str) -> TenantRef | None: @@ -467,9 +533,24 @@ class TenantAccessProvisioner(Protocol): ) -> str: ... + def ensure_development_admin( + self, + session: object, + *, + tenant: object, + user_email: str, + user_password: str, + api_key_secret: str | None, + scopes: Sequence[str], + ) -> DevelopmentBootstrapRef: + ... + @runtime_checkable class AccessAdministration(Protocol): + def tenant_counts(self, session: object, tenant_id: str) -> Mapping[str, int]: + ... + def system_account_count(self, session: object) -> int: ... @@ -485,6 +566,32 @@ class AccessAdministration(Protocol): def user_ids_for_actor_filter(self, session: object, *, operator: str, value: str) -> Sequence[str]: ... + def user_settings(self, session: object, user_id: str, *, tenant_id: str) -> Mapping[str, object] | None: + ... + + def set_user_settings( + self, + session: object, + user_id: str, + *, + tenant_id: str, + settings: Mapping[str, object], + ) -> Mapping[str, object] | None: + ... + + def group_settings(self, session: object, group_id: str, *, tenant_id: str) -> Mapping[str, object] | None: + ... + + def set_group_settings( + self, + session: object, + group_id: str, + *, + tenant_id: str, + settings: Mapping[str, object], + ) -> Mapping[str, object] | None: + ... + @runtime_checkable class AccessGovernanceMaterializer(Protocol): @@ -499,3 +606,22 @@ class AccessGovernanceMaterializer(Protocol): class AuditSink(Protocol): def record(self, event: AuditEvent) -> None: ... + + +@runtime_checkable +class AuditRecorder(Protocol): + def record_event(self, session: object, event: AuditEvent) -> AuditRecordRef: + ... + + +@runtime_checkable +class AuditRetentionProvider(Protocol): + def apply_detail_retention( + self, + session: object, + *, + dry_run: bool, + now: datetime, + policy_for_record: Callable[[AuditRecordRef], object], + ) -> Mapping[str, int]: + ... diff --git a/src/govoplan_core/core/configuration_safety.py b/src/govoplan_core/core/configuration_safety.py index cbb14e1..4e5fcab 100644 --- a/src/govoplan_core/core/configuration_safety.py +++ b/src/govoplan_core/core/configuration_safety.py @@ -205,7 +205,21 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = ( policy_explanation_required=True, audit_event="organizations.model.updated", rollback_history_required=True, - notes="Organization meta-model, concrete units, relations, functions, and assignments can be governed per tenant.", + notes="Organization meta-model, concrete units, relations, and function definitions can be governed per tenant.", + ), + ConfigurationFieldSafety( + key="idm.organization_assignments", + label="IDM organization function assignments", + owner_module="idm", + scope="tenant", + storage="module_settings", + ui_managed=True, + risk="high", + dry_run_required=True, + policy_explanation_required=True, + audit_event="idm.organization_assignment.updated", + rollback_history_required=True, + notes="Identity-to-organization-function assignments can grant role-derived access when access consumes IDM assignment links.", ), ConfigurationFieldSafety( key="mail_profiles.credentials", diff --git a/src/govoplan_core/core/idm.py b/src/govoplan_core/core/idm.py new file mode 100644 index 0000000..88d3a9b --- /dev/null +++ b/src/govoplan_core/core/idm.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import datetime +from typing import Literal, Protocol, runtime_checkable + + +IDM_MODULE_ID = "idm" +CAPABILITY_IDM_DIRECTORY = "idm.directory" + +IdmStatus = Literal["active", "inactive", "suspended"] +OrganizationFunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"] + + +@dataclass(frozen=True, slots=True) +class OrganizationFunctionAssignmentRef: + id: str + tenant_id: str + identity_id: str + function_id: str + organization_unit_id: str + account_id: str | None = None + applies_to_subunits: bool = False + source: OrganizationFunctionAssignmentSource = "direct" + delegated_from_assignment_id: str | None = None + acting_for_account_id: str | None = None + valid_from: datetime | None = None + valid_until: datetime | None = None + status: IdmStatus = "active" + + +@runtime_checkable +class IdmDirectory(Protocol): + def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None: + ... + + def organization_function_assignments_for_identity( + self, + identity_id: str, + *, + tenant_id: str | None = None, + ) -> Sequence[OrganizationFunctionAssignmentRef]: + ... + + def organization_function_assignments_for_account( + self, + account_id: str, + *, + tenant_id: str | None = None, + ) -> Sequence[OrganizationFunctionAssignmentRef]: + ... diff --git a/src/govoplan_core/core/install_config.py b/src/govoplan_core/core/install_config.py new file mode 100644 index 0000000..40de016 --- /dev/null +++ b/src/govoplan_core/core/install_config.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +import base64 +import json +import os +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Literal + +from cryptography.fernet import Fernet +from sqlalchemy.engine import make_url + + +ConfigIssueLevel = Literal["error", "warning"] + + +@dataclass(frozen=True, slots=True) +class ConfigIssue: + level: ConfigIssueLevel + key: str + message: str + action: str + + def to_dict(self) -> dict[str, str]: + return { + "level": self.level, + "key": self.key, + "message": self.message, + "action": self.action, + } + + +@dataclass(frozen=True, slots=True) +class ConfigValidationResult: + profile: str + issues: tuple[ConfigIssue, ...] + + @property + def errors(self) -> tuple[ConfigIssue, ...]: + return tuple(issue for issue in self.issues if issue.level == "error") + + @property + def warnings(self) -> tuple[ConfigIssue, ...]: + return tuple(issue for issue in self.issues if issue.level == "warning") + + @property + def ok(self) -> bool: + return not self.errors + + def to_dict(self) -> dict[str, object]: + return { + "profile": self.profile, + "ok": self.ok, + "errors": [issue.to_dict() for issue in self.errors], + "warnings": [issue.to_dict() for issue in self.warnings], + } + + def to_json(self) -> str: + return json.dumps(self.to_dict(), indent=2, sort_keys=True) + + def to_text(self) -> str: + lines = [ + f"GovOPlaN configuration validation: {'OK' if self.ok else 'FAILED'}", + f"Profile: {self.profile}", + ] + if not self.issues: + lines.append("No configuration issues found.") + return "\n".join(lines) + for issue in self.issues: + lines.append(f"- {issue.level.upper()} {issue.key}: {issue.message}") + lines.append(f" Action: {issue.action}") + return "\n".join(lines) + + +_LOCAL_PROFILES = {"dev", "local", "local-dev", "test"} +_PRODUCTION_PROFILES = {"prod", "production", "self-hosted"} +_PRODUCTION_LIKE_PROFILES = {"staging", "production-like", "production-like-dev"} +_DEFAULT_LOCAL_CORS = { + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:8080", +} + + +def normalize_install_profile(value: str | None) -> str: + profile = (value or "").strip().lower().replace("_", "-") + if profile in {"prod", "production"}: + return "production" + if profile in {"selfhosted", "self-hosted"}: + return "self-hosted" + if profile in {"productionlike", "production-like"}: + return "production-like" + if profile in {"local", "local-dev"}: + return "local-dev" + return profile or "local-dev" + + +def generate_master_key() -> str: + return Fernet.generate_key().decode("ascii") + + +def env_template(*, profile: str = "self-hosted", generate_secrets: bool = False) -> str: + clean_profile = normalize_install_profile(profile) + master_key = generate_master_key() if generate_secrets else "" + if clean_profile == "production-like": + return _production_like_env_template(master_key) + return _self_hosted_env_template(master_key) + + +def validate_runtime_configuration( + environ: Mapping[str, str] | None = None, + *, + profile: str | None = None, + strict: bool = False, +) -> ConfigValidationResult: + env = dict(os.environ if environ is None else environ) + clean_profile = normalize_install_profile(profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV")) + issues: list[ConfigIssue] = [] + + production = clean_profile in _PRODUCTION_PROFILES or env.get("APP_ENV", "").strip().lower() in {"prod", "production"} + production_like = production or clean_profile in _PRODUCTION_LIKE_PROFILES + local = clean_profile in _LOCAL_PROFILES and not production_like + + def issue(level: ConfigIssueLevel, key: str, message: str, action: str) -> None: + if strict and level == "warning": + level = "error" + issues.append(ConfigIssue(level=level, key=key, message=message, action=action)) + + app_env = _clean(env.get("APP_ENV")) + if not app_env and production_like: + issue("error", "APP_ENV", "APP_ENV is missing for a production-like install.", "Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.") + elif app_env.lower() in {"dev", "test", "local"} and production_like: + issue("error", "APP_ENV", f"APP_ENV={app_env!r} is not valid for profile {clean_profile!r}.", "Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.") + + database_url = _clean(env.get("DATABASE_URL")) + if not database_url: + issue("error", "DATABASE_URL", "DATABASE_URL is missing.", "Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.") + else: + backend = _database_backend(database_url) + if backend is None: + issue("error", "DATABASE_URL", "DATABASE_URL is not a valid SQLAlchemy URL.", "Use a value like postgresql+psycopg://user:password@host:5432/database.") + elif backend == "sqlite" and production_like: + issue("error", "DATABASE_URL", "SQLite is only supported for disposable local development.", "Use PostgreSQL for production-like and self-hosted installs.") + elif backend != "postgresql" and production: + issue("warning", "DATABASE_URL", f"Database backend {backend!r} is not the preferred production target.", "Use PostgreSQL unless this deployment has an explicit support decision.") + if backend == "postgresql" and not _clean(env.get("GOVOPLAN_DATABASE_URL_PGTOOLS")): + issue("warning", "GOVOPLAN_DATABASE_URL_PGTOOLS", "PostgreSQL backup/restore tools URL is missing.", "Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.") + + master_key = _clean(env.get("MASTER_KEY_B64")) + if not master_key and not local: + issue("error", "MASTER_KEY_B64", "MASTER_KEY_B64 is required outside local dev/test.", "Generate a Fernet key with `govoplan-config env-template --generate-secrets` or `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` and store it in deployment secrets.") + elif master_key: + error = _master_key_error(master_key) + if error: + issue("error", "MASTER_KEY_B64", error, "Replace MASTER_KEY_B64 with a Fernet key or base64-encoded 32-byte key.") + elif "change-me" in master_key.lower() or "generate" in master_key.lower(): + issue("error", "MASTER_KEY_B64", "MASTER_KEY_B64 still looks like a placeholder.", "Generate a real deployment key and store it outside git.") + + enabled_modules = _csv(env.get("ENABLED_MODULES")) + if not enabled_modules and production_like: + issue("error", "ENABLED_MODULES", "ENABLED_MODULES is missing.", "Set ENABLED_MODULES explicitly so startup module composition is intentional.") + elif "access" not in enabled_modules and production_like: + issue("error", "ENABLED_MODULES", "The access module is not enabled.", "Include `access` unless this deployment has a replacement auth/principal provider.") + elif enabled_modules and "admin" not in enabled_modules and production_like: + issue("warning", "ENABLED_MODULES", "The admin module is not enabled.", "Keep `admin` enabled for operator UI unless this is a deliberately headless install.") + + celery_enabled = _truthy(env.get("CELERY_ENABLED")) + if celery_enabled and not _clean(env.get("REDIS_URL")): + issue("error", "REDIS_URL", "CELERY_ENABLED=true but REDIS_URL is missing.", "Set REDIS_URL to the Redis broker/result backend used by workers.") + + if production and _truthy(env.get("DEV_BOOTSTRAP_ENABLED")): + issue("error", "DEV_BOOTSTRAP_ENABLED", "Development bootstrap is enabled in production.", "Set DEV_BOOTSTRAP_ENABLED=false and create first administrators through the controlled bootstrap path.") + + if production and not _truthy(env.get("AUTH_COOKIE_SECURE")): + issue("error", "AUTH_COOKIE_SECURE", "Secure auth cookies are disabled for production.", "Set AUTH_COOKIE_SECURE=true behind HTTPS.") + + cors_origins = _csv(env.get("CORS_ORIGINS")) + if production_like and not cors_origins: + issue("error", "CORS_ORIGINS", "CORS_ORIGINS is missing.", "Set CORS_ORIGINS to the exact WebUI origin or origins.") + elif "*" in cors_origins and production_like: + issue("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.") + elif production and set(cors_origins) <= _DEFAULT_LOCAL_CORS: + issue("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.") + + storage_backend = _clean(env.get("FILE_STORAGE_BACKEND")) or "local" + if storage_backend == "local": + if not _clean(env.get("FILE_STORAGE_LOCAL_ROOT")) and production_like: + issue("error", "FILE_STORAGE_LOCAL_ROOT", "Local file storage root is missing.", "Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.") + elif production: + issue("warning", "FILE_STORAGE_BACKEND", "Production is configured for local file storage.", "Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.") + elif storage_backend == "s3": + for key in ("FILE_STORAGE_S3_ENDPOINT_URL", "FILE_STORAGE_S3_REGION", "FILE_STORAGE_S3_ACCESS_KEY_ID", "FILE_STORAGE_S3_SECRET_ACCESS_KEY", "FILE_STORAGE_S3_BUCKET"): + if not _clean(env.get(key)): + issue("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.") + else: + issue("error", "FILE_STORAGE_BACKEND", f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.", "Use `local` or `s3`.") + + catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG")) + if production and catalog_source: + if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE")): + issue("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "A module catalog source is configured without a trusted keyring file.", "Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.") + if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL")): + issue("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL", "A module catalog source is configured without an approved release channel.", "Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.") + + return ConfigValidationResult(profile=clean_profile, issues=tuple(issues)) + + +def _self_hosted_env_template(master_key: str) -> str: + return f"""# GovOPlaN self-hosted install configuration. +# Copy to a deployment-local .env or secret store. Do not commit populated secrets. + +APP_ENV=production +GOVOPLAN_INSTALL_PROFILE=self-hosted +MASTER_KEY_B64={master_key} + +DATABASE_URL=postgresql+psycopg://govoplan:change-me@127.0.0.1:5432/govoplan +GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:change-me@127.0.0.1:5432/govoplan + +ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,files,mail,campaigns,calendar,docs,ops + +CELERY_ENABLED=true +REDIS_URL=redis://127.0.0.1:6379/0 +CELERY_QUEUES=send_email,append_sent,default + +CORS_ORIGINS=https://govoplan.example.org +AUTH_COOKIE_SECURE=true +AUTH_COOKIE_SAMESITE=lax +AUTH_COOKIE_DOMAIN= + +FILE_STORAGE_BACKEND=local +FILE_STORAGE_LOCAL_ROOT=/var/lib/govoplan/files +FILE_STORAGE_LOCAL_FALLBACK_ROOTS= + +DEV_AUTO_MIGRATE_ENABLED=false +DEV_BOOTSTRAP_ENABLED=false +DEV_MAILBOX_API_ENABLED=false + +GOVOPLAN_MODULE_PACKAGE_CATALOG_URL=https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json +GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE=/etc/govoplan/catalog-keyring.json +GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable +""" + + +def _production_like_env_template(master_key: str) -> str: + return f"""# GovOPlaN production-like development profile. +# Copy to dev/production-like/.env for local overrides. + +APP_ENV=staging +GOVOPLAN_INSTALL_PROFILE=production-like +MASTER_KEY_B64={master_key} + +GOVOPLAN_PRODUCTION_LIKE_POSTGRES_DB=govoplan +GOVOPLAN_PRODUCTION_LIKE_POSTGRES_USER=govoplan +GOVOPLAN_PRODUCTION_LIKE_POSTGRES_PASSWORD=govoplan-dev +GOVOPLAN_PRODUCTION_LIKE_POSTGRES_PORT=55433 +GOVOPLAN_PRODUCTION_LIKE_REDIS_PORT=56379 + +GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan +GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan +GOVOPLAN_PRODUCTION_LIKE_REDIS_URL=redis://127.0.0.1:56379/0 + +DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan +GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan +REDIS_URL=redis://127.0.0.1:56379/0 +CELERY_ENABLED=true +CELERY_QUEUES=send_email,append_sent,default + +ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops +CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173 +AUTH_COOKIE_SECURE=false +FILE_STORAGE_BACKEND=local +FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files +DEV_AUTO_MIGRATE_ENABLED=false +DEV_BOOTSTRAP_ENABLED=true +""" + + +def _clean(value: str | None) -> str: + return str(value or "").strip() + + +def _csv(value: str | None) -> tuple[str, ...]: + return tuple(dict.fromkeys(item.strip() for item in str(value or "").split(",") if item.strip())) + + +def _truthy(value: str | None) -> bool: + return _clean(value).lower() in {"1", "true", "yes", "on"} + + +def _database_backend(database_url: str) -> str | None: + try: + return make_url(database_url).get_backend_name() + except Exception: + return None + + +def _master_key_error(value: str) -> str | None: + candidate = value.strip().encode("utf-8") + try: + Fernet(candidate) + return None + except Exception: + pass + try: + raw = base64.b64decode(candidate) + except Exception: + return "MASTER_KEY_B64 must be a Fernet key or base64-encoded 32-byte key." + if len(raw) != 32: + return "MASTER_KEY_B64 must decode to exactly 32 bytes." + try: + Fernet(base64.urlsafe_b64encode(raw)) + except Exception: + return "MASTER_KEY_B64 is not usable as a Fernet key." + return None diff --git a/src/govoplan_core/core/module_installer.py b/src/govoplan_core/core/module_installer.py index c37d4bd..6a17345 100644 --- a/src/govoplan_core/core/module_installer.py +++ b/src/govoplan_core/core/module_installer.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections import defaultdict from collections.abc import Iterable, Mapping from contextlib import AbstractContextManager, closing from dataclasses import dataclass @@ -35,6 +36,7 @@ from govoplan_core.core.module_management import ( save_module_install_plan, ) from govoplan_core.core.modules import ModuleManifest, MigrationRetirementPlan +from govoplan_core.core.versioning import compare_versions, format_version_range, version_satisfies_range IssueSeverity = Literal["blocker", "warning", "info"] ChecklistStatus = Literal["done", "pending", "blocked", "warning"] @@ -162,7 +164,9 @@ def module_install_preflight( if not maintenance_mode: issues.append(ModuleInstallerIssue("blocker", "maintenance_required", "Package changes require maintenance mode.")) - issues.extend(module_manifest_compatibility_issues(available)) + activation_candidates = desired_modules_after_package_plan(desired, plan) + issues.extend(module_manifest_compatibility_issues(available, module_ids=activation_candidates)) + issues.extend(_package_catalog_preflight_issues(plan, available)) frontend_rebuild_required = False for item in planned_items: @@ -965,10 +969,15 @@ def _module_verify_command(plan: ModuleInstallPlan) -> dict[str, Any] | None: return _structured_command(argv) -def module_manifest_compatibility_issues(available: Mapping[str, ModuleManifest]) -> tuple[ModuleInstallerIssue, ...]: +def module_manifest_compatibility_issues( + available: Mapping[str, ModuleManifest], + *, + module_ids: Iterable[str] | None = None, +) -> tuple[ModuleInstallerIssue, ...]: current_core_version = _current_core_version() issues: list[ModuleInstallerIssue] = [] - for manifest in available.values(): + manifests = _selected_manifests(available, module_ids) + for manifest in manifests.values(): compatibility = manifest.compatibility if compatibility.manifest_contract_version != SUPPORTED_MANIFEST_CONTRACT_VERSION: issues.append(ModuleInstallerIssue( @@ -980,20 +989,21 @@ def module_manifest_compatibility_issues(available: Mapping[str, ModuleManifest] ), manifest.id, )) - if compatibility.core_version_min and _compare_versions(current_core_version, compatibility.core_version_min) < 0: + if compatibility.core_version_min and compare_versions(current_core_version, compatibility.core_version_min) < 0: issues.append(ModuleInstallerIssue( "blocker", "core_version_too_low", f"Module requires core >= {compatibility.core_version_min}; current core is {current_core_version}.", manifest.id, )) - if compatibility.core_version_max and _compare_versions(current_core_version, compatibility.core_version_max) > 0: + if compatibility.core_version_max and compare_versions(current_core_version, compatibility.core_version_max) > 0: issues.append(ModuleInstallerIssue( "blocker", "core_version_too_high", f"Module supports core <= {compatibility.core_version_max}; current core is {current_core_version}.", manifest.id, )) + issues.extend(_interface_contract_issues(manifests)) return tuple(issues) @@ -1013,24 +1023,210 @@ def _current_core_version() -> str: return "0.0.0" -def _compare_versions(left: str, right: str) -> int: - left_parts = _version_tuple(left) - right_parts = _version_tuple(right) - max_length = max(len(left_parts), len(right_parts)) - padded_left = left_parts + (0,) * (max_length - len(left_parts)) - padded_right = right_parts + (0,) * (max_length - len(right_parts)) - if padded_left < padded_right: - return -1 - if padded_left > padded_right: - return 1 - return 0 +def _selected_manifests( + available: Mapping[str, ModuleManifest], + module_ids: Iterable[str] | None, +) -> dict[str, ModuleManifest]: + if module_ids is None: + return dict(available) + selected: dict[str, ModuleManifest] = {} + for module_id in module_ids: + manifest = available.get(module_id) + if manifest is not None: + selected[module_id] = manifest + return selected -def _version_tuple(value: str) -> tuple[int, ...]: - match = re.match(r"^\s*(\d+(?:\.\d+)*)", value) - if not match: - return (0,) - return tuple(int(part) for part in match.group(1).split(".")) +def _interface_contract_issues(available: Mapping[str, ModuleManifest]) -> tuple[ModuleInstallerIssue, ...]: + providers: dict[str, list[tuple[str, str]]] = defaultdict(list) + for manifest in available.values(): + for provided in manifest.provides_interfaces: + providers[provided.name].append((manifest.id, provided.version)) + + issues: list[ModuleInstallerIssue] = [] + for manifest in available.values(): + for requirement in manifest.requires_interfaces: + available_versions = providers.get(requirement.name, []) + matching = [ + (module_id, version) + for module_id, version in available_versions + if version_satisfies_range( + version, + version_min=requirement.version_min, + version_max_exclusive=requirement.version_max_exclusive, + ) + ] + if matching: + continue + version_range = format_version_range( + version_min=requirement.version_min, + version_max_exclusive=requirement.version_max_exclusive, + ) + if not available_versions: + if requirement.optional: + continue + issues.append(ModuleInstallerIssue( + "blocker", + "required_interface_missing", + f"Module requires interface {requirement.name!r} ({version_range}), but no provider is installed.", + manifest.id, + )) + continue + provider_summary = ", ".join(f"{module_id}@{version}" for module_id, version in available_versions) + issues.append(ModuleInstallerIssue( + "blocker", + "interface_version_mismatch", + ( + f"Module requires interface {requirement.name!r} ({version_range}), " + f"but installed providers are {provider_summary}." + ), + manifest.id, + )) + return tuple(issues) + + +def _package_catalog_preflight_issues( + plan: ModuleInstallPlan, + available: Mapping[str, ModuleManifest], +) -> tuple[ModuleInstallerIssue, ...]: + install_items = tuple(item for item in plan.items if item.status == "planned" and item.action == "install") + if not install_items: + return () + catalog_items = tuple(item for item in install_items if item.source == "catalog") + try: + from govoplan_core.core.module_package_catalog import validate_module_package_catalog + + result = validate_module_package_catalog() + except Exception as exc: + severity: IssueSeverity = "blocker" if catalog_items else "warning" + return (ModuleInstallerIssue( + severity, + "catalog_validation_failed", + f"Package catalog could not be validated for this install plan: {exc}", + ),) + if not result.get("configured"): + if catalog_items: + return (ModuleInstallerIssue( + "blocker", + "catalog_required", + "Catalog-sourced package installs require a configured package catalog before activation.", + ),) + return () + issues: list[ModuleInstallerIssue] = [] + if result.get("valid") is not True: + severity = "blocker" if catalog_items else "warning" + issues.append(ModuleInstallerIssue( + severity, + "catalog_validation_failed", + str(result.get("error") or "Module package catalog is invalid."), + )) + return tuple(issues) + warnings = result.get("warnings") + if isinstance(warnings, list): + issues.extend( + ModuleInstallerIssue("warning", "catalog_warning", str(warning)) + for warning in warnings + ) + if catalog_items: + issues.extend(_selected_catalog_interface_issues(catalog_items, result, available)) + return tuple(issues) + + +def _selected_catalog_interface_issues( + catalog_items: tuple[ModuleInstallPlanItem, ...], + validation: Mapping[str, object], + available: Mapping[str, ModuleManifest], +) -> tuple[ModuleInstallerIssue, ...]: + modules = { + str(item.get("module_id")): item + for item in validation.get("modules", []) + if isinstance(item, Mapping) and item.get("module_id") + } + catalog_provider_versions: dict[str, list[tuple[str, str]]] = defaultdict(list) + for module_id, module in modules.items(): + for provided in _catalog_interface_items(module.get("provides_interfaces")): + name = provided.get("name") if isinstance(provided.get("name"), str) else None + version = provided.get("version") if isinstance(provided.get("version"), str) else None + if name and version: + catalog_provider_versions[name].append((module_id, version)) + installed_provider_versions: dict[str, list[tuple[str, str]]] = defaultdict(list) + for manifest in available.values(): + for provided in manifest.provides_interfaces: + installed_provider_versions[provided.name].append((manifest.id, provided.version)) + + issues: list[ModuleInstallerIssue] = [] + for item in catalog_items: + module = modules.get(item.module_id) + if module is None: + issues.append(ModuleInstallerIssue( + "blocker", + "catalog_entry_missing", + f"Catalog-sourced install plan item {item.module_id!r} is no longer present in the package catalog.", + item.module_id, + )) + continue + for required in _catalog_interface_items(module.get("requires_interfaces")): + name = required.get("name") if isinstance(required.get("name"), str) else None + if not name: + continue + version_min = required.get("version_min") if isinstance(required.get("version_min"), str) else None + version_max_exclusive = ( + required.get("version_max_exclusive") + if isinstance(required.get("version_max_exclusive"), str) + else None + ) + optional = required.get("optional") is True + providers = [ + *catalog_provider_versions.get(name, ()), + *installed_provider_versions.get(name, ()), + ] + matching = [ + (provider_module_id, version) + for provider_module_id, version in providers + if version_satisfies_range( + version, + version_min=version_min, + version_max_exclusive=version_max_exclusive, + ) + ] + if matching: + continue + version_range = format_version_range( + version_min=version_min, + version_max_exclusive=version_max_exclusive, + ) + if not providers: + if optional: + continue + issues.append(ModuleInstallerIssue( + "blocker", + "catalog_required_interface_missing", + ( + f"Catalog-sourced module {item.module_id!r} requires interface " + f"{name!r} ({version_range}), but neither the package catalog nor " + "installed modules provide it." + ), + item.module_id, + )) + continue + provider_summary = ", ".join(f"{provider_module_id}@{version}" for provider_module_id, version in providers) + severity: IssueSeverity = "warning" if optional else "blocker" + issues.append(ModuleInstallerIssue( + severity, + "catalog_interface_version_mismatch", + ( + f"Catalog-sourced module {item.module_id!r} requires interface " + f"{name!r} ({version_range}), but available providers are {provider_summary}." + ), + item.module_id, + )) + return tuple(issues) + + +def _catalog_interface_items(value: object) -> tuple[Mapping[str, object], ...]: + if not isinstance(value, list): + return () + return tuple(item for item in value if isinstance(item, Mapping)) def _post_install_checklist( diff --git a/src/govoplan_core/core/module_management.py b/src/govoplan_core/core/module_management.py index cc17f34..11ef79b 100644 --- a/src/govoplan_core/core/module_management.py +++ b/src/govoplan_core/core/module_management.py @@ -21,6 +21,7 @@ REQUIRED_PLATFORM_MODULES = ("access",) PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin") INSTALL_PLAN_ACTIONS = ("install", "uninstall") INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked") +INSTALL_PLAN_SOURCES = ("manual", "catalog") LOCAL_DEPENDENCY_REF_PREFIXES = ("file:", "path:", "workspace:", "link:") @@ -38,6 +39,8 @@ class ModuleStatePlan: class ModuleInstallPlanItem: module_id: str action: str + source: str = "manual" + catalog: Mapping[str, object] | None = None python_package: str | None = None python_ref: str | None = None webui_package: str | None = None @@ -51,10 +54,13 @@ class ModuleInstallPlanItem: payload: dict[str, object] = { "module_id": self.module_id, "action": self.action, + "source": self.source, "status": self.status, } if self.destroy_data: payload["destroy_data"] = True + if self.catalog: + payload["catalog"] = dict(self.catalog) for key in ("python_package", "python_ref", "webui_package", "webui_ref", "notes"): value = getattr(self, key) if value: @@ -291,6 +297,8 @@ def normalize_module_install_plan_item( module_id = _required_string(raw.get("module_id"), "module_id") action = _required_string(raw.get("action"), "action") + source = _clean_optional_string(raw.get("source")) or "manual" + catalog = _clean_optional_mapping(raw.get("catalog"), field="catalog", module_id=module_id) status = _clean_optional_string(raw.get("status")) or "planned" python_package = _clean_optional_string(raw.get("python_package")) python_ref = _clean_optional_string(raw.get("python_ref")) @@ -304,6 +312,8 @@ def normalize_module_install_plan_item( raise ModuleManagementError(f"Unsupported install plan action for {module_id!r}: {action!r}.") if status not in INSTALL_PLAN_STATUSES: raise ModuleManagementError(f"Unsupported install plan status for {module_id!r}: {status!r}.") + if source not in INSTALL_PLAN_SOURCES: + raise ModuleManagementError(f"Unsupported install plan source for {module_id!r}: {source!r}.") if action == "install" and not python_ref: raise ModuleManagementError(f"Install plan item {module_id!r} needs a Python package reference.") if action == "uninstall" and not python_package: @@ -322,6 +332,8 @@ def normalize_module_install_plan_item( return ModuleInstallPlanItem( module_id=module_id, action=action, + source=source, + catalog=catalog, python_package=python_package, python_ref=python_ref, webui_package=webui_package, diff --git a/src/govoplan_core/core/module_package_catalog.py b/src/govoplan_core/core/module_package_catalog.py index fdb58ac..7d2b529 100644 --- a/src/govoplan_core/core/module_package_catalog.py +++ b/src/govoplan_core/core/module_package_catalog.py @@ -2,10 +2,12 @@ from __future__ import annotations import base64 import binascii +from collections import defaultdict from datetime import UTC, datetime from pathlib import Path import json import os +import re from typing import Any import urllib.error import urllib.request @@ -14,6 +16,10 @@ from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey +from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range + +_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$") + def module_package_catalog( path: Path | str | None = None, @@ -193,6 +199,7 @@ def validate_module_package_catalog( ) warnings.extend(str(item) for item in freshness.get("warnings", ()) if item) warnings.extend(str(item) for item in replay.get("warnings", ()) if item) + warnings.extend(_catalog_interface_warnings(modules)) if not signature_state["signed"]: warnings.append("Catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.") elif not signature_state["trusted"]: @@ -303,7 +310,10 @@ def _configured_enforce_sequence() -> bool: def _configured_require_signature() -> bool: - return os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE", "").strip().lower() in {"1", "true", "yes", "on"} + value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE") + if value is not None and value.strip(): + return value.strip().lower() in {"1", "true", "yes", "on"} + return os.getenv("APP_ENV", "").strip().lower() in {"prod", "production"} def _configured_approved_channels() -> tuple[str, ...]: @@ -601,6 +611,8 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]: "webui_package": _optional_str(value, "webui_package"), "webui_ref": _optional_str(value, "webui_ref"), "license_features": _string_list(value.get("license_features")), + "provides_interfaces": _normalize_catalog_interface_providers(value.get("provides_interfaces"), module_id=module_id), + "requires_interfaces": _normalize_catalog_interface_requirements(value.get("requires_interfaces"), module_id=module_id), "notes": _optional_str(value, "notes"), "tags": _string_list(value.get("tags")), } @@ -633,6 +645,134 @@ def _string_list(value: Any) -> list[str]: return [str(item).strip() for item in value if str(item).strip()] +def _normalize_catalog_interface_providers(value: Any, *, module_id: str) -> list[dict[str, object]]: + if value is None: + return [] + if not isinstance(value, list): + raise ValueError(f"Module package catalog provides_interfaces for {module_id!r} must be a list.") + normalized: list[dict[str, object]] = [] + seen: set[str] = set() + for raw in value: + if not isinstance(raw, dict): + raise ValueError(f"Module package catalog provides_interfaces entries for {module_id!r} must be objects.") + name = _catalog_interface_required_str(raw, "name", module_id=module_id, field="provides_interfaces") + version = _catalog_interface_required_str(raw, "version", module_id=module_id, field="provides_interfaces") + _validate_catalog_interface_name(name, module_id=module_id) + if name in seen: + raise ValueError(f"Module package catalog entry {module_id!r} provides interface {name!r} more than once.") + seen.add(name) + normalized.append({"name": name, "version": version}) + return normalized + + +def _normalize_catalog_interface_requirements(value: Any, *, module_id: str) -> list[dict[str, object]]: + if value is None: + return [] + if not isinstance(value, list): + raise ValueError(f"Module package catalog requires_interfaces for {module_id!r} must be a list.") + normalized: list[dict[str, object]] = [] + seen: set[str] = set() + for raw in value: + if not isinstance(raw, dict): + raise ValueError(f"Module package catalog requires_interfaces entries for {module_id!r} must be objects.") + name = _catalog_interface_required_str(raw, "name", module_id=module_id, field="requires_interfaces") + _validate_catalog_interface_name(name, module_id=module_id) + if name in seen: + raise ValueError(f"Module package catalog entry {module_id!r} requires interface {name!r} more than once.") + seen.add(name) + version_min = _optional_str(raw, "version_min") + version_max_exclusive = _optional_str(raw, "version_max_exclusive") + if not version_range_is_valid(version_min=version_min, version_max_exclusive=version_max_exclusive): + version_range = format_version_range(version_min=version_min, version_max_exclusive=version_max_exclusive) + raise ValueError(f"Module package catalog entry {module_id!r} has invalid interface range {version_range!r}.") + item: dict[str, object] = { + "name": name, + "optional": _optional_bool(raw, "optional"), + } + if version_min is not None: + item["version_min"] = version_min + if version_max_exclusive is not None: + item["version_max_exclusive"] = version_max_exclusive + normalized.append(item) + return normalized + + +def _catalog_interface_warnings(modules: tuple[dict[str, object], ...]) -> list[str]: + providers: dict[str, list[tuple[str, str]]] = defaultdict(list) + for module in modules: + module_id = str(module.get("module_id") or "") + for raw in _catalog_interface_list(module.get("provides_interfaces")): + name = str(raw.get("name") or "") + version = str(raw.get("version") or "") + providers[name].append((module_id, version)) + + warnings: list[str] = [] + for module in modules: + module_id = str(module.get("module_id") or "") + for raw in _catalog_interface_list(module.get("requires_interfaces")): + name = str(raw.get("name") or "") + version_min = raw.get("version_min") if isinstance(raw.get("version_min"), str) else None + version_max_exclusive = raw.get("version_max_exclusive") if isinstance(raw.get("version_max_exclusive"), str) else None + optional = raw.get("optional") is True + available = providers.get(name, []) + matching = [ + (provider_module_id, version) + for provider_module_id, version in available + if version_satisfies_range( + version, + version_min=version_min, + version_max_exclusive=version_max_exclusive, + ) + ] + if matching: + continue + version_range = format_version_range( + version_min=version_min, + version_max_exclusive=version_max_exclusive, + ) + if not available: + if optional: + continue + warnings.append( + f"Catalog module {module_id!r} requires interface {name!r} ({version_range}), " + "but no catalog entry provides it; activation may still succeed if an installed module provides it." + ) + continue + provider_summary = ", ".join(f"{provider_module_id}@{version}" for provider_module_id, version in available) + warnings.append( + f"Catalog module {module_id!r} requires interface {name!r} ({version_range}), " + f"but catalog providers are {provider_summary}." + ) + return warnings + + +def _catalog_interface_list(value: object) -> list[dict[str, object]]: + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, dict)] + + +def _catalog_interface_required_str(value: dict[str, Any], key: str, *, module_id: str, field: str) -> str: + item = _optional_str(value, key) + if not item: + raise ValueError(f"Module package catalog {field} entry for {module_id!r} is missing {key!r}.") + return item + + +def _validate_catalog_interface_name(name: str, *, module_id: str) -> None: + if not _INTERFACE_NAME_RE.match(name): + raise ValueError(f"Module package catalog entry {module_id!r} has invalid interface name {name!r}.") + + +def _optional_bool(value: dict[str, Any], key: str) -> bool: + item = value.get(key) + if item is None: + return False + if not isinstance(item, bool): + raise ValueError(f"Module package catalog boolean field {key!r} must be true or false.") + return item + + def _normalize_artifact_integrity(value: Any) -> dict[str, object]: if value is None: return {} diff --git a/src/govoplan_core/core/modules.py b/src/govoplan_core/core/modules.py index 1a500e0..17ba8ea 100644 --- a/src/govoplan_core/core/modules.py +++ b/src/govoplan_core/core/modules.py @@ -108,6 +108,20 @@ class ModuleCompatibility: manifest_contract_version: str = "1" +@dataclass(frozen=True, slots=True) +class ModuleInterfaceProvider: + name: str + version: str + + +@dataclass(frozen=True, slots=True) +class ModuleInterfaceRequirement: + name: str + version_min: str | None = None + version_max_exclusive: str | None = None + optional: bool = False + + @dataclass(frozen=True, slots=True) class ModuleUninstallGuardResult: severity: Literal["blocker", "warning", "info"] @@ -217,6 +231,8 @@ class ModuleManifest: optional_dependencies: tuple[str, ...] = () required_capabilities: tuple[str, ...] = () optional_capabilities: tuple[str, ...] = () + provides_interfaces: tuple[ModuleInterfaceProvider, ...] = () + requires_interfaces: tuple[ModuleInterfaceRequirement, ...] = () permissions: tuple[PermissionDefinition, ...] = () role_templates: tuple[RoleTemplate, ...] = () route_factory: RouteFactory | None = None diff --git a/src/govoplan_core/core/organizations.py b/src/govoplan_core/core/organizations.py index 737ff8d..dcc23dc 100644 --- a/src/govoplan_core/core/organizations.py +++ b/src/govoplan_core/core/organizations.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Sequence from dataclasses import dataclass -from datetime import datetime from typing import Literal, Protocol, runtime_checkable @@ -10,7 +9,6 @@ ORGANIZATIONS_MODULE_ID = "organizations" CAPABILITY_ORGANIZATION_DIRECTORY = "organizations.directory" OrganizationStatus = Literal["active", "inactive", "suspended"] -FunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"] @dataclass(frozen=True, slots=True) @@ -39,23 +37,6 @@ class OrganizationFunctionRef: status: OrganizationStatus = "active" -@dataclass(frozen=True, slots=True) -class OrganizationFunctionAssignmentRef: - id: str - tenant_id: str - identity_id: str - function_id: str - organization_unit_id: str - account_id: str | None = None - applies_to_subunits: bool = False - source: FunctionAssignmentSource = "direct" - delegated_from_assignment_id: str | None = None - acting_for_account_id: str | None = None - valid_from: datetime | None = None - valid_until: datetime | None = None - status: OrganizationStatus = "active" - - @runtime_checkable class OrganizationDirectory(Protocol): def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None: @@ -74,22 +55,3 @@ class OrganizationDirectory(Protocol): include_subunits: bool = False, ) -> Sequence[OrganizationFunctionRef]: ... - - def get_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None: - ... - - def function_assignments_for_identity( - self, - identity_id: str, - *, - tenant_id: str | None = None, - ) -> Sequence[OrganizationFunctionAssignmentRef]: - ... - - def function_assignments_for_account( - self, - account_id: str, - *, - tenant_id: str | None = None, - ) -> Sequence[OrganizationFunctionAssignmentRef]: - ... diff --git a/src/govoplan_core/core/policy.py b/src/govoplan_core/core/policy.py index 1129a7e..a76d8d5 100644 --- a/src/govoplan_core/core/policy.py +++ b/src/govoplan_core/core/policy.py @@ -1,11 +1,13 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Iterable, Literal, Mapping, cast +from typing import Any, Iterable, Literal, Mapping, Protocol, cast, runtime_checkable from urllib.parse import quote, unquote PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"] +CAPABILITY_POLICY_PRIVACY_RETENTION = "policy.privacyRetention" + POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign") @@ -122,3 +124,39 @@ class PolicyDecision: "requirements": list(self.requirements), "details": dict(self.details), } + + +@runtime_checkable +class PrivacyRetentionService(Protocol): + def privacy_policy_from_settings(self, *args: Any, **kwargs: Any) -> Any: + ... + + def privacy_policy_from_session(self, *args: Any, **kwargs: Any) -> Any: + ... + + def set_privacy_policy(self, *args: Any, **kwargs: Any) -> Any: + ... + + def effective_privacy_policy(self, *args: Any, **kwargs: Any) -> Any: + ... + + def parent_privacy_policy(self, *args: Any, **kwargs: Any) -> Any: + ... + + def effective_privacy_policy_sources(self, *args: Any, **kwargs: Any) -> Any: + ... + + def parent_privacy_policy_sources(self, *args: Any, **kwargs: Any) -> Any: + ... + + def get_privacy_policy_for_scope(self, *args: Any, **kwargs: Any) -> Any: + ... + + def set_privacy_policy_for_scope(self, *args: Any, **kwargs: Any) -> Any: + ... + + def sanitize_audit_details_for_policy(self, *args: Any, **kwargs: Any) -> Any: + ... + + def apply_retention_policy(self, *args: Any, **kwargs: Any) -> Any: + ... diff --git a/src/govoplan_core/core/registry.py b/src/govoplan_core/core/registry.py index 01248af..f53d8db 100644 --- a/src/govoplan_core/core/registry.py +++ b/src/govoplan_core/core/registry.py @@ -8,6 +8,8 @@ from dataclasses import dataclass from govoplan_core.core.modules import ( CapabilityFactory, DeleteVetoProvider, + ModuleInterfaceProvider, + ModuleInterfaceRequirement, ModuleContext, ModuleManifest, NavItem, @@ -18,11 +20,13 @@ from govoplan_core.core.modules import ( SUPPORTED_MANIFEST_CONTRACT_VERSION, TenantSummaryProvider, ) +from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range _MODULE_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$") _NPM_PACKAGE_RE = re.compile(r"^(?:@[a-z0-9][a-z0-9_.-]*/)?[a-z0-9][a-z0-9_.-]*$") _SCOPE_RE = re.compile(r"^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:[a-z][a-z0-9_]*$") _WILDCARD_RE = re.compile(r"^([a-z][a-z0-9_]*|\*):\*$|^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:\*$") +_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$") class RegistryError(ValueError): @@ -177,6 +181,8 @@ class PlatformRegistry: raise RegistryError(f"Duplicate permission scope: {permission.scope}") seen_permissions[permission.scope] = permission + _validate_interface_closure(ordered) + known_scopes = set(seen_permissions) for manifest in ordered: for template in manifest.role_templates: @@ -236,6 +242,8 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None: _validate_dependency_list(manifest.id, "optional_dependencies", manifest.optional_dependencies) _validate_capability_list(manifest.id, "required_capabilities", manifest.required_capabilities) _validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities) + _validate_interface_providers(manifest.id, manifest.provides_interfaces) + _validate_interface_requirements(manifest.id, manifest.requires_interfaces) overlap = set(manifest.dependencies) & set(manifest.optional_dependencies) if overlap: joined = ", ".join(sorted(overlap)) @@ -275,6 +283,47 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None: _validate_nav_item(manifest.id, item) +def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None: + providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list) + for manifest in manifests: + for provided in manifest.provides_interfaces: + providers[provided.name].append((manifest, provided)) + + for manifest in manifests: + for requirement in manifest.requires_interfaces: + available = providers.get(requirement.name, []) + matching = [ + (provider_manifest, provided) + for provider_manifest, provided in available + if version_satisfies_range( + provided.version, + version_min=requirement.version_min, + version_max_exclusive=requirement.version_max_exclusive, + ) + ] + if matching: + continue + version_range = format_version_range( + version_min=requirement.version_min, + version_max_exclusive=requirement.version_max_exclusive, + ) + if not available: + if requirement.optional: + continue + raise RegistryError( + f"Module {manifest.id!r} requires unavailable interface " + f"{requirement.name!r} ({version_range})" + ) + available_versions = ", ".join( + f"{provider_manifest.id}@{provided.version}" + for provider_manifest, provided in available + ) + raise RegistryError( + f"Module {manifest.id!r} requires interface {requirement.name!r} " + f"({version_range}) but available providers are {available_versions}" + ) + + def _validate_dependency_list(module_id: str, field_name: str, dependencies: tuple[str, ...]) -> None: if len(dependencies) != len(set(dependencies)): raise RegistryError(f"Module {module_id!r} has duplicate {field_name}") @@ -293,6 +342,42 @@ def _validate_capability_list(module_id: str, field_name: str, capabilities: tup raise RegistryError(f"Module {module_id!r} has invalid capability name {capability!r}") +def _validate_interface_providers(module_id: str, providers: tuple[ModuleInterfaceProvider, ...]) -> None: + names = [provider.name for provider in providers] + if len(names) != len(set(names)): + raise RegistryError(f"Module {module_id!r} has duplicate provided interface names") + for provider in providers: + _validate_interface_name(module_id, provider.name) + if not provider.version.strip(): + raise RegistryError(f"Module {module_id!r} provides interface {provider.name!r} without a version") + + +def _validate_interface_requirements(module_id: str, requirements: tuple[ModuleInterfaceRequirement, ...]) -> None: + names = [requirement.name for requirement in requirements] + if len(names) != len(set(names)): + raise RegistryError(f"Module {module_id!r} has duplicate required interface names") + for requirement in requirements: + _validate_interface_name(module_id, requirement.name) + if requirement.version_min is not None and not requirement.version_min.strip(): + raise RegistryError(f"Module {module_id!r} has blank minimum version for interface {requirement.name!r}") + if requirement.version_max_exclusive is not None and not requirement.version_max_exclusive.strip(): + raise RegistryError(f"Module {module_id!r} has blank maximum version for interface {requirement.name!r}") + if not version_range_is_valid( + version_min=requirement.version_min, + version_max_exclusive=requirement.version_max_exclusive, + ): + version_range = format_version_range( + version_min=requirement.version_min, + version_max_exclusive=requirement.version_max_exclusive, + ) + raise RegistryError(f"Module {module_id!r} has invalid interface range {version_range!r}") + + +def _validate_interface_name(module_id: str, name: str) -> None: + if not _INTERFACE_NAME_RE.match(name): + raise RegistryError(f"Module {module_id!r} has invalid interface name {name!r}") + + def _validate_nav_item(module_id: str, item: NavItem) -> None: if not item.path.startswith("/"): raise RegistryError(f"Navigation item for module {module_id!r} must start with '/': {item.path!r}") diff --git a/src/govoplan_core/core/runtime.py b/src/govoplan_core/core/runtime.py index 0c139b4..17708d4 100644 --- a/src/govoplan_core/core/runtime.py +++ b/src/govoplan_core/core/runtime.py @@ -10,10 +10,14 @@ def configure_runtime(context: ModuleContext) -> None: _context = context +def clear_runtime() -> None: + global _context + _context = None + + def get_runtime_context() -> ModuleContext | None: return _context def get_registry() -> object | None: return _context.registry if _context is not None else None - diff --git a/src/govoplan_core/core/versioning.py b/src/govoplan_core/core/versioning.py new file mode 100644 index 0000000..9d2633a --- /dev/null +++ b/src/govoplan_core/core/versioning.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import re + + +def compare_versions(left: str, right: str) -> int: + left_parts = version_tuple(left) + right_parts = version_tuple(right) + max_length = max(len(left_parts), len(right_parts)) + padded_left = left_parts + (0,) * (max_length - len(left_parts)) + padded_right = right_parts + (0,) * (max_length - len(right_parts)) + if padded_left < padded_right: + return -1 + if padded_left > padded_right: + return 1 + return 0 + + +def version_satisfies_range( + version: str, + *, + version_min: str | None = None, + version_max_exclusive: str | None = None, +) -> bool: + if version_min is not None and compare_versions(version, version_min) < 0: + return False + if version_max_exclusive is not None and compare_versions(version, version_max_exclusive) >= 0: + return False + return True + + +def version_range_is_valid( + *, + version_min: str | None = None, + version_max_exclusive: str | None = None, +) -> bool: + if version_min is None or version_max_exclusive is None: + return True + return compare_versions(version_min, version_max_exclusive) < 0 + + +def format_version_range( + *, + version_min: str | None = None, + version_max_exclusive: str | None = None, +) -> str: + parts: list[str] = [] + if version_min is not None: + parts.append(f">= {version_min}") + if version_max_exclusive is not None: + parts.append(f"< {version_max_exclusive}") + return ", ".join(parts) if parts else "any version" + + +def version_tuple(value: str) -> tuple[int, ...]: + match = re.match(r"^\s*v?(\d+(?:\.\d+)*)", value) + if not match: + return (0,) + return tuple(int(part) for part in match.group(1).split(".")) diff --git a/src/govoplan_core/db/bootstrap.py b/src/govoplan_core/db/bootstrap.py index 98f4289..eed6e91 100644 --- a/src/govoplan_core/db/bootstrap.py +++ b/src/govoplan_core/db/bootstrap.py @@ -4,15 +4,17 @@ from dataclasses import dataclass from sqlalchemy.orm import Session +from govoplan_core.admin.common import AdminValidationError +from govoplan_core.core.access import CAPABILITY_ACCESS_TENANT_PROVISIONER, CreatedApiKeyRef, TenantAccessProvisioner, UserRef +from govoplan_core.core.modules import ModuleContext +from govoplan_core.core.runtime import configure_runtime, get_registry from govoplan_core.db.base import Base from govoplan_core.db.session import get_database from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids from govoplan_core.security.permissions import TENANT_SCOPES from govoplan_core.server.registry import available_module_manifests, build_platform_registry from govoplan_core.settings import settings -from govoplan_access.backend.admin.service import ensure_default_roles, get_or_create_account -from govoplan_access.backend.db.models import Role, SystemRoleAssignment, Tenant, User, UserRoleAssignment -from govoplan_access.backend.security.api_keys import CreatedApiKey, create_api_key +from govoplan_core.tenancy.scope import Tenant from govoplan_core.tenancy.scope import create_scope_tables DEFAULT_SCOPES = sorted(TENANT_SCOPES) @@ -21,8 +23,8 @@ DEFAULT_SCOPES = sorted(TENANT_SCOPES) @dataclass(slots=True) class BootstrapResult: tenant: Tenant - user: User - created_api_key: CreatedApiKey | None + user: UserRef + created_api_key: CreatedApiKeyRef | None def create_all_tables() -> None: @@ -42,6 +44,21 @@ def create_all_tables() -> None: Base.metadata.create_all(bind=engine) +def _access_provisioner() -> TenantAccessProvisioner: + registry = get_registry() + if registry is None: + registry = build_platform_registry(settings.enabled_modules) + context = ModuleContext(registry=registry, settings=settings) + registry.configure_capability_context(context) + configure_runtime(context) + if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_ACCESS_TENANT_PROVISIONER): + raise RuntimeError("Development bootstrap requires the access tenant provisioner capability.") + capability = registry.require_capability(CAPABILITY_ACCESS_TENANT_PROVISIONER) + if not isinstance(capability, TenantAccessProvisioner): + raise RuntimeError("Access tenant provisioner capability is invalid.") + return capability + + def bootstrap_dev_data( session: Session, *, @@ -56,72 +73,16 @@ def bootstrap_dev_data( session.add(tenant) session.flush() - tenant_roles = ensure_default_roles(session, tenant) - system_roles = ensure_default_roles(session, None) - - account, _, _ = get_or_create_account( - session, - email=user_email, - display_name="Development Admin", - password=user_password, - password_reset_required=False, - ) - # Keep development credentials deterministic when an old create_all database - # is reused. This is guarded by the explicit dev bootstrap setting. - from govoplan_access.backend.security.passwords import hash_password - - if not account.password_hash: - account.password_hash = hash_password(user_password) - account.is_active = True - session.add(account) - - user = session.query(User).filter(User.tenant_id == tenant.id, User.account_id == account.id).one_or_none() - if user is None: - user = User( - tenant_id=tenant.id, - account_id=account.id, - email=account.email, - display_name="Development Admin", - is_tenant_admin=True, # legacy presentation flag; RBAC uses the owner role below. - auth_provider=account.auth_provider, - password_hash=account.password_hash, + try: + result = _access_provisioner().ensure_development_admin( + session, + tenant=tenant, + user_email=user_email, + user_password=user_password, + api_key_secret=api_key_secret, + scopes=DEFAULT_SCOPES, ) - session.add(user) - session.flush() - else: - user.email = account.email - user.password_hash = account.password_hash - user.is_active = True - session.add(user) - - owner_role = tenant_roles["owner"] - existing_assignment = session.query(UserRoleAssignment).filter( - UserRoleAssignment.tenant_id == tenant.id, - UserRoleAssignment.user_id == user.id, - UserRoleAssignment.role_id == owner_role.id, - ).one_or_none() - if existing_assignment is None: - session.add(UserRoleAssignment(tenant_id=tenant.id, user_id=user.id, role_id=owner_role.id)) - - system_owner = system_roles["system_owner"] - existing_system_assignment = session.query(SystemRoleAssignment).filter( - SystemRoleAssignment.account_id == account.id, - SystemRoleAssignment.role_id == system_owner.id, - ).one_or_none() - if existing_system_assignment is None: - session.add(SystemRoleAssignment(account_id=account.id, role_id=system_owner.id)) - - created_api_key = None - if api_key_secret: - existing = [key for key in user.api_keys if key.name == "Development API key" and key.revoked_at is None] - if not existing: - created_api_key = create_api_key( - session, - user=user, - name="Development API key", - scopes=DEFAULT_SCOPES, - secret=api_key_secret, - ) - + except AdminValidationError as exc: + raise RuntimeError(str(exc)) from exc session.commit() - return BootstrapResult(tenant=tenant, user=user, created_api_key=created_api_key) + return BootstrapResult(tenant=tenant, user=result.user, created_api_key=result.created_api_key) diff --git a/src/govoplan_core/privacy/retention.py b/src/govoplan_core/privacy/retention.py index 6f38f99..efcd62b 100644 --- a/src/govoplan_core/privacy/retention.py +++ b/src/govoplan_core/privacy/retention.py @@ -1,29 +1,18 @@ from __future__ import annotations -import hashlib -import json -from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import Any, Literal +"""Compatibility facade for privacy retention policy. -from pydantic import BaseModel, ConfigDict, Field, field_validator -from sqlalchemy.orm import Session +Policy-owned behavior is provided by ``govoplan-policy`` through the +``policy.privacyRetention`` capability. This module keeps legacy imports stable +without importing policy implementation code from core. +""" -from govoplan_core.admin.settings import get_system_settings -from govoplan_core.core.campaigns import ( - CAPABILITY_CAMPAIGNS_POLICY_CONTEXT, - CAPABILITY_CAMPAIGNS_RETENTION, - CampaignPolicyContext, - CampaignPolicyContextProvider, - CampaignRetentionProvider, -) -from govoplan_core.core.policy import policy_source_step +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_access.backend.db.models import Group, User -from govoplan_audit.backend.db.models import AuditLog -from govoplan_core.admin.models import SystemSettings -from govoplan_core.settings import settings -from govoplan_core.tenancy.scope import Tenant + PRIVACY_POLICY_SETTINGS_KEY = "privacy_retention_policy" RETENTION_DAY_KEYS = ( @@ -38,17 +27,109 @@ RETENTION_POLICY_FIELD_KEYS = ( *RETENTION_DAY_KEYS, "audit_detail_level", ) -AUDIT_DETAIL_LEVEL_ORDER = {"full": 0, "redacted": 1, "minimal": 2} + +_DEFAULT_POLICY = { + "store_raw_campaign_json": True, + "raw_campaign_json_retention_days": None, + "generated_eml_retention_days": None, + "stored_report_detail_retention_days": None, + "mock_mailbox_retention_days": None, + "audit_detail_retention_days": None, + "audit_detail_level": "full", +} + +_EXPORTS = ( + "PRIVACY_POLICY_SETTINGS_KEY", + "RETENTION_DAY_KEYS", + "RETENTION_POLICY_FIELD_KEYS", + "PrivacyRetentionPolicy", + "PrivacyRetentionPolicyPatch", + "PrivacyPolicyError", + "default_allow_lower_level_limits", + "privacy_policy_from_settings", + "privacy_policy_from_session", + "set_privacy_policy", + "effective_privacy_policy", + "parent_privacy_policy", + "effective_privacy_policy_sources", + "parent_privacy_policy_sources", + "get_privacy_policy_for_scope", + "set_privacy_policy_for_scope", + "sanitize_audit_details_for_policy", + "apply_retention_policy", +) -def default_allow_lower_level_limits() -> dict[str, bool]: - return {key: True for key in RETENTION_POLICY_FIELD_KEYS} +class PrivacyPolicyError(RuntimeError): + pass + + +class _UnavailablePolicyModel: + def __init__(self, *args: Any, **kwargs: Any) -> None: + del args, kwargs + _raise_unavailable() + + @classmethod + def model_validate(cls, value: Any) -> Any: + del value + _raise_unavailable() + + +PrivacyRetentionPolicy = _UnavailablePolicyModel +PrivacyRetentionPolicyPatch = _UnavailablePolicyModel + + +@dataclass(frozen=True, slots=True) +class _SystemPrivacyPolicy: + payload: Mapping[str, Any] + + def model_dump(self, *args: Any, **kwargs: Any) -> dict[str, Any]: + del args, kwargs + return dict(self.payload) + + +def _raise_unavailable() -> None: + raise PrivacyPolicyError("Privacy retention policy requires the active govoplan-policy module.") + + +def _runtime_service() -> PrivacyRetentionService | None: + registry = get_registry() + if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_POLICY_PRIVACY_RETENTION): + return None + capability = registry.require_capability(CAPABILITY_POLICY_PRIVACY_RETENTION) + if not isinstance(capability, PrivacyRetentionService): + raise PrivacyPolicyError("Privacy retention policy capability is invalid") + return capability + + +def _service() -> PrivacyRetentionService: + service = _runtime_service() + if service is None: + _raise_unavailable() + return service + + +def _settings_payload(item: object) -> dict[str, Any]: + settings = getattr(item, "settings", None) + return dict(settings or {}) if isinstance(settings, Mapping) else {} + + +def _policy_data(settings_payload: Mapping[str, Any] | None) -> dict[str, Any]: + raw = settings_payload.get(PRIVACY_POLICY_SETTINGS_KEY) if isinstance(settings_payload, Mapping) else None + if not isinstance(raw, Mapping): + raw = {} + payload = dict(_DEFAULT_POLICY) + 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) + 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, dict): + 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(): @@ -58,612 +139,67 @@ def _normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> d normalized[clean_key] = bool(allowed) return normalized -AUDIT_DETAIL_REDACTION_KEYS = { - "attachments", - "bcc", - "body", - "campaign_json", - "cc", - "content", - "entries", - "html", - "imap", - "message", - "password", - "raw_eml", - "raw_json", - "recipients", - "secret", - "smtp", - "template", - "text", - "token", - "to", -} +def default_allow_lower_level_limits() -> dict[str, bool]: + return {key: True for key in RETENTION_POLICY_FIELD_KEYS} -class PrivacyRetentionPolicy(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) +def privacy_policy_from_settings(item: object) -> Any: + service = _runtime_service() + if service is not None: + return service.privacy_policy_from_settings(item) + return _SystemPrivacyPolicy(_policy_data(_settings_payload(item))) - @field_validator( - "raw_campaign_json_retention_days", - "generated_eml_retention_days", - "stored_report_detail_retention_days", - "mock_mailbox_retention_days", - "audit_detail_retention_days", - mode="before", - ) - @classmethod - def _blank_string_is_none(cls, value: Any) -> Any: - if value == "": - return None - return value - @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) +def privacy_policy_from_session(*args: Any, **kwargs: Any) -> Any: + return _service().privacy_policy_from_session(*args, **kwargs) -class PrivacyRetentionPolicyPatch(BaseModel): - model_config = ConfigDict(extra="forbid") +def set_privacy_policy(item: object, policy: Any) -> Any: + service = _runtime_service() + if service is not None: + return service.set_privacy_policy(item, policy) + value = policy.model_dump(mode="json") if hasattr(policy, "model_dump") else dict(policy or {}) + next_settings = _settings_payload(item) + next_settings[PRIVACY_POLICY_SETTINGS_KEY] = _policy_data({PRIVACY_POLICY_SETTINGS_KEY: value}) + setattr(item, "settings", next_settings) + return _SystemPrivacyPolicy(next_settings[PRIVACY_POLICY_SETTINGS_KEY]) - 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(*RETENTION_DAY_KEYS, mode="before") - @classmethod - def _blank_string_is_none(cls, value: Any) -> Any: - if value == "": - return None - return value +def effective_privacy_policy(*args: Any, **kwargs: Any) -> Any: + return _service().effective_privacy_policy(*args, **kwargs) - @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) +def parent_privacy_policy(*args: Any, **kwargs: Any) -> Any: + return _service().parent_privacy_policy(*args, **kwargs) -class PrivacyPolicyError(RuntimeError): - pass +def effective_privacy_policy_sources(*args: Any, **kwargs: Any) -> Any: + return _service().effective_privacy_policy_sources(*args, **kwargs) -def _campaign_policy_provider() -> CampaignPolicyContextProvider | None: - registry = get_registry() - if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_CAMPAIGNS_POLICY_CONTEXT): - return None - capability = registry.require_capability(CAPABILITY_CAMPAIGNS_POLICY_CONTEXT) - if not isinstance(capability, CampaignPolicyContextProvider): - raise PrivacyPolicyError("Campaign policy context capability is invalid") - return capability +def parent_privacy_policy_sources(*args: Any, **kwargs: Any) -> Any: + return _service().parent_privacy_policy_sources(*args, **kwargs) -def _campaign_retention_provider() -> CampaignRetentionProvider | None: - registry = get_registry() - if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_CAMPAIGNS_RETENTION): - return None - capability = registry.require_capability(CAPABILITY_CAMPAIGNS_RETENTION) - if not isinstance(capability, CampaignRetentionProvider): - raise PrivacyPolicyError("Campaign retention capability is invalid") - return capability +def get_privacy_policy_for_scope(*args: Any, **kwargs: Any) -> Any: + return _service().get_privacy_policy_for_scope(*args, **kwargs) -def _campaign_policy_context(session: Session, *, campaign_id: str, tenant_id: str | None = None) -> CampaignPolicyContext: - provider = _campaign_policy_provider() - if provider is None: - raise PrivacyPolicyError("Campaign module is not installed") - context = provider.get_campaign_policy_context(session, tenant_id=tenant_id, campaign_id=campaign_id) - if context is None: - raise PrivacyPolicyError("Campaign not found for privacy policy") - return context +def set_privacy_policy_for_scope(*args: Any, **kwargs: Any) -> Any: + return _service().set_privacy_policy_for_scope(*args, **kwargs) -def _utcnow() -> datetime: - return datetime.now(timezone.utc) +def sanitize_audit_details_for_policy(*args: Any, **kwargs: Any) -> dict[str, Any]: + service = _runtime_service() + if service is not None: + return dict(service.sanitize_audit_details_for_policy(*args, **kwargs)) + details = args[1] if len(args) > 1 else kwargs.get("details") + return dict(details or {}) -def _cutoff(days: int | None, *, now: datetime) -> datetime | None: - if days is None: - return None - return now - timedelta(days=days) +def apply_retention_policy(*args: Any, **kwargs: Any) -> Any: + return _service().apply_retention_policy(*args, **kwargs) -def _json_sha256(value: Any) -> str: - payload = json.dumps(value, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8") - return hashlib.sha256(payload).hexdigest() - -def _privacy_policy_data(settings_payload: dict[str, Any] | None) -> dict[str, Any]: - if not isinstance(settings_payload, dict): - return {} - data = settings_payload.get(PRIVACY_POLICY_SETTINGS_KEY, {}) - return data if isinstance(data, dict) else {} - - -def _privacy_policy_patch_from_settings(settings_payload: dict[str, Any] | None) -> dict[str, Any]: - data = _privacy_policy_data(settings_payload) - if not data: - return {} - return PrivacyRetentionPolicyPatch.model_validate(data).model_dump(mode="json", exclude_none=True) - - -def _merge_privacy_policy(parent: PrivacyRetentionPolicy, patch: dict[str, Any]) -> PrivacyRetentionPolicy: - payload = parent.model_dump(mode="json") - parent_allow = {**default_allow_lower_level_limits(), **(payload.get("allow_lower_level_limits") or {})} - if parent_allow["store_raw_campaign_json"] and patch.get("store_raw_campaign_json") is False: - payload["store_raw_campaign_json"] = False - for key in RETENTION_DAY_KEYS: - value = patch.get(key) - if value is None or not parent_allow[key]: - continue - current = payload.get(key) - payload[key] = int(value) if current is None else min(int(current), int(value)) - detail_level = patch.get("audit_detail_level") - if parent_allow["audit_detail_level"] and detail_level and AUDIT_DETAIL_LEVEL_ORDER[detail_level] > AUDIT_DETAIL_LEVEL_ORDER[payload["audit_detail_level"]]: - payload["audit_detail_level"] = detail_level - - patch_allow = patch.get("allow_lower_level_limits") or {} - payload["allow_lower_level_limits"] = { - key: parent_allow[key] and bool(patch_allow.get(key, parent_allow[key])) - for key in RETENTION_POLICY_FIELD_KEYS - } - return PrivacyRetentionPolicy.model_validate(payload) - - -def _validate_privacy_patch_against_parent(parent: PrivacyRetentionPolicy, patch: dict[str, Any]) -> None: - parent_payload = parent.model_dump(mode="json") - parent_allow = {**default_allow_lower_level_limits(), **(parent_payload.get("allow_lower_level_limits") or {})} - for key in RETENTION_POLICY_FIELD_KEYS: - if key in patch and patch.get(key) is not None and not parent_allow[key]: - raise PrivacyPolicyError(f"{key} is locked by the parent retention policy.") - patch_allow = patch.get("allow_lower_level_limits") or {} - for key, allowed in patch_allow.items(): - if allowed and not parent_allow[key]: - raise PrivacyPolicyError(f"{key} limiting cannot be re-enabled below a parent retention policy lock.") - if patch.get("store_raw_campaign_json") is True and not parent.store_raw_campaign_json: - raise PrivacyPolicyError("Raw campaign JSON storage cannot be re-enabled below a parent policy that disables it.") - for key in RETENTION_DAY_KEYS: - value = patch.get(key) - parent_value = parent_payload.get(key) - if value is not None and parent_value is not None and int(value) > int(parent_value): - raise PrivacyPolicyError(f"{key} cannot be less restrictive than the parent retention policy.") - detail_level = patch.get("audit_detail_level") - if detail_level and AUDIT_DETAIL_LEVEL_ORDER[detail_level] < AUDIT_DETAIL_LEVEL_ORDER[parent.audit_detail_level]: - raise PrivacyPolicyError("Audit detail level cannot be less restrictive than the parent retention policy.") - - -def _set_settings_privacy_policy(settings_payload: dict[str, Any] | None, policy: dict[str, Any]) -> dict[str, Any]: - payload = dict(settings_payload or {}) - payload[PRIVACY_POLICY_SETTINGS_KEY] = policy - return payload - - -def privacy_policy_from_settings(item: SystemSettings) -> PrivacyRetentionPolicy: - return PrivacyRetentionPolicy.model_validate(_privacy_policy_data(item.settings or {})) - - -def privacy_policy_from_session(session: Session) -> PrivacyRetentionPolicy: - return privacy_policy_from_settings(get_system_settings(session)) - - -def set_privacy_policy(item: SystemSettings, policy: PrivacyRetentionPolicy | dict[str, Any]) -> PrivacyRetentionPolicy: - validated = policy if isinstance(policy, PrivacyRetentionPolicy) else PrivacyRetentionPolicy.model_validate(policy) - item.settings = _set_settings_privacy_policy(item.settings, validated.model_dump(mode="json")) - return validated - - -def effective_privacy_policy( - session: Session, - *, - tenant_id: str | None = None, - owner_user_id: str | None = None, - owner_group_id: str | None = None, - campaign_id: str | None = None, -) -> PrivacyRetentionPolicy: - policy = privacy_policy_from_session(session) - campaign: CampaignPolicyContext | None = None - if campaign_id: - campaign = _campaign_policy_context(session, campaign_id=campaign_id) - tenant_id = campaign.tenant_id - owner_user_id = campaign.owner_user_id - owner_group_id = campaign.owner_group_id - if tenant_id: - tenant = session.get(Tenant, tenant_id) - if tenant is None: - raise PrivacyPolicyError("Tenant not found for privacy policy") - policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(tenant.settings or {})) - if owner_user_id: - user = session.get(User, owner_user_id) - if user and (not tenant_id or user.tenant_id == tenant_id): - policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(user.settings or {})) - if owner_group_id: - group = session.get(Group, owner_group_id) - if group and (not tenant_id or group.tenant_id == tenant_id): - policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(group.settings or {})) - if campaign is not None: - policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(dict(campaign.settings or {}))) - return policy - - -def parent_privacy_policy(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None = None) -> PrivacyRetentionPolicy: - clean_scope = scope_type.strip().casefold() - policy = privacy_policy_from_session(session) - if clean_scope == "tenant": - return policy - tenant = session.get(Tenant, tenant_id) - if tenant is None: - raise PrivacyPolicyError("Tenant not found for privacy policy") - policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(tenant.settings or {})) - if clean_scope in {"user", "group"}: - return policy - if clean_scope != "campaign" or not scope_id: - return policy - campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id) - if campaign.owner_user_id: - user = session.get(User, campaign.owner_user_id) - if user and user.tenant_id == tenant_id: - policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(user.settings or {})) - if campaign.owner_group_id: - group = session.get(Group, campaign.owner_group_id) - if group and group.tenant_id == tenant_id: - policy = _merge_privacy_policy(policy, _privacy_policy_patch_from_settings(group.settings or {})) - return policy - - - -def _retention_policy_source_fields(patch: dict[str, Any], *, baseline: bool = False) -> list[str]: - fields: list[str] = [] - for key in RETENTION_POLICY_FIELD_KEYS: - if key in patch and patch.get(key) is not None: - fields.append(key) - if isinstance(patch.get("allow_lower_level_limits"), dict) and patch["allow_lower_level_limits"]: - fields.append("allow_lower_level_limits") - if baseline and not fields: - fields.append("defaults") - return fields - - -def _retention_policy_source_step(scope_type: str, label: str, scope_id: str | None, patch: dict[str, Any], *, baseline: bool = False) -> dict[str, Any]: - source_policy = PrivacyRetentionPolicy.model_validate(patch).model_dump(mode="json") if baseline else dict(patch) - return policy_source_step( - scope_type, - label, - scope_id, - applied_fields=_retention_policy_source_fields(patch, baseline=baseline), - policy=source_policy, - ) - - -def effective_privacy_policy_sources( - session: Session, - *, - tenant_id: str | None = None, - owner_user_id: str | None = None, - owner_group_id: str | None = None, - campaign_id: str | None = None, -) -> list[dict[str, Any]]: - system_settings = get_system_settings(session) - sources = [_retention_policy_source_step("system", "System", None, _privacy_policy_patch_from_settings(system_settings.settings or {}), baseline=True)] - campaign: CampaignPolicyContext | None = None - if campaign_id: - campaign = _campaign_policy_context(session, campaign_id=campaign_id) - tenant_id = campaign.tenant_id - owner_user_id = campaign.owner_user_id - owner_group_id = campaign.owner_group_id - if tenant_id: - tenant = session.get(Tenant, tenant_id) - if tenant is None: - raise PrivacyPolicyError("Tenant not found for privacy policy") - sources.append(_retention_policy_source_step("tenant", "Tenant", tenant.id, _privacy_policy_patch_from_settings(tenant.settings or {}))) - if owner_user_id: - user = session.get(User, owner_user_id) - if user and (not tenant_id or user.tenant_id == tenant_id): - sources.append(_retention_policy_source_step("user", "Owner user", user.id, _privacy_policy_patch_from_settings(user.settings or {}))) - if owner_group_id: - group = session.get(Group, owner_group_id) - if group and (not tenant_id or group.tenant_id == tenant_id): - sources.append(_retention_policy_source_step("group", "Owner group", group.id, _privacy_policy_patch_from_settings(group.settings or {}))) - if campaign is not None: - sources.append(_retention_policy_source_step("campaign", "Campaign", campaign.id, _privacy_policy_patch_from_settings(dict(campaign.settings or {})))) - return sources - - -def parent_privacy_policy_sources(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None = None) -> list[dict[str, Any]]: - clean_scope = scope_type.strip().casefold() - if clean_scope == "system": - return [] - system_settings = get_system_settings(session) - sources = [_retention_policy_source_step("system", "System", None, _privacy_policy_patch_from_settings(system_settings.settings or {}), baseline=True)] - if clean_scope == "tenant": - return sources - tenant = session.get(Tenant, tenant_id) - if tenant is None: - raise PrivacyPolicyError("Tenant not found for privacy policy") - sources.append(_retention_policy_source_step("tenant", "Tenant", tenant.id, _privacy_policy_patch_from_settings(tenant.settings or {}))) - if clean_scope in {"user", "group"}: - return sources - if clean_scope != "campaign" or not scope_id: - return sources - campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id) - if campaign.owner_user_id: - user = session.get(User, campaign.owner_user_id) - if user and user.tenant_id == tenant_id: - sources.append(_retention_policy_source_step("user", "Owner user", user.id, _privacy_policy_patch_from_settings(user.settings or {}))) - if campaign.owner_group_id: - group = session.get(Group, campaign.owner_group_id) - if group and group.tenant_id == tenant_id: - sources.append(_retention_policy_source_step("group", "Owner group", group.id, _privacy_policy_patch_from_settings(group.settings or {}))) - return sources - -def get_privacy_policy_for_scope(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None = None) -> dict[str, Any]: - clean_scope = scope_type.strip().casefold() - if clean_scope == "system": - return privacy_policy_from_session(session).model_dump(mode="json") - if clean_scope == "tenant": - tenant = session.get(Tenant, scope_id or tenant_id) - if tenant is None or tenant.id != tenant_id: - raise PrivacyPolicyError("Tenant privacy policy not found") - return _privacy_policy_patch_from_settings(tenant.settings or {}) - if not scope_id: - raise PrivacyPolicyError(f"{clean_scope.capitalize()} privacy policy requires scope_id") - if clean_scope == "user": - user = session.get(User, scope_id) - if user is None or user.tenant_id != tenant_id: - raise PrivacyPolicyError("User privacy policy not found") - return _privacy_policy_patch_from_settings(user.settings or {}) - if clean_scope == "group": - group = session.get(Group, scope_id) - if group is None or group.tenant_id != tenant_id: - raise PrivacyPolicyError("Group privacy policy not found") - return _privacy_policy_patch_from_settings(group.settings or {}) - if clean_scope == "campaign": - campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id) - return _privacy_policy_patch_from_settings(dict(campaign.settings or {})) - raise PrivacyPolicyError("Privacy policy scope must be system, tenant, user, group or campaign") - - -def set_privacy_policy_for_scope( - session: Session, - *, - tenant_id: str, - scope_type: str, - scope_id: str | None = None, - policy: dict[str, Any] | None = None, -) -> dict[str, Any]: - clean_scope = scope_type.strip().casefold() - if clean_scope == "system": - item = get_system_settings(session) - validated = set_privacy_policy(item, PrivacyRetentionPolicy.model_validate(policy or {})) - session.add(item) - return validated.model_dump(mode="json") - patch = PrivacyRetentionPolicyPatch.model_validate(policy or {}).model_dump(mode="json", exclude_none=True) - _validate_privacy_patch_against_parent(parent_privacy_policy(session, tenant_id=tenant_id, scope_type=clean_scope, scope_id=scope_id or (tenant_id if clean_scope == "tenant" else None)), patch) - if clean_scope == "tenant": - tenant = session.get(Tenant, scope_id or tenant_id) - if tenant is None or tenant.id != tenant_id: - raise PrivacyPolicyError("Tenant privacy policy not found") - tenant.settings = _set_settings_privacy_policy(tenant.settings, patch) - session.add(tenant) - return patch - if not scope_id: - raise PrivacyPolicyError(f"{clean_scope.capitalize()} privacy policy requires scope_id") - if clean_scope == "user": - user = session.get(User, scope_id) - if user is None or user.tenant_id != tenant_id: - raise PrivacyPolicyError("User privacy policy not found") - user.settings = _set_settings_privacy_policy(user.settings, patch) - session.add(user) - return patch - if clean_scope == "group": - group = session.get(Group, scope_id) - if group is None or group.tenant_id != tenant_id: - raise PrivacyPolicyError("Group privacy policy not found") - group.settings = _set_settings_privacy_policy(group.settings, patch) - session.add(group) - return patch - if clean_scope == "campaign": - provider = _campaign_policy_provider() - if provider is None: - raise PrivacyPolicyError("Campaign module is not installed") - campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id) - settings_payload = _set_settings_privacy_policy(dict(campaign.settings or {}), patch) - try: - provider.set_campaign_settings(session, tenant_id=tenant_id, campaign_id=scope_id, settings=settings_payload) - except ValueError as exc: - raise PrivacyPolicyError("Campaign privacy policy not found") from exc - return patch - raise PrivacyPolicyError("Privacy policy scope must be system, tenant, user, group or campaign") - - -def sanitize_audit_details_for_policy(session: Session, details: dict[str, Any]) -> dict[str, Any]: - policy = privacy_policy_from_session(session) - if policy.audit_detail_level == "full": - return details - if policy.audit_detail_level == "minimal": - return { - "_privacy": {"detail_level": "minimal"}, - "keys": sorted(str(key) for key in details.keys()), - } - return _redact_audit_value(details) - - -def _redact_audit_value(value: Any) -> Any: - if isinstance(value, dict): - redacted: dict[str, Any] = {} - for key, item in value.items(): - if str(key).lower() in AUDIT_DETAIL_REDACTION_KEYS: - redacted[key] = "" - else: - redacted[key] = _redact_audit_value(item) - return redacted - if isinstance(value, list): - return [_redact_audit_value(item) for item in value] - return value - - -def _is_before_cutoff(value: datetime | None, cutoff: datetime | None) -> bool: - if value is None or cutoff is None: - return False - candidate = value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value - return candidate < cutoff - - -def _system_cutoffs(policy: PrivacyRetentionPolicy, *, now: datetime) -> dict[str, datetime | None]: - return { - "raw_campaign_json": _cutoff(0 if not policy.store_raw_campaign_json else policy.raw_campaign_json_retention_days, now=now), - "generated_eml": _cutoff(policy.generated_eml_retention_days, now=now), - "stored_report_detail": _cutoff(policy.stored_report_detail_retention_days, now=now), - "mock_mailbox": _cutoff(policy.mock_mailbox_retention_days, now=now), - "audit_detail": _cutoff(policy.audit_detail_retention_days, now=now), - } - - -def apply_retention_policy(session: Session, *, dry_run: bool = True) -> dict[str, Any]: - now = _utcnow() - policy = privacy_policy_from_session(session) - cutoffs = _system_cutoffs(policy, now=now) - policy_cache: dict[str, PrivacyRetentionPolicy] = {} - campaign_retention = _campaign_retention_provider() - campaign_counts = { - "raw_campaign_json": {"eligible": 0, "redacted": 0, "skipped_not_final": 0, "already_redacted": 0}, - "generated_eml": {"eligible": 0, "metadata_cleared": 0, "files_deleted": 0, "files_missing": 0, "skipped_not_final": 0}, - "stored_report_detail": {"eligible_versions": 0, "summaries_redacted": 0, "already_redacted": 0}, - } - if campaign_retention is not None: - campaign_counts.update( - { - key: dict(value) - for key, value in campaign_retention.apply_retention( - session, - dry_run=dry_run, - now=now, - policy_for_campaign_id=lambda campaign_id: _campaign_policy_for_id(session, campaign_id, policy_cache), - ).items() - } - ) - counts = { - "raw_campaign_json": campaign_counts["raw_campaign_json"], - "generated_eml": campaign_counts["generated_eml"], - "stored_report_detail": campaign_counts["stored_report_detail"], - "mock_mailbox": _apply_mock_mailbox_retention(cutoffs["mock_mailbox"], dry_run=dry_run), - "audit_detail": _apply_audit_detail_retention(session, dry_run=dry_run, now=now), - } - return { - "dry_run": dry_run, - "policy": policy.model_dump(mode="json"), - "cutoffs": {key: value.isoformat() if value else None for key, value in cutoffs.items()}, - "effective_policy_scope": "per-object", - "counts": counts, - } - - -def _campaign_policy_for_id(session: Session, campaign_id: str | None, cache: dict[str, PrivacyRetentionPolicy]) -> PrivacyRetentionPolicy: - if not campaign_id: - return privacy_policy_from_session(session) - if campaign_id not in cache: - cache[campaign_id] = effective_privacy_policy(session, campaign_id=campaign_id) - return cache[campaign_id] - - -def _apply_mock_mailbox_retention(cutoff: datetime | None, *, dry_run: bool) -> dict[str, int]: - result = {"eligible_records": 0, "json_deleted": 0, "eml_deleted": 0} - if cutoff is None: - return result - message_dir = Path(settings.mock_mailbox_dir) / "messages" - if not message_dir.exists(): - return result - for path in message_dir.glob("*.json"): - try: - record = json.loads(path.read_text(encoding="utf-8")) - except json.JSONDecodeError: - continue - created_at = _parse_datetime(record.get("created_at")) - if created_at and created_at >= cutoff: - continue - result["eligible_records"] += 1 - if dry_run: - continue - raw_filename = record.get("raw_filename") - if raw_filename: - raw_path = message_dir / str(raw_filename) - if raw_path.exists(): - raw_path.unlink() - result["eml_deleted"] += 1 - if path.exists(): - path.unlink() - result["json_deleted"] += 1 - return result - - -def _parse_datetime(value: Any) -> datetime | None: - if not value: - return None - try: - parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) - except ValueError: - return None - if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=timezone.utc) - return parsed - - -def _privacy_policy_for_audit_item(session: Session, item: AuditLog, campaign_cache: dict[str, PrivacyRetentionPolicy], tenant_cache: dict[str, PrivacyRetentionPolicy]) -> PrivacyRetentionPolicy: - if item.object_type == "campaign" and item.object_id: - provider = _campaign_policy_provider() - if provider is not None: - context = provider.get_campaign_policy_context(session, campaign_id=str(item.object_id)) - if context is not None: - return _campaign_policy_for_id(session, context.id, campaign_cache) - if item.tenant_id: - if item.tenant_id not in tenant_cache: - tenant_cache[item.tenant_id] = effective_privacy_policy(session, tenant_id=item.tenant_id) - return tenant_cache[item.tenant_id] - return privacy_policy_from_session(session) - - -def _apply_audit_detail_retention(session: Session, *, dry_run: bool, now: datetime) -> dict[str, int]: - result = {"eligible": 0, "redacted": 0, "already_redacted": 0} - campaign_cache: dict[str, PrivacyRetentionPolicy] = {} - tenant_cache: dict[str, PrivacyRetentionPolicy] = {} - items = ( - session.query(AuditLog) - .order_by(AuditLog.created_at.asc()) - .all() - ) - for item in items: - policy = _privacy_policy_for_audit_item(session, item, campaign_cache, tenant_cache) - cutoff = _cutoff(policy.audit_detail_retention_days, now=now) - if not _is_before_cutoff(item.created_at, cutoff): - continue - details = item.details if isinstance(item.details, dict) else {} - if details.get("_retention", {}).get("audit_detail_redacted"): - result["already_redacted"] += 1 - continue - result["eligible"] += 1 - if dry_run: - continue - item.details = { - "_retention": { - "audit_detail_redacted": True, - "redacted_at": now.isoformat(), - "original_detail_sha256": _json_sha256(details), - } - } - session.add(item) - result["redacted"] += 1 - return result +__all__ = list(_EXPORTS) diff --git a/src/govoplan_core/tenancy/service.py b/src/govoplan_core/tenancy/service.py index 3229d32..5655c96 100644 --- a/src/govoplan_core/tenancy/service.py +++ b/src/govoplan_core/tenancy/service.py @@ -6,8 +6,8 @@ from sqlalchemy.orm import Session from govoplan_core.admin.common import AdminValidationError from govoplan_core.admin.settings import get_system_settings +from govoplan_core.core.access import CAPABILITY_ACCESS_ADMINISTRATION, AccessAdministration from govoplan_core.core.runtime import get_registry -from govoplan_access.backend.db.models import ApiKey, Group, User from govoplan_core.tenancy.scope import Tenant @@ -57,14 +57,27 @@ def _tenant_module_counts(session: Session, tenant_id: str) -> dict[str, int]: return counts +def _access_administration() -> AccessAdministration | None: + registry = get_registry() + if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_ACCESS_ADMINISTRATION): + return None + capability = registry.require_capability(CAPABILITY_ACCESS_ADMINISTRATION) + if not isinstance(capability, AccessAdministration): + raise TypeError("Access administration capability is invalid") + return capability + + def tenant_counts(session: Session, tenant_id: str) -> dict[str, int]: module_counts = _tenant_module_counts(session, tenant_id) + access_administration = _access_administration() + access_counts = access_administration.tenant_counts(session, tenant_id) if access_administration is not None else {} return { - "users": session.query(User).filter(User.tenant_id == tenant_id).count(), - "active_users": session.query(User).filter(User.tenant_id == tenant_id, User.is_active.is_(True)).count(), - "groups": session.query(Group).filter(Group.tenant_id == tenant_id).count(), + "users": int(access_counts.get("users", 0)), + "active_users": int(access_counts.get("active_users", 0)), + "groups": int(access_counts.get("groups", 0)), "campaigns": module_counts.get("campaigns", 0), "files": module_counts.get("files", 0), - "api_keys": session.query(ApiKey).filter(ApiKey.tenant_id == tenant_id, ApiKey.revoked_at.is_(None)).count(), + "api_keys": int(access_counts.get("api_keys", 0)), + "active_api_keys": int(access_counts.get("active_api_keys", access_counts.get("api_keys", 0))), } diff --git a/tests/test_access_contracts.py b/tests/test_access_contracts.py index 22a6674..f35d861 100644 --- a/tests/test_access_contracts.py +++ b/tests/test_access_contracts.py @@ -22,6 +22,8 @@ from govoplan_core.core.access import ( CAPABILITY_ACCESS_EXPLANATION, CAPABILITY_ACCESS_SEMANTIC_DIRECTORY, CAPABILITY_ACCESS_TENANT_PROVISIONER, + CAPABILITY_AUDIT_RECORDER, + CAPABILITY_AUDIT_RETENTION, CAPABILITY_AUDIT_SINK, CAPABILITY_TENANCY_TENANT_RESOLVER, AccessDecision, @@ -34,7 +36,12 @@ from govoplan_core.core.access import ( AccessSubjectRef, AccountRef, AuditEvent, + AuditRecordRef, + AuditRecorder, + AuditRetentionProvider, AuditSink, + CreatedApiKeyRef, + DevelopmentBootstrapRef, FunctionAssignmentRef, FunctionDelegationRef, FunctionRef, @@ -69,7 +76,9 @@ from govoplan_core.core.campaigns import ( CampaignRetentionProvider, ) from govoplan_core.core.modules import ModuleContext, ModuleManifest +from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory as CoreOrganizationDirectory, OrganizationFunctionRef as CoreOrganizationFunctionRef from govoplan_core.core.registry import PlatformRegistry +from govoplan_core.core.runtime import clear_runtime, configure_runtime from govoplan_core.db.base import Base from govoplan_core.server.app import create_app from govoplan_core.server.config import GovoplanServerConfig @@ -265,8 +274,19 @@ class _FakeTenantAccessProvisioner: del session, tenant, owner_account_id return "user-1" + def ensure_development_admin(self, session: object, *, tenant: object, user_email: str, user_password: str, api_key_secret: str | None, scopes): + del session, tenant, user_password, scopes + return DevelopmentBootstrapRef( + user=UserRef(id="user-1", account_id="account-1", tenant_id="tenant-1", email=user_email), + created_api_key=CreatedApiKeyRef(id="api-key-1", secret=api_key_secret) if api_key_secret else None, + ) + class _FakeAccessAdministration: + def tenant_counts(self, session: object, tenant_id: str): + del session, tenant_id + return {"users": 1, "active_users": 1, "groups": 2, "api_keys": 3} + def system_account_count(self, session: object) -> int: del session return 1 @@ -287,6 +307,22 @@ class _FakeAccessAdministration: del session, operator, value return ("user-1",) + def user_settings(self, session: object, user_id: str, *, tenant_id: str): + del session, user_id, tenant_id + return {"privacy_retention_policy": {"audit_detail_level": "minimal"}} + + def set_user_settings(self, session: object, user_id: str, *, tenant_id: str, settings): + del session, user_id, tenant_id + return dict(settings) + + def group_settings(self, session: object, group_id: str, *, tenant_id: str): + del session, group_id, tenant_id + return {"privacy_retention_policy": {"audit_detail_level": "redacted"}} + + def set_group_settings(self, session: object, group_id: str, *, tenant_id: str, settings): + del session, group_id, tenant_id + return dict(settings) + class _FakeAccessGovernanceMaterializer: def __init__(self) -> None: @@ -408,6 +444,28 @@ class _FakeAuditSink: self.events.append(event) +class _FakeAuditRecorder: + def record_event(self, session: object, event: AuditEvent) -> AuditRecordRef: + del session + return AuditRecordRef( + id="audit-1", + scope=event.scope, + tenant_id=event.tenant_id, + user_id=event.user_id, + api_key_id=event.api_key_id, + action=event.action, + object_type=event.resource_type, + object_id=event.resource_id, + details=event.details, + ) + + +class _FakeAuditRetentionProvider: + def apply_detail_retention(self, session: object, *, dry_run: bool, now, policy_for_record): + del session, dry_run, now, policy_for_record + return {"eligible": 1, "redacted": 0, "already_redacted": 0} + + class AccessContractTests(unittest.TestCase): def test_access_capability_names_are_stable(self) -> None: self.assertEqual("access", ACCESS_MODULE_ID) @@ -428,6 +486,8 @@ class AccessContractTests(unittest.TestCase): self.assertEqual("tenancy.tenantResolver", CAPABILITY_TENANCY_TENANT_RESOLVER) self.assertEqual("security.secretProvider", CAPABILITY_SECURITY_SECRET_PROVIDER) self.assertEqual("audit.sink", CAPABILITY_AUDIT_SINK) + self.assertEqual("audit.recorder", CAPABILITY_AUDIT_RECORDER) + self.assertEqual("audit.retention", CAPABILITY_AUDIT_RETENTION) self.assertEqual(len(ACCESS_CAPABILITY_NAMES), len(set(ACCESS_CAPABILITY_NAMES))) def test_access_refs_are_small_immutable_dtos(self) -> None: @@ -533,6 +593,8 @@ class AccessContractTests(unittest.TestCase): self.assertIsInstance(_FakeCampaignRetentionProvider(), CampaignRetentionProvider) self.assertIsInstance(_FakeSecretProvider(), SecretProvider) self.assertIsInstance(_FakeAuditSink(), AuditSink) + self.assertIsInstance(_FakeAuditRecorder(), AuditRecorder) + self.assertIsInstance(_FakeAuditRetentionProvider(), AuditRetentionProvider) def test_campaign_mail_policy_context_capability_shape(self) -> None: provider = _FakeCampaignMailPolicyContextProvider() @@ -843,6 +905,132 @@ class AccessContractTests(unittest.TestCase): finally: shutil.rmtree(root, ignore_errors=True) + def test_external_function_role_mappings_require_organizations_directory(self) -> None: + root = Path(tempfile.mkdtemp(prefix="govoplan-access-external-function-map-")) + try: + tenant_id = "tenant-external-function-map" + account_id = "account-external-function-map" + user_id = "user-external-function-map" + role_id = "role-external-function-map" + function_id = "function-external-function-map" + + with temporary_database(f"sqlite:///{root / 'external-function-map.db'}") as database: + create_scope_tables(database.engine) + Base.metadata.create_all(bind=database.engine) + with database.session() as session: + tenant = Tenant(id=tenant_id, slug="external-function-map", name="External Function Map", settings={}) + account = Account( + id=account_id, + email="external-function-map@example.test", + normalized_email="external-function-map@example.test", + display_name="External Function Map Admin", + ) + user = User( + id=user_id, + tenant_id=tenant_id, + account_id=account_id, + email=account.email, + display_name=account.display_name, + settings={}, + mail_profile_policy={}, + ) + role = Role( + id=role_id, + tenant_id=tenant_id, + slug="external-function-reader", + name="External Function Reader", + permissions=["files:file:read"], + is_assignable=True, + ) + session.add_all([tenant, account, user, role]) + session.commit() + + from govoplan_access.backend.api.v1.routes import router as admin_router + + app = FastAPI() + app.include_router(admin_router) + + def principal_override() -> ApiPrincipal: + with database.session() as session: + account = session.get(Account, account_id) + user = session.get(User, user_id) + assert account is not None + assert user is not None + return ApiPrincipal( + principal=PrincipalRef( + account_id=account_id, + membership_id=user_id, + tenant_id=tenant_id, + scopes=frozenset({"admin:roles:read", "admin:roles:write", "access:role:assign"}), + ), + account=account, + user=user, + ) + + app.dependency_overrides[get_api_principal] = principal_override + clear_runtime() + + with TestClient(app) as client: + blocked_response = client.post( + "/admin/external-function-role-mappings", + json={"source_module": "organizations", "function_id": function_id, "role_id": role_id}, + ) + self.assertEqual(409, blocked_response.status_code, blocked_response.text) + self.assertEqual("organizations_required", blocked_response.json()["detail"]["code"]) + + class FakeOrganizationDirectory(CoreOrganizationDirectory): + def get_organization_unit(self, organization_unit_id: str): + del organization_unit_id + return None + + def organization_units_for_tenant(self, tenant_id: str): + del tenant_id + return () + + def get_function(self, requested_function_id: str): + if requested_function_id != function_id: + return None + return CoreOrganizationFunctionRef( + id=function_id, + tenant_id=tenant_id, + organization_unit_id="unit-external-function-map", + slug="external-function", + name="External Function", + ) + + def functions_for_organization_unit(self, organization_unit_id: str, *, include_subunits: bool = False): + del organization_unit_id, include_subunits + return () + + registry = PlatformRegistry() + registry.register( + ModuleManifest( + id="organizations", + name="Organizations", + version="test", + capability_factories={CAPABILITY_ORGANIZATION_DIRECTORY: lambda context: FakeOrganizationDirectory()}, + ) + ) + context = ModuleContext(registry=registry, settings=object()) + registry.configure_capability_context(context) + configure_runtime(context) + + created_response = client.post( + "/admin/external-function-role-mappings", + json={"source_module": "organizations", "function_id": function_id, "role_id": role_id}, + ) + self.assertEqual(201, created_response.status_code, created_response.text) + self.assertEqual(function_id, created_response.json()["function_id"]) + + unsupported_response = client.post( + "/admin/external-function-role-mappings", + json={"source_module": "legacy", "function_id": function_id, "role_id": role_id}, + ) + self.assertEqual(422, unsupported_response.status_code, unsupported_response.text) + finally: + clear_runtime() + shutil.rmtree(root, ignore_errors=True) + def test_api_principal_dependency_uses_access_resolver_capability(self) -> None: root = Path(tempfile.mkdtemp(prefix="govoplan-access-contract-")) try: diff --git a/tests/test_core_events.py b/tests/test_core_events.py index e687c4c..a5efda3 100644 --- a/tests/test_core_events.py +++ b/tests/test_core_events.py @@ -20,12 +20,22 @@ from govoplan_core.core.events import ( event_context, normalize_trace_id, ) +from govoplan_core.core.modules import ModuleContext from govoplan_core.core.registry import PlatformRegistry +from govoplan_core.core.runtime import clear_runtime, configure_runtime from govoplan_core.db.base import Base from govoplan_core.server.fastapi import create_govoplan_app +from govoplan_core.server.registry import build_platform_registry from tests.db_isolation import temporary_database +def _configure_audit_runtime() -> None: + registry = build_platform_registry(("audit",)) + context = ModuleContext(registry=registry, settings=object()) + registry.configure_capability_context(context) + configure_runtime(context) + + class CoreEventTests(unittest.TestCase): def test_event_bus_adds_trace_ids_and_propagates_causation_to_nested_events(self) -> None: bus = EventBus() @@ -127,6 +137,7 @@ class CoreEventTests(unittest.TestCase): root = Path(tempfile.mkdtemp(prefix="govoplan-audit-trace-")) try: with temporary_database(f"sqlite:///{root / 'audit.db'}") as database: + _configure_audit_runtime() from govoplan_access.backend.db import models as access_models # noqa: F401 from govoplan_admin.backend.db import models as admin_models # noqa: F401 from govoplan_audit.backend.db import models as audit_models # noqa: F401 @@ -148,12 +159,14 @@ class CoreEventTests(unittest.TestCase): self.assertEqual({"correlation_id": "corr-1", "causation_id": "cause-1"}, item.details["_trace"]) self.assertEqual("", item.details["password"]) finally: + clear_runtime() shutil.rmtree(root, ignore_errors=True) def test_audit_event_publishes_governed_platform_event(self) -> None: root = Path(tempfile.mkdtemp(prefix="govoplan-audit-event-")) try: with temporary_database(f"sqlite:///{root / 'audit.db'}") as database: + _configure_audit_runtime() from govoplan_access.backend.db import models as access_models # noqa: F401 from govoplan_admin.backend.db import models as admin_models # noqa: F401 from govoplan_audit.backend.db import models as audit_models # noqa: F401 @@ -187,12 +200,14 @@ class CoreEventTests(unittest.TestCase): self.assertEqual(item.id, event.payload["audit_log_id"]) self.assertEqual("", event.payload["details"]["password"]) finally: + clear_runtime() shutil.rmtree(root, ignore_errors=True) def test_audit_events_map_existing_module_action_prefixes(self) -> None: root = Path(tempfile.mkdtemp(prefix="govoplan-audit-module-events-")) try: with temporary_database(f"sqlite:///{root / 'audit.db'}") as database: + _configure_audit_runtime() from govoplan_access.backend.db import models as access_models # noqa: F401 from govoplan_admin.backend.db import models as admin_models # noqa: F401 from govoplan_audit.backend.db import models as audit_models # noqa: F401 @@ -225,6 +240,7 @@ class CoreEventTests(unittest.TestCase): by_type = {event.type: event.module_id for event in seen if event.type in cases} self.assertEqual(cases, by_type) finally: + clear_runtime() shutil.rmtree(root, ignore_errors=True) def test_audit_operation_context_keeps_lifecycle_ids_and_sanitizes_details(self) -> None: diff --git a/tests/test_identity_organization_contracts.py b/tests/test_identity_organization_contracts.py index 0fcb114..26489f3 100644 --- a/tests/test_identity_organization_contracts.py +++ b/tests/test_identity_organization_contracts.py @@ -11,21 +11,26 @@ from govoplan_core.core.identity import ( IdentityDirectory, IdentityRef, ) +from govoplan_core.core.idm import ( + CAPABILITY_IDM_DIRECTORY, + IdmDirectory, + OrganizationFunctionAssignmentRef, +) from govoplan_core.core.organizations import ( CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory, - OrganizationFunctionAssignmentRef, OrganizationFunctionRef, OrganizationUnitRef, ) from govoplan_core.core.modules import ModuleContext, ModuleManifest from govoplan_core.core.registry import PlatformRegistry from govoplan_core.db.base import Base -from govoplan_access.backend.db.models import User # noqa: F401 - registers legacy tenancy relationship target +from govoplan_access.backend.db.models import ExternalFunctionRoleAssignment, Role, User +from govoplan_access.backend.semantic import collect_external_function_roles from govoplan_identity.backend.db.models import Identity, IdentityAccountLink +from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment from govoplan_organizations.backend.db.models import ( OrganizationFunction, - OrganizationFunctionAssignment, OrganizationFunctionType, OrganizationRelation, OrganizationRelationType, @@ -73,15 +78,6 @@ class _FakeOrganizationDirectory: slug="registry-clerk", name="Registry Clerk", ) - assignment = OrganizationFunctionAssignmentRef( - id="assignment-1", - tenant_id="tenant-1", - identity_id="identity-1", - account_id="account-1", - function_id=function.id, - organization_unit_id=unit.id, - applies_to_subunits=True, - ) def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None: return self.unit if organization_unit_id == self.unit.id else None @@ -96,17 +92,29 @@ class _FakeOrganizationDirectory: del include_subunits return (self.function,) if organization_unit_id == self.unit.id else () - def get_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None: + +class _FakeIdmDirectory: + assignment = OrganizationFunctionAssignmentRef( + id="assignment-1", + tenant_id="tenant-1", + identity_id="identity-1", + account_id="account-1", + function_id=_FakeOrganizationDirectory.function.id, + organization_unit_id=_FakeOrganizationDirectory.unit.id, + applies_to_subunits=True, + ) + + def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None: return self.assignment if assignment_id == self.assignment.id else None - def function_assignments_for_account(self, account_id: str, *, tenant_id: str | None = None): + def organization_function_assignments_for_account(self, account_id: str, *, tenant_id: str | None = None): if account_id != self.assignment.account_id: return () if tenant_id is not None and tenant_id != self.assignment.tenant_id: return () return (self.assignment,) - def function_assignments_for_identity(self, identity_id: str, *, tenant_id: str | None = None): + def organization_function_assignments_for_identity(self, identity_id: str, *, tenant_id: str | None = None): if identity_id != self.assignment.identity_id: return () if tenant_id is not None and tenant_id != self.assignment.tenant_id: @@ -118,10 +126,12 @@ class IdentityOrganizationContractTests(unittest.TestCase): def test_protocol_shapes_match_reference_implementations(self) -> None: self.assertIsInstance(_FakeIdentityDirectory(), IdentityDirectory) self.assertIsInstance(_FakeOrganizationDirectory(), OrganizationDirectory) + self.assertIsInstance(_FakeIdmDirectory(), IdmDirectory) def test_capabilities_register_and_resolve_through_platform_registry(self) -> None: identity_directory = _FakeIdentityDirectory() organization_directory = _FakeOrganizationDirectory() + idm_directory = _FakeIdmDirectory() manifest = ModuleManifest( id="directory-contract-test", name="Directory Contract Test", @@ -129,6 +139,7 @@ class IdentityOrganizationContractTests(unittest.TestCase): capability_factories={ CAPABILITY_IDENTITY_DIRECTORY: lambda context: identity_directory, CAPABILITY_ORGANIZATION_DIRECTORY: lambda context: organization_directory, + CAPABILITY_IDM_DIRECTORY: lambda context: idm_directory, }, ) registry = PlatformRegistry() @@ -137,6 +148,64 @@ class IdentityOrganizationContractTests(unittest.TestCase): self.assertIs(identity_directory, registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)) self.assertIs(organization_directory, registry.require_capability(CAPABILITY_ORGANIZATION_DIRECTORY)) + self.assertIs(idm_directory, registry.require_capability(CAPABILITY_IDM_DIRECTORY)) + + def test_access_directory_can_optionally_consume_idm_assignments(self) -> None: + from govoplan_access.backend.directory import SqlAccessDirectory + + root = Path(tempfile.mkdtemp(prefix="govoplan-access-idm-directory-")) + try: + with temporary_database(f"sqlite:///{root / 'access-idm.db'}") as database: + Base.metadata.create_all(bind=database.engine) + directory = SqlAccessDirectory(idm_directory=_FakeIdmDirectory()) + assignments = directory.function_assignments_for_account("account-1", tenant_id="tenant-1") + self.assertEqual(("assignment-1",), tuple(item.id for item in assignments)) + self.assertEqual("identity-1", assignments[0].identity_id) + self.assertEqual("function-1", assignments[0].function_id) + finally: + shutil.rmtree(root, ignore_errors=True) + + def test_access_maps_idm_function_facts_to_roles_explicitly(self) -> None: + root = Path(tempfile.mkdtemp(prefix="govoplan-access-idm-role-map-")) + try: + with temporary_database(f"sqlite:///{root / 'access-idm-role-map.db'}") as database: + Base.metadata.create_all(bind=database.engine) + with database.session() as session: + user = User( + id="user-idm-role-map", + tenant_id="tenant-1", + account_id="account-1", + email="role-map@example.test", + display_name="Role Map", + settings={}, + mail_profile_policy={}, + ) + role = Role( + id="role-idm-function", + tenant_id="tenant-1", + slug="idm-function-reader", + name="IDM Function Reader", + permissions=["files:file:read"], + is_assignable=True, + ) + mapping = ExternalFunctionRoleAssignment( + tenant_id="tenant-1", + source_module="organizations", + function_id="function-1", + role_id=role.id, + settings={}, + ) + session.add_all([user, role, mapping]) + session.commit() + + roles = collect_external_function_roles( + session, + user, + _FakeIdmDirectory().organization_function_assignments_for_account("account-1", tenant_id="tenant-1"), + ) + self.assertEqual(["role-idm-function"], [item.id for item in roles]) + finally: + shutil.rmtree(root, ignore_errors=True) def test_sql_identity_and_organization_directories_return_dtos(self) -> None: root = Path(tempfile.mkdtemp(prefix="govoplan-directory-contracts-")) @@ -210,7 +279,7 @@ class IdentityOrganizationContractTests(unittest.TestCase): slug="registry-clerk", name="Registry Clerk", ) - assignment = OrganizationFunctionAssignment( + assignment = IdmOrganizationFunctionAssignment( id="assignment-directory", tenant_id=tenant.id, identity_id=identity.id, @@ -235,10 +304,12 @@ class IdentityOrganizationContractTests(unittest.TestCase): session.commit() from govoplan_identity.backend.directory import SqlIdentityDirectory + from govoplan_idm.backend.directory import SqlIdmDirectory from govoplan_organizations.backend.directory import SqlOrganizationDirectory identity_directory = SqlIdentityDirectory() - organization_directory = SqlOrganizationDirectory(identity_directory=identity_directory) + idm_directory = SqlIdmDirectory() + organization_directory = SqlOrganizationDirectory() self.assertEqual("Directory Person", identity_directory.get_identity("identity-directory").display_name) # type: ignore[union-attr] self.assertEqual("identity-directory", identity_directory.identity_for_account("account-directory").id) # type: ignore[union-attr] @@ -249,11 +320,11 @@ class IdentityOrganizationContractTests(unittest.TestCase): self.assertEqual("function-type-directory", organization_directory.get_function("function-directory").function_type_id) # type: ignore[union-attr] self.assertEqual( ["assignment-directory"], - [item.id for item in organization_directory.function_assignments_for_identity("identity-directory", tenant_id="tenant-directory")], + [item.id for item in idm_directory.organization_function_assignments_for_identity("identity-directory", tenant_id="tenant-directory")], ) self.assertEqual( ["assignment-directory"], - [item.id for item in organization_directory.function_assignments_for_account("account-directory", tenant_id="tenant-directory")], + [item.id for item in idm_directory.organization_function_assignments_for_account("account-directory", tenant_id="tenant-directory")], ) finally: shutil.rmtree(root, ignore_errors=True) diff --git a/tests/test_idm_api.py b/tests/test_idm_api.py new file mode 100644 index 0000000..a5cf243 --- /dev/null +++ b/tests/test_idm_api.py @@ -0,0 +1,403 @@ +from __future__ import annotations + +import shutil +import tempfile +import unittest +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from govoplan_access.backend.db.models import Account, User +from govoplan_core.auth import ApiPrincipal, get_api_principal +from govoplan_core.core.access import PrincipalRef +from govoplan_core.core.configuration_control import configuration_control_snapshot, create_configuration_change_request +from govoplan_core.db.base import Base +from govoplan_identity.backend.db.models import Identity, IdentityAccountLink +from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment, IdmTenantSettings +from govoplan_idm.backend.api.v1.routes import router as idm_router +from govoplan_idm.backend.manifest import get_manifest +from govoplan_organizations.backend.db.models import OrganizationFunction, OrganizationUnit +from tests.db_isolation import temporary_database + + +class IdmApiTests(unittest.TestCase): + def _principal(self, scopes: set[str]) -> ApiPrincipal: + account = Account( + id="account-idm-api", + email="idm-admin@example.test", + normalized_email="idm-admin@example.test", + display_name="IDM Admin", + ) + user = User( + id="user-idm-api", + tenant_id="tenant-idm-api", + account_id=account.id, + email=account.email, + display_name=account.display_name, + settings={}, + mail_profile_policy={}, + ) + return ApiPrincipal( + principal=PrincipalRef( + account_id=account.id, + membership_id=user.id, + tenant_id=user.tenant_id, + scopes=frozenset(scopes), + ), + account=account, + user=user, + ) + + def _app(self, scopes: set[str]) -> FastAPI: + app = FastAPI() + app.include_router(idm_router, prefix="/api/v1") + app.dependency_overrides[get_api_principal] = lambda: self._principal(scopes) + return app + + def test_idm_hard_depends_on_identity_and_organizations(self) -> None: + manifest = get_manifest() + self.assertEqual(("identity", "organizations"), manifest.dependencies) + + def test_organization_identity_candidates_are_idm_bridge(self) -> None: + root = Path(tempfile.mkdtemp(prefix="govoplan-idm-api-")) + try: + with temporary_database(f"sqlite:///{root / 'idm.db'}") as database: + Base.metadata.create_all(bind=database.engine) + with database.session() as session: + identity = Identity( + id="identity-idm-alice", + display_name="Alice Registry", + external_subject="alice@example.test", + source="local", + ) + session.add(identity) + session.add( + IdentityAccountLink( + identity_id=identity.id, + account_id="account-idm-alice-primary", + is_primary=True, + source="local", + ) + ) + session.commit() + + app = self._app({"organizations:function:assign"}) + with TestClient(app) as client: + response = client.get("/api/v1/idm/organization-identities", params={"query": "registry"}) + self.assertEqual(200, response.status_code, response.text) + payload = response.json() + self.assertEqual(1, len(payload["identities"])) + item = payload["identities"][0] + self.assertEqual("identity-idm-alice", item["id"]) + self.assertEqual("Alice Registry", item["display_name"]) + self.assertEqual("account-idm-alice-primary", item["primary_account_id"]) + finally: + shutil.rmtree(root, ignore_errors=True) + + def test_organization_function_assignments_are_owned_by_idm(self) -> None: + root = Path(tempfile.mkdtemp(prefix="govoplan-idm-assignments-api-")) + try: + with temporary_database(f"sqlite:///{root / 'idm-assignments.db'}") as database: + Base.metadata.create_all(bind=database.engine) + with database.session() as session: + identity = Identity( + id="identity-idm-bob", + display_name="Bob Registry", + external_subject="bob@example.test", + source="local", + ) + unit = OrganizationUnit( + id="unit-idm-registry", + tenant_id="tenant-idm-api", + slug="registry", + name="Registry", + settings={}, + ) + function = OrganizationFunction( + id="function-idm-clerk", + tenant_id="tenant-idm-api", + organization_unit_id=unit.id, + slug="registry-clerk", + name="Registry Clerk", + settings={}, + ) + session.add_all([ + identity, + IdentityAccountLink( + identity_id=identity.id, + account_id="account-idm-bob-primary", + is_primary=True, + source="local", + ), + unit, + function, + ]) + session.commit() + + app = self._app({"idm:organization_assignment:read", "idm:organization_assignment:write"}) + with TestClient(app) as client: + create_response = client.post( + "/api/v1/idm/organization-function-assignments", + json={ + "identity_id": "identity-idm-bob", + "account_id": "account-idm-bob-primary", + "function_id": "function-idm-clerk", + "applies_to_subunits": True, + }, + ) + self.assertEqual(201, create_response.status_code, create_response.text) + created = create_response.json() + self.assertEqual("identity-idm-bob", created["identity_id"]) + self.assertEqual("function-idm-clerk", created["function_id"]) + self.assertEqual("unit-idm-registry", created["organization_unit_id"]) + self.assertTrue(created["applies_to_subunits"]) + + list_response = client.get("/api/v1/idm/organization-function-assignments") + self.assertEqual(200, list_response.status_code, list_response.text) + self.assertEqual([created["id"]], [item["id"] for item in list_response.json()["assignments"]]) + + patch_response = client.patch( + f"/api/v1/idm/organization-function-assignments/{created['id']}", + json={"is_active": False, "applies_to_subunits": False}, + ) + self.assertEqual(200, patch_response.status_code, patch_response.text) + patched = patch_response.json() + self.assertFalse(patched["is_active"]) + self.assertFalse(patched["applies_to_subunits"]) + + with database.session() as session: + self.assertEqual(1, session.query(IdmOrganizationFunctionAssignment).count()) + finally: + shutil.rmtree(root, ignore_errors=True) + + def test_legacy_organization_assign_scope_remains_assignment_compatibility(self) -> None: + root = Path(tempfile.mkdtemp(prefix="govoplan-idm-legacy-scope-")) + try: + with temporary_database(f"sqlite:///{root / 'idm-legacy.db'}") as database: + Base.metadata.create_all(bind=database.engine) + with database.session() as session: + session.add(Identity(id="identity-idm-legacy", display_name="Legacy Person", source="local")) + session.add(IdentityAccountLink(identity_id="identity-idm-legacy", account_id="account-idm-legacy", is_primary=True, source="local")) + session.add(OrganizationUnit(id="unit-idm-legacy", tenant_id="tenant-idm-api", slug="legacy", name="Legacy", settings={})) + session.add( + OrganizationFunction( + id="function-idm-legacy", + tenant_id="tenant-idm-api", + organization_unit_id="unit-idm-legacy", + slug="legacy-clerk", + name="Legacy Clerk", + settings={}, + ) + ) + session.commit() + + app = self._app({"organizations:function:assign"}) + with TestClient(app) as client: + create_response = client.post( + "/api/v1/idm/organization-function-assignments", + json={ + "identity_id": "identity-idm-legacy", + "account_id": "account-idm-legacy", + "function_id": "function-idm-legacy", + }, + ) + self.assertEqual(201, create_response.status_code, create_response.text) + list_response = client.get("/api/v1/idm/organization-function-assignments") + self.assertEqual(200, list_response.status_code, list_response.text) + self.assertEqual([create_response.json()["id"]], [item["id"] for item in list_response.json()["assignments"]]) + finally: + shutil.rmtree(root, ignore_errors=True) + + def test_idm_assignment_change_requests_can_be_required_per_tenant(self) -> None: + root = Path(tempfile.mkdtemp(prefix="govoplan-idm-change-control-")) + try: + with temporary_database(f"sqlite:///{root / 'idm-change-control.db'}") as database: + Base.metadata.create_all(bind=database.engine) + with database.session() as session: + session.add(Identity(id="identity-idm-change", display_name="Change Person", source="local")) + session.add(IdentityAccountLink(identity_id="identity-idm-change", account_id="account-idm-change", is_primary=True, source="local")) + session.add(OrganizationUnit(id="unit-idm-change", tenant_id="tenant-idm-api", slug="change", name="Change", settings={})) + session.add( + OrganizationFunction( + id="function-idm-change", + tenant_id="tenant-idm-api", + organization_unit_id="unit-idm-change", + slug="change-clerk", + name="Change Clerk", + settings={}, + ) + ) + session.add( + IdmTenantSettings( + tenant_id="tenant-idm-api", + require_assignment_change_requests=True, + audit_detail_level="standard", + settings={}, + ) + ) + session.commit() + + app = self._app({"idm:organization_assignment:write", "idm:organization_assignment:read"}) + payload = { + "identity_id": "identity-idm-change", + "account_id": "account-idm-change", + "function_id": "function-idm-change", + "applies_to_subunits": True, + } + with TestClient(app) as client: + blocked = client.post("/api/v1/idm/organization-function-assignments", json=payload) + self.assertEqual(409, blocked.status_code, blocked.text) + self.assertEqual("configuration_request_required", blocked.json()["detail"]["code"]) + + with database.session() as session: + request = create_configuration_change_request( + session, + key="idm.organization_assignments", + value={ + "resource_type": "organization_function_assignment", + "operation": "created", + "payload": payload, + }, + actor_user_id="user-idm-api", + actor_scopes=("idm:organization_assignment:write",), + dry_run=True, + target={ + "tenant_id": "tenant-idm-api", + "resource_type": "organization_function_assignment", + "operation": "created", + }, + ) + session.commit() + + with TestClient(app) as client: + applied = client.post( + "/api/v1/idm/organization-function-assignments", + json={**payload, "change_request_id": request["id"]}, + ) + self.assertEqual(201, applied.status_code, applied.text) + self.assertEqual("identity-idm-change", applied.json()["identity_id"]) + + with database.session() as session: + history = configuration_control_snapshot(session)["history"] + self.assertEqual("idm.organization_assignments", history[0]["key"]) + self.assertEqual("idm.organization_assignment.updated", history[0]["audit_event"]) + finally: + shutil.rmtree(root, ignore_errors=True) + + def test_idm_settings_can_be_read_and_updated(self) -> None: + root = Path(tempfile.mkdtemp(prefix="govoplan-idm-settings-")) + try: + with temporary_database(f"sqlite:///{root / 'idm-settings.db'}") as database: + Base.metadata.create_all(bind=database.engine) + app = self._app({"idm:settings:read", "idm:settings:write"}) + with TestClient(app) as client: + initial = client.get("/api/v1/idm/settings") + self.assertEqual(200, initial.status_code, initial.text) + self.assertFalse(initial.json()["require_assignment_change_requests"]) + + updated = client.patch( + "/api/v1/idm/settings", + json={ + "require_assignment_change_requests": True, + "audit_detail_level": "full", + "change_retention_days": 90, + }, + ) + self.assertEqual(200, updated.status_code, updated.text) + payload = updated.json() + self.assertTrue(payload["require_assignment_change_requests"]) + self.assertEqual("full", payload["audit_detail_level"]) + self.assertEqual(90, payload["change_retention_days"]) + finally: + shutil.rmtree(root, ignore_errors=True) + + def test_delegated_assignments_require_delegable_function_and_source_assignment(self) -> None: + root = Path(tempfile.mkdtemp(prefix="govoplan-idm-delegation-rules-")) + try: + with temporary_database(f"sqlite:///{root / 'idm-delegation.db'}") as database: + Base.metadata.create_all(bind=database.engine) + with database.session() as session: + session.add_all([ + Identity(id="identity-idm-source", display_name="Source Person", source="local"), + IdentityAccountLink(identity_id="identity-idm-source", account_id="account-idm-source", is_primary=True, source="local"), + Identity(id="identity-idm-delegate", display_name="Delegate Person", source="local"), + IdentityAccountLink(identity_id="identity-idm-delegate", account_id="account-idm-delegate", is_primary=True, source="local"), + OrganizationUnit(id="unit-idm-delegation", tenant_id="tenant-idm-api", slug="delegation", name="Delegation", settings={}), + OrganizationFunction( + id="function-idm-not-delegable", + tenant_id="tenant-idm-api", + organization_unit_id="unit-idm-delegation", + slug="not-delegable", + name="Not Delegable", + delegable=False, + settings={}, + ), + OrganizationFunction( + id="function-idm-delegable", + tenant_id="tenant-idm-api", + organization_unit_id="unit-idm-delegation", + slug="delegable", + name="Delegable", + delegable=True, + settings={}, + ), + ]) + session.add( + IdmOrganizationFunctionAssignment( + id="assignment-idm-source", + tenant_id="tenant-idm-api", + identity_id="identity-idm-source", + account_id="account-idm-source", + function_id="function-idm-delegable", + organization_unit_id="unit-idm-delegation", + applies_to_subunits=False, + source="direct", + settings={}, + ) + ) + session.commit() + + app = self._app({"idm:organization_assignment:read", "idm:organization_assignment:write"}) + with TestClient(app) as client: + missing_source = client.post( + "/api/v1/idm/organization-function-assignments", + json={ + "identity_id": "identity-idm-delegate", + "account_id": "account-idm-delegate", + "function_id": "function-idm-delegable", + "source": "delegated", + }, + ) + self.assertEqual(422, missing_source.status_code, missing_source.text) + + not_delegable = client.post( + "/api/v1/idm/organization-function-assignments", + json={ + "identity_id": "identity-idm-delegate", + "account_id": "account-idm-delegate", + "function_id": "function-idm-not-delegable", + "source": "delegated", + "delegated_from_assignment_id": "assignment-idm-source", + }, + ) + self.assertEqual(422, not_delegable.status_code, not_delegable.text) + + delegated = client.post( + "/api/v1/idm/organization-function-assignments", + json={ + "identity_id": "identity-idm-delegate", + "account_id": "account-idm-delegate", + "function_id": "function-idm-delegable", + "source": "delegated", + "delegated_from_assignment_id": "assignment-idm-source", + }, + ) + self.assertEqual(201, delegated.status_code, delegated.text) + self.assertEqual("delegated", delegated.json()["source"]) + finally: + shutil.rmtree(root, ignore_errors=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_install_config.py b/tests/test_install_config.py new file mode 100644 index 0000000..5c9a479 --- /dev/null +++ b/tests/test_install_config.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import unittest + +from cryptography.fernet import Fernet + +from govoplan_core.core.install_config import env_template, validate_runtime_configuration + + +class InstallConfigTests(unittest.TestCase): + def test_self_hosted_validation_reports_actionable_missing_settings(self) -> None: + result = validate_runtime_configuration({}, profile="self-hosted") + + self.assertFalse(result.ok) + errors = {issue.key: issue for issue in result.errors} + self.assertIn("APP_ENV", errors) + self.assertIn("DATABASE_URL", errors) + self.assertIn("MASTER_KEY_B64", errors) + self.assertIn("ENABLED_MODULES", errors) + self.assertIn("CORS_ORIGINS", errors) + self.assertIn("Set DATABASE_URL", errors["DATABASE_URL"].action) + + def test_self_hosted_validation_accepts_explicit_postgres_configuration(self) -> None: + result = validate_runtime_configuration( + { + "APP_ENV": "production", + "GOVOPLAN_INSTALL_PROFILE": "self-hosted", + "DATABASE_URL": "postgresql+psycopg://govoplan:secret@db.example.internal:5432/govoplan", + "GOVOPLAN_DATABASE_URL_PGTOOLS": "postgresql://govoplan:secret@db.example.internal:5432/govoplan", + "MASTER_KEY_B64": Fernet.generate_key().decode("ascii"), + "ENABLED_MODULES": "access,admin,policy,audit", + "CELERY_ENABLED": "true", + "REDIS_URL": "redis://redis.example.internal:6379/0", + "CORS_ORIGINS": "https://govoplan.example.org", + "AUTH_COOKIE_SECURE": "true", + "FILE_STORAGE_BACKEND": "local", + "FILE_STORAGE_LOCAL_ROOT": "/var/lib/govoplan/files", + }, + profile="self-hosted", + ) + + self.assertTrue(result.ok, result.to_text()) + self.assertEqual([], [issue.key for issue in result.errors]) + + def test_production_validation_rejects_dev_bootstrap_and_sqlite(self) -> None: + result = validate_runtime_configuration( + { + "APP_ENV": "production", + "DATABASE_URL": "sqlite:////tmp/govoplan.db", + "MASTER_KEY_B64": Fernet.generate_key().decode("ascii"), + "ENABLED_MODULES": "access,admin", + "DEV_BOOTSTRAP_ENABLED": "true", + "CORS_ORIGINS": "https://govoplan.example.org", + "AUTH_COOKIE_SECURE": "true", + "FILE_STORAGE_BACKEND": "local", + "FILE_STORAGE_LOCAL_ROOT": "/var/lib/govoplan/files", + }, + profile="self-hosted", + ) + + error_keys = {issue.key for issue in result.errors} + self.assertIn("DATABASE_URL", error_keys) + self.assertIn("DEV_BOOTSTRAP_ENABLED", error_keys) + + def test_env_templates_surface_install_profiles(self) -> None: + self_hosted = env_template(profile="self-hosted") + production_like = env_template(profile="production-like", generate_secrets=True) + + self.assertIn("GOVOPLAN_INSTALL_PROFILE=self-hosted", self_hosted) + self.assertIn("MASTER_KEY_B64= None: registry = PlatformRegistry() @@ -590,6 +657,68 @@ finally: with self.assertRaisesRegex(RegistryError, "requires unavailable capability"): registry.validate() + def test_registry_resolves_required_interface_ranges(self) -> None: + registry = PlatformRegistry() + registry.register(ModuleManifest( + id="files", + name="Files", + version="1.4.0", + provides_interfaces=(ModuleInterfaceProvider(name="files.spaces", version="1.4.0"),), + )) + registry.register(ModuleManifest( + id="campaigns", + name="Campaigns", + version="1.1.0", + requires_interfaces=( + ModuleInterfaceRequirement( + name="files.spaces", + version_min="1.0.0", + version_max_exclusive="2.0.0", + ), + ), + )) + + snapshot = registry.validate() + + self.assertEqual({"files", "campaigns"}, {manifest.id for manifest in snapshot.manifests}) + + def test_registry_rejects_missing_required_interface(self) -> None: + registry = PlatformRegistry() + registry.register(ModuleManifest( + id="campaigns", + name="Campaigns", + version="1.1.0", + requires_interfaces=(ModuleInterfaceRequirement(name="files.spaces", version_min="1.0.0"),), + )) + + with self.assertRaisesRegex(RegistryError, "requires unavailable interface"): + registry.validate() + + def test_registry_rejects_incompatible_optional_interface_provider(self) -> None: + registry = PlatformRegistry() + registry.register(ModuleManifest( + id="files", + name="Files", + version="2.0.0", + provides_interfaces=(ModuleInterfaceProvider(name="files.spaces", version="2.0.0"),), + )) + registry.register(ModuleManifest( + id="campaigns", + name="Campaigns", + version="1.1.0", + requires_interfaces=( + ModuleInterfaceRequirement( + name="files.spaces", + version_min="1.0.0", + version_max_exclusive="2.0.0", + optional=True, + ), + ), + )) + + with self.assertRaisesRegex(RegistryError, "available providers"): + registry.validate() + def test_access_and_organizations_do_not_hard_depend_on_tenancy(self) -> None: manifests = available_module_manifests() @@ -630,11 +759,79 @@ finally: saved = saved_module_install_plan(session) self.assertEqual(("files",), tuple(item.module_id for item in saved.items)) + self.assertEqual("manual", saved.items[0].source) self.assertEqual(( "python -m pip install 'govoplan-files @ git+ssh://git.example.invalid/add-ideas/govoplan-files.git@v0.1.4'", "npm pkg set 'dependencies.@govoplan/files-webui=git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.1.4' && npm install", ), module_install_plan_commands(saved)) + def test_module_install_plan_preserves_catalog_source(self) -> None: + item = normalize_module_install_plan_item({ + "module_id": "files", + "action": "install", + "source": "catalog", + "catalog": { + "channel": "stable", + "sequence": 8, + "key_id": "release", + }, + "python_package": "govoplan-files", + "python_ref": "govoplan-files==0.1.4", + }) + + self.assertEqual("catalog", item.source) + self.assertEqual("catalog", item.as_dict()["source"]) + self.assertEqual("stable", item.catalog["channel"] if item.catalog else None) + self.assertEqual(8, item.as_dict()["catalog"]["sequence"]) + + def test_admin_catalog_install_plan_records_catalog_provenance(self) -> None: + app, _settings_obj = self._app_for_modules(("tenancy", "admin")) + database = get_database() + create_scope_tables(database.engine) + Base.metadata.create_all(bind=database.engine) + with database.session() as session: + bootstrap_dev_data(session, user_password="test-admin") + + root = Path(tempfile.mkdtemp(prefix="govoplan-admin-module-catalog-", dir=_TEST_ROOT)) + catalog_path = root / "catalog.json" + catalog_path.write_text(json.dumps({ + "channel": "stable", + "sequence": 11, + "generated_at": "2026-07-10T00:00:00Z", + "modules": [{ + "module_id": "files", + "name": "Files", + "version": "0.1.6", + "action": "install", + "python_package": "govoplan-files", + "python_ref": "govoplan-files==0.1.6", + "webui_package": "@govoplan/files-webui", + "webui_ref": "git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.1.6", + }], + }), encoding="utf-8") + + with TestClient(app) as client: + login = client.post( + "/api/v1/auth/login", + json={"email": "admin@example.local", "password": "test-admin"}, + ) + self.assertEqual(200, login.status_code, login.text) + headers = {"Authorization": f"Bearer {login.json()['access_token']}"} + with patch.dict(os.environ, { + "GOVOPLAN_MODULE_PACKAGE_CATALOG": str(catalog_path), + "GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE": "false", + "GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS": "", + }): + planned = client.post("/api/v1/admin/system/modules/install-plan/catalog/files", headers=headers) + + self.assertEqual(200, planned.status_code, planned.text) + item = planned.json()["items"][0] + self.assertEqual("files", item["module_id"]) + self.assertEqual("catalog", item["source"]) + self.assertEqual("stable", item["catalog"]["channel"]) + self.assertEqual(11, item["catalog"]["sequence"]) + self.assertEqual(str(catalog_path), item["catalog"]["source"]) + def test_module_install_plan_rejects_local_dependency_refs(self) -> None: root = Path(tempfile.mkdtemp(prefix="govoplan-module-install-plan-local-", dir=_TEST_ROOT)) settings = _settings(root) @@ -768,6 +965,166 @@ finally: self.assertIn("core_version_too_low", {issue.code for issue in preflight.issues}) self.assertTrue(preflight.checklist) + def test_module_installer_preflight_reports_catalog_warnings_before_activation(self) -> None: + plan = ModuleInstallPlan(items=( + ModuleInstallPlanItem( + module_id="files", + action="install", + python_package="govoplan-files", + python_ref="govoplan-files==0.1.4", + ), + )) + + with patch( + "govoplan_core.core.module_package_catalog.validate_module_package_catalog", + return_value={ + "configured": True, + "valid": True, + "warnings": ["Catalog providers do not satisfy files.campaign_attachments."], + }, + ): + preflight = module_install_preflight( + plan=plan, + available={}, + current_enabled=(), + desired_enabled=(), + maintenance_mode=True, + ) + + self.assertTrue(preflight.allowed) + self.assertIn("catalog_warning", {issue.code for issue in preflight.issues}) + self.assertTrue(any("files.campaign_attachments" in issue.message for issue in preflight.issues)) + + def test_module_installer_preflight_blocks_invalid_catalog_sourced_install(self) -> None: + plan = ModuleInstallPlan(items=( + ModuleInstallPlanItem( + module_id="files", + action="install", + source="catalog", + python_package="govoplan-files", + python_ref="govoplan-files==0.1.4", + ), + )) + + with patch( + "govoplan_core.core.module_package_catalog.validate_module_package_catalog", + return_value={ + "configured": True, + "valid": False, + "error": "Module package catalog must be signed by a trusted key.", + "warnings": [], + }, + ): + preflight = module_install_preflight( + plan=plan, + available={}, + current_enabled=(), + desired_enabled=(), + maintenance_mode=True, + ) + + self.assertFalse(preflight.allowed) + blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"} + self.assertIn("catalog_validation_failed", blockers) + + def test_module_installer_preflight_warns_for_invalid_catalog_during_manual_install(self) -> None: + plan = ModuleInstallPlan(items=( + ModuleInstallPlanItem( + module_id="files", + action="install", + python_package="govoplan-files", + python_ref="govoplan-files==0.1.4", + ), + )) + + with patch( + "govoplan_core.core.module_package_catalog.validate_module_package_catalog", + return_value={ + "configured": True, + "valid": False, + "error": "Module package catalog must be signed by a trusted key.", + "warnings": [], + }, + ): + preflight = module_install_preflight( + plan=plan, + available={}, + current_enabled=(), + desired_enabled=(), + maintenance_mode=True, + ) + + self.assertTrue(preflight.allowed) + warnings = {issue.code for issue in preflight.issues if issue.severity == "warning"} + self.assertIn("catalog_validation_failed", warnings) + + def test_module_installer_preflight_blocks_unsatisfied_catalog_interface_for_selected_install(self) -> None: + plan = ModuleInstallPlan(items=( + ModuleInstallPlanItem( + module_id="campaigns", + action="install", + source="catalog", + python_package="govoplan-campaign", + python_ref="govoplan-campaign==0.1.4", + ), + )) + + with patch( + "govoplan_core.core.module_package_catalog.validate_module_package_catalog", + return_value={ + "configured": True, + "valid": True, + "warnings": [], + "modules": [{ + "module_id": "campaigns", + "requires_interfaces": [{ + "name": "files.campaign_attachments", + "version_min": "1.0.0", + "version_max_exclusive": "2.0.0", + }], + }], + }, + ): + preflight = module_install_preflight( + plan=plan, + available={}, + current_enabled=(), + desired_enabled=(), + maintenance_mode=True, + ) + + self.assertFalse(preflight.allowed) + blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"} + self.assertIn("catalog_required_interface_missing", blockers) + + def test_module_installer_reports_interface_contract_issues(self) -> None: + available = { + "files": ModuleManifest( + id="files", + name="Files", + version="2.0.0", + provides_interfaces=(ModuleInterfaceProvider(name="files.spaces", version="2.0.0"),), + ), + "campaigns": ModuleManifest( + id="campaigns", + name="Campaigns", + version="1.1.0", + requires_interfaces=( + ModuleInterfaceRequirement( + name="files.spaces", + version_min="1.0.0", + version_max_exclusive="2.0.0", + ), + ModuleInterfaceRequirement(name="mail.delivery", version_min="1.0.0"), + ), + ), + } + + issues = module_manifest_compatibility_issues(available) + + self.assertIn("interface_version_mismatch", {issue.code for issue in issues}) + self.assertIn("required_interface_missing", {issue.code for issue in issues}) + def test_module_installer_preflight_requires_python_package_for_install_rollback(self) -> None: root = Path(tempfile.mkdtemp(prefix="govoplan-installer-install-rollback-", dir=_TEST_ROOT)) settings = _settings(root) @@ -1379,6 +1736,8 @@ finally: "version": "0.1.4", "python_package": "govoplan-files", "python_ref": "govoplan-files==0.1.4", + "provides_interfaces": [{"name": "files.spaces", "version": "1.2.0"}], + "requires_interfaces": [{"name": "access.directory", "version_min": "1.0.0", "optional": True}], "artifact_integrity": { "python": { "ref": "govoplan-files==0.1.4", @@ -1396,12 +1755,48 @@ finally: self.assertEqual("files", catalog[0]["module_id"]) self.assertEqual("install", catalog[0]["action"]) self.assertEqual(["official"], catalog[0]["tags"]) + self.assertEqual([{"name": "files.spaces", "version": "1.2.0"}], catalog[0]["provides_interfaces"]) + self.assertEqual( + [{"name": "access.directory", "optional": True, "version_min": "1.0.0"}], + catalog[0]["requires_interfaces"], + ) self.assertEqual("0" * 64, catalog[0]["artifact_integrity"]["python"]["sha256"]) validation = validate_module_package_catalog(catalog_path) self.assertTrue(validation["valid"]) self.assertEqual("files", validation["modules"][0]["module_id"]) + def test_module_package_catalog_warns_about_interface_range_mismatch(self) -> None: + root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-interfaces-", dir=_TEST_ROOT)) + catalog_path = root / "catalog.json" + catalog_path.write_text(json.dumps({ + "modules": [ + { + "module_id": "files", + "version": "2.0.0", + "python_package": "govoplan-files", + "python_ref": "govoplan-files==2.0.0", + "provides_interfaces": [{"name": "files.spaces", "version": "2.0.0"}], + }, + { + "module_id": "campaigns", + "version": "1.1.0", + "python_package": "govoplan-campaign", + "python_ref": "govoplan-campaign==1.1.0", + "requires_interfaces": [{ + "name": "files.spaces", + "version_min": "1.0.0", + "version_max_exclusive": "2.0.0", + }], + }, + ], + }), encoding="utf-8") + + validation = validate_module_package_catalog(catalog_path) + + self.assertTrue(validation["valid"]) + self.assertTrue(any("catalog providers" in warning for warning in validation["warnings"])) + def test_module_package_catalog_validation_reports_bad_entries(self) -> None: root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-invalid-", dir=_TEST_ROOT)) catalog_path = root / "catalog.json" @@ -1412,6 +1807,54 @@ finally: self.assertFalse(validation["valid"]) self.assertIn("module_id", str(validation["error"])) + def test_module_package_catalog_rejects_invalid_interface_ranges(self) -> None: + root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-invalid-interface-", dir=_TEST_ROOT)) + catalog_path = root / "catalog.json" + catalog_path.write_text(json.dumps({ + "modules": [{ + "module_id": "campaigns", + "python_package": "govoplan-campaign", + "python_ref": "govoplan-campaign==1.1.0", + "requires_interfaces": [{ + "name": "files.spaces", + "version_min": "2.0.0", + "version_max_exclusive": "2.0.0", + }], + }], + }), encoding="utf-8") + + validation = validate_module_package_catalog(catalog_path) + + self.assertFalse(validation["valid"]) + self.assertIn("invalid interface range", str(validation["error"])) + + def test_module_package_catalog_requires_signature_by_default_in_production(self) -> None: + root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-production-", dir=_TEST_ROOT)) + catalog_path = root / "catalog.json" + catalog_path.write_text(json.dumps({ + "channel": "stable", + "sequence": 1, + "modules": [{"module_id": "files"}], + }), encoding="utf-8") + + with patch.dict(os.environ, { + "APP_ENV": "production", + "GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE": "", + }): + validation = validate_module_package_catalog(catalog_path) + + self.assertFalse(validation["valid"]) + self.assertIn("signed", str(validation["error"])) + + with patch.dict(os.environ, { + "APP_ENV": "production", + "GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE": "false", + }): + relaxed = validate_module_package_catalog(catalog_path) + + self.assertTrue(relaxed["valid"], relaxed["error"]) + self.assertTrue(any("unsigned" in warning for warning in relaxed["warnings"])) + def test_module_package_catalog_validates_signed_approved_channel(self) -> None: root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-signed-", dir=_TEST_ROOT)) catalog_path = root / "catalog.json" @@ -1454,6 +1897,47 @@ finally: self.assertTrue(validation["trusted"]) self.assertEqual("stable", validation["channel"]) + def test_release_catalog_generator_reads_manifest_interface_contracts(self) -> None: + root = Path(tempfile.mkdtemp(prefix="govoplan-release-catalog-generator-", dir=_TEST_ROOT)) + catalog_path = root / "catalog.json" + + result = subprocess.run( + [ + sys.executable, + "scripts/generate-release-catalog.py", + "--version", + "0.1.6", + "--catalog-output", + str(catalog_path), + ], + cwd=str(Path(__file__).resolve().parents[1]), + env=os.environ.copy(), + text=True, + capture_output=True, + check=False, + ) + + self.assertEqual(0, result.returncode, result.stderr) + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + modules = {item["module_id"]: item for item in catalog["modules"]} + + self.assertIn({"name": "files.campaign_attachments", "version": "0.1.6"}, modules["files"]["provides_interfaces"]) + self.assertIn({ + "name": "campaigns.access", + "optional": True, + "version_min": "0.1.0", + "version_max_exclusive": "0.2.0", + }, modules["files"]["requires_interfaces"]) + self.assertIn({"name": "mail.campaign_delivery", "version": "0.1.6"}, modules["mail"]["provides_interfaces"]) + self.assertIn({ + "name": "files.campaign_attachments", + "optional": True, + "version_min": "0.1.0", + "version_max_exclusive": "0.2.0", + }, modules["campaigns"]["requires_interfaces"]) + self.assertEqual("0.1.6", modules["files"]["version"]) + self.assertIn("@v0.1.6", modules["files"]["python_ref"]) + def test_module_package_catalog_validates_remote_url_and_cache_fallback(self) -> None: root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-remote-", dir=_TEST_ROOT)) catalog_path = root / "catalog.json" diff --git a/webui/package-lock.json b/webui/package-lock.json index 620ae8d..f45182e 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -15,6 +15,7 @@ "@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui", "@govoplan/docs-webui": "file:../../govoplan-docs/webui", "@govoplan/files-webui": "file:../../govoplan-files/webui", + "@govoplan/idm-webui": "file:../../govoplan-idm/webui", "@govoplan/mail-webui": "file:../../govoplan-mail/webui", "@govoplan/ops-webui": "file:../../govoplan-ops/webui", "@govoplan/organizations-webui": "file:../../govoplan-organizations/webui" @@ -171,6 +172,25 @@ } } }, + "../../govoplan-idm/webui": { + "name": "@govoplan/idm-webui", + "version": "0.1.6", + "peerDependencies": { + "@govoplan/core-webui": "^0.1.6", + "@vitejs/plugin-react": "^4.3.4", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1", + "typescript": "^5.7.2", + "vite": "^6.0.6" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } + }, "../../govoplan-mail/webui": { "name": "@govoplan/mail-webui", "version": "0.1.6", @@ -980,6 +1000,10 @@ "resolved": "../../govoplan-files/webui", "link": true }, + "node_modules/@govoplan/idm-webui": { + "resolved": "../../govoplan-idm/webui", + "link": true + }, "node_modules/@govoplan/mail-webui": { "resolved": "../../govoplan-mail/webui", "link": true diff --git a/webui/package.json b/webui/package.json index 2577dcf..3debf07 100644 --- a/webui/package.json +++ b/webui/package.json @@ -33,6 +33,7 @@ "@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui", "@govoplan/docs-webui": "file:../../govoplan-docs/webui", "@govoplan/files-webui": "file:../../govoplan-files/webui", + "@govoplan/idm-webui": "file:../../govoplan-idm/webui", "@govoplan/mail-webui": "file:../../govoplan-mail/webui", "@govoplan/organizations-webui": "file:../../govoplan-organizations/webui", "@govoplan/ops-webui": "file:../../govoplan-ops/webui" diff --git a/webui/package.release.json b/webui/package.release.json index 4e45a8e..0ff1e19 100644 --- a/webui/package.release.json +++ b/webui/package.release.json @@ -28,6 +28,7 @@ "@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.6", "@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#v0.1.6", "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.6", + "@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.6", "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.6", "@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.6", "@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.6", diff --git a/webui/scripts/test-module-permutations.mjs b/webui/scripts/test-module-permutations.mjs index 2f5e1e6..ff22401 100644 --- a/webui/scripts/test-module-permutations.mjs +++ b/webui/scripts/test-module-permutations.mjs @@ -7,6 +7,7 @@ const packageByModule = { dashboard: "@govoplan/dashboard-webui", docs: "@govoplan/docs-webui", files: "@govoplan/files-webui", + idm: "@govoplan/idm-webui", mail: "@govoplan/mail-webui", organizations: "@govoplan/organizations-webui", ops: "@govoplan/ops-webui" @@ -21,11 +22,12 @@ const cases = [ { name: "files-only", modules: ["files"] }, { name: "mail-only", modules: ["mail"] }, { name: "organizations-only", modules: ["organizations"] }, + { name: "idm-with-organizations", modules: ["organizations", "idm"] }, { name: "campaign-only", modules: ["campaigns"] }, { name: "campaign-with-files-no-mail", modules: ["campaigns", "files"] }, { name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] }, { name: "docs-and-ops", modules: ["access", "docs", "ops"] }, - { name: "full-product", modules: ["access", "admin", "dashboard", "organizations", "campaigns", "files", "mail", "docs", "ops"] } + { name: "full-product", modules: ["access", "admin", "dashboard", "organizations", "idm", "campaigns", "files", "mail", "docs", "ops"] } ]; const npmExec = process.env.npm_execpath; diff --git a/webui/src/styles/components.css b/webui/src/styles/components.css index a345a5b..04ed695 100644 --- a/webui/src/styles/components.css +++ b/webui/src/styles/components.css @@ -756,6 +756,14 @@ min-width: 0; } +.module-install-plan-row-meta { + grid-column: 1 / -1; + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + .module-install-plan-row label span { color: var(--muted); font-size: 12px; diff --git a/webui/src/types.ts b/webui/src/types.ts index ba62c67..fdbfa3c 100644 --- a/webui/src/types.ts +++ b/webui/src/types.ts @@ -277,6 +277,55 @@ export type DashboardWidgetsUiCapability = { widgets: DashboardWidgetContribution[]; }; +export type OrganizationFunctionActionContext = PlatformRouteContext & { + function: { + id: string; + tenant_id: string; + organization_unit_id: string; + slug: string; + name: string; + }; +}; + +export type OrganizationFunctionActionContribution = { + id: string; + label?: string; + order?: number; + anyOf?: string[]; + allOf?: string[]; + render: (context: OrganizationFunctionActionContext) => ReactNode; +}; + +export type OrganizationFunctionActionsUiCapability = { + actions: OrganizationFunctionActionContribution[]; +}; + +export type OrganizationFunctionSelection = { + sourceModule: "organizations"; + functionId: string; + label?: string | null; + organizationUnitId?: string | null; + organizationUnitLabel?: string | null; +}; + +export type OrganizationFunctionPickerContext = PlatformRouteContext & { + value: OrganizationFunctionSelection | null; + disabled?: boolean; + onChange: (value: OrganizationFunctionSelection | null) => void; +}; + +export type OrganizationFunctionLabelContext = PlatformRouteContext & { + sourceModule: string; + functionId: string; + fallback?: ReactNode; +}; + +export type OrganizationFunctionPickerUiCapability = { + sourceModule: "organizations"; + renderPicker: (context: OrganizationFunctionPickerContext) => ReactNode; + renderLabel?: (context: OrganizationFunctionLabelContext) => ReactNode; +}; + export type MailSecurity = "plain" | "tls" | "starttls"; export type MailProfileScope = "system" | "tenant" | "user" | "group" | "campaign"; diff --git a/webui/vite.config.ts b/webui/vite.config.ts index a76c660..88baf06 100644 --- a/webui/vite.config.ts +++ b/webui/vite.config.ts @@ -18,6 +18,7 @@ const defaultWebModulePackages = [ "@govoplan/dashboard-webui", "@govoplan/docs-webui", "@govoplan/files-webui", + "@govoplan/idm-webui", "@govoplan/mail-webui", "@govoplan/organizations-webui", "@govoplan/ops-webui" @@ -95,6 +96,7 @@ export default defineConfig({ fileURLToPath(new URL('../../govoplan-dashboard/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-docs/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-files/webui', import.meta.url)), + fileURLToPath(new URL('../../govoplan-idm/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-mail/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-organizations/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-campaign/webui', import.meta.url)),