Prepare GovOPlaN self-hosted release workflow
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 50s
Module Matrix / module-matrix (push) Failing after 49s

This commit is contained in:
2026-07-10 21:57:22 +02:00
parent 8dd5123aab
commit 94236a7d7e
51 changed files with 3576 additions and 803 deletions

32
.env.example Normal file
View File

@@ -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=<generate-with-govoplan-config-env-template-generate-secrets>
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

View File

@@ -19,6 +19,24 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: "22" 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 - name: Install backend dev audit dependencies
run: | run: |
python -m venv .venv python -m venv .venv

View File

@@ -17,6 +17,24 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: "22" 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 - name: Install backend release dependencies
run: | run: |
python -m venv .venv python -m venv .venv

View File

@@ -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
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. 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). `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 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 ## WebUI development

View File

@@ -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_DB=govoplan
GOVOPLAN_PRODUCTION_LIKE_POSTGRES_USER=govoplan GOVOPLAN_PRODUCTION_LIKE_POSTGRES_USER=govoplan
GOVOPLAN_PRODUCTION_LIKE_POSTGRES_PASSWORD=govoplan-dev 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=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_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 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

View File

@@ -18,6 +18,17 @@ cd /mnt/DATA/git/govoplan-core
scripts/launch-production-like-dev.sh 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 The launcher uses `dev/production-like/.env` when present, otherwise
`dev/production-like/.env.example`. Copy the example when you want local port or `dev/production-like/.env.example`. Copy the example when you want local port or
password changes: password changes:
@@ -44,6 +55,6 @@ GOVOPLAN_STOP_PROFILE_DEPENDENCIES_ON_EXIT=1 scripts/launch-production-like-dev.
To remove all profile data: To remove all profile data:
```bash ```bash
cd /mnt/DATA/git/govoplan-core/dev/production-like cd /mnt/DATA/git/govoplan-core
docker compose --env-file .env.example down -v scripts/production-like-dev.sh reset --yes
``` ```

View File

@@ -7,6 +7,30 @@ files.
## Runtime Configuration Contract ## 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 ### Required Runtime Identity
| Setting | Required outside dev | Purpose | | Setting | Required outside dev | Purpose |
@@ -231,6 +255,16 @@ cd /mnt/DATA/git/govoplan-core
scripts/launch-production-like-dev.sh 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 The launcher uses `dev/production-like/.env` when present, otherwise the checked
in `.env.example`. It runs: in `.env.example`. It runs:

View File

@@ -21,6 +21,7 @@ operator, and roadmap pages.
| Topic | Canonical document | Notes | | Topic | Canonical document | Notes |
| --- | --- | --- | | --- | --- | --- |
| Runtime configuration and operator flow | `DEPLOYMENT_OPERATOR_GUIDE.md` | Production/staging configuration, migrations, backups, installer operation, and rollback drill. | | 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. | | 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. | | 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. | | Remote WebUI bundle design | `REMOTE_WEBUI_BUNDLES.md` | Experimental controlled-deployment design; normal releases still use package builds. |

View File

@@ -80,6 +80,7 @@ The following contracts are the baseline API that modules can rely on:
- `ModuleManifest` - `ModuleManifest`
- `ModuleCompatibility` - `ModuleCompatibility`
- named interface contract provider/requirement metadata
- module uninstall guard provider contract - module uninstall guard provider contract
- `MigrationSpec` - `MigrationSpec`
- route factory contract - 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/tenant ORM models when they need labels, group membership, default
access provisioning, counts, audit actor labels, or tenant metadata. 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 FastAPI route dependencies for authenticated endpoints are imported from the
core `govoplan_core.auth` facade. Routers may import that public API for core `govoplan_core.auth` facade. Routers may import that public API for
`ApiPrincipal`, `get_api_principal`, `has_scope`, `require_scope`, and `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 - pinned backend and WebUI package refs for official catalog entries
- compatibility metadata in the module manifest - 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 - lifecycle hooks when a runtime enable/disable action needs module-specific
work work
- uninstall guards for persistent data, active workers, schedulers, or external - 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 - access source may not import files/mail/campaign internals
- feature modules may not import access implementation internals - feature modules may not import access implementation internals
- feature modules may not add new direct imports of sibling feature modules - 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 - FastAPI routers import the core `govoplan_core.auth` dependency facade
- the transitional allowlist is expected to stay empty - the transitional allowlist is expected to stay empty

View File

@@ -8,7 +8,7 @@ consistent while each module still owns its domain rules.
| Policy area | Current owner | Runtime surface | Notes | | 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. | | 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. | | 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. | | 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 policy's `allow_lower_level_limits`; clients can use it to disable local
controls before attempting a write. 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 ## Frontend Contract
Policy UIs must: Policy UIs must:

View File

@@ -107,6 +107,15 @@ cd /mnt/DATA/git/govoplan-core
scripts/push-release-tag.sh --version 0.1.6 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<manifest.version>`, 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 The script also includes GovOPlaN roadmap/scaffold module repositories that do
not yet have package metadata. Those repositories are committed, tagged, and 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 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 - WebUI package name and pinned install reference
- display metadata and tags - display metadata and tags
- `license_features`, the feature entitlements required to plan that install - `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 The signature is Ed25519 over canonical JSON with both `signature` and
`signatures` removed. Core accepts the legacy single `signature` field and the `signatures` removed. Core accepts the legacy single `signature` field and the
new `signatures` array. 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: Trusted catalog keys are configured locally:

View File

@@ -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.

View File

@@ -15,6 +15,20 @@
"python_ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.4", "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_package": "@govoplan/files-webui",
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4", "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": { "artifact_integrity": {
"python": { "python": {
"ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.4", "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", "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_package": "@govoplan/mail-webui",
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4", "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": { "artifact_integrity": {
"python": { "python": {
"ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.4", "ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.4",

View File

@@ -28,6 +28,7 @@ where = ["src"]
govoplan_core = ["py.typed"] govoplan_core = ["py.typed"]
[project.scripts] [project.scripts]
govoplan-config = "govoplan_core.commands.config:main"
govoplan-devserver = "govoplan_core.devserver:main" govoplan-devserver = "govoplan_core.devserver:main"
govoplan-module-install-plan = "govoplan_core.commands.module_install_plan:main" govoplan-module-install-plan = "govoplan_core.commands.module_install_plan:main"
govoplan-module-installer = "govoplan_core.commands.module_installer:main" govoplan-module-installer = "govoplan_core.commands.module_installer:main"

View File

@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
import ast import ast
import json
import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -36,6 +38,23 @@ PREFIXES = {
} }
FEATURE_OWNERS = ("files", "mail", "campaign") FEATURE_OWNERS = ("files", "mail", "campaign")
PLATFORM_OWNERS = ("admin", "tenancy", "organizations", "identity", "policy", "audit", "dashboard") 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<package>@govoplan/[a-z0-9_-]+-webui)(?:/[A-Za-z0-9_./-]+)?[\"']"
r"|import\s+[\"'](?P<side_effect_package>@govoplan/[a-z0-9_-]+-webui)(?:/[A-Za-z0-9_./-]+)?[\"']"
)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -54,6 +73,7 @@ ALLOWLIST_KEYS = {(item.owner, item.relative_path, item.imported_owner) for item
@dataclass(frozen=True) @dataclass(frozen=True)
class Violation: class Violation:
kind: str
owner: str owner: str
path: Path path: Path
lineno: int lineno: int
@@ -102,17 +122,97 @@ def _violations() -> list[Violation]:
continue continue
if owner == "core" and imported_owner in FEATURE_OWNERS: if owner == "core" and imported_owner in FEATURE_OWNERS:
if not _allowed(owner, path, imported_owner): 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: 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": elif owner in PLATFORM_OWNERS and imported_owner == "access":
if not _allowed(owner, path, imported_owner): 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: elif owner in FEATURE_OWNERS and imported_owner in FEATURE_OWNERS:
if not _allowed(owner, path, imported_owner): 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": 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 return violations
@@ -125,7 +225,7 @@ def main() -> int:
print("Dependency boundary violations:") print("Dependency boundary violations:")
for item in violations: for item in violations:
print( 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" f"({item.imported_owner}); use kernel capability/API/event contracts instead"
) )
print("\nIf this is unavoidable transitional debt, add a reasoned ALLOWLIST entry.") print("\nIf this is unavoidable transitional debt, add a reasoned ALLOWLIST entry.")

View File

@@ -9,11 +9,17 @@ from dataclasses import dataclass
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
import json import json
from pathlib import Path from pathlib import Path
import sys
from typing import Any from typing import Any
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey 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" GITEA_BASE = "git+ssh://git@git.add-ideas.de/add-ideas"
@@ -27,6 +33,8 @@ class CatalogModule:
description: str description: str
tags: tuple[str, ...] tags: tuple[str, ...]
webui_package: str | None = None webui_package: str | None = None
provides_interfaces: tuple[dict[str, object], ...] = ()
requires_interfaces: tuple[dict[str, object], ...] = ()
CATALOG_MODULES = ( CATALOG_MODULES = (
@@ -225,22 +233,32 @@ def _catalog_payload(
repository_base: str, repository_base: str,
public_base_url: str, public_base_url: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
manifests = _discovered_catalog_manifests()
modules: list[dict[str, Any]] = [] modules: list[dict[str, Any]] = []
for module in CATALOG_MODULES: 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] = { entry: dict[str, Any] = {
"module_id": module.module_id, "module_id": module.module_id,
"name": module.name, "name": module.name,
"description": module.description, "description": module.description,
"version": version, "version": module_version,
"action": "install", "action": "install",
"python_package": module.python_package, "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}"], "license_features": [f"module.{module.module_id}"],
"tags": list(module.tags), "tags": list(module.tags),
} }
if module.webui_package: if module.webui_package:
entry["webui_package"] = 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) modules.append(entry)
return { 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]: def _parse_signing_key(value: str) -> tuple[str, Ed25519PrivateKey]:
key_id, separator, path_text = value.partition("=") key_id, separator, path_text = value.partition("=")
if not separator or not key_id.strip() or not path_text.strip(): if not separator or not key_id.strip() or not path_text.strip():

View File

@@ -45,7 +45,8 @@ set -a
set +a set +a
export APP_ENV="${APP_ENV:-staging}" 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 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 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 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 export MASTER_KEY_B64
fi fi
"$PYTHON" -m govoplan_core.commands.config validate --profile production-like
compose() { compose() {
"$DOCKER" compose --env-file "$PROFILE_ENV" -f "$PROFILE_ROOT/docker-compose.yml" "$@" "$DOCKER" compose --env-file "$PROFILE_ENV" -f "$PROFILE_ROOT/docker-compose.yml" "$@"
} }

View File

@@ -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 <command> [--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

View File

@@ -58,6 +58,7 @@ Repos:
govoplan-tenancy govoplan-tenancy
govoplan-organizations govoplan-organizations
govoplan-identity govoplan-identity
govoplan-idm
govoplan-policy govoplan-policy
govoplan-audit govoplan-audit
govoplan-dashboard govoplan-dashboard
@@ -70,7 +71,7 @@ Repos:
Roadmap/scaffold module repositories are tag-only until they have package Roadmap/scaffold module repositories are tag-only until they have package
metadata: addresses, appointments, cases, connectors, dms, erp, 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, portal, reporting, scheduling, search, tasks, templates, workflow, xoev,
xrechnung, and xta-osci. xrechnung, and xta-osci.
@@ -94,6 +95,7 @@ PACKAGE_MODULE_REPOS=(
"$PARENT/govoplan-tenancy" "$PARENT/govoplan-tenancy"
"$PARENT/govoplan-organizations" "$PARENT/govoplan-organizations"
"$PARENT/govoplan-identity" "$PARENT/govoplan-identity"
"$PARENT/govoplan-idm"
"$PARENT/govoplan-policy" "$PARENT/govoplan-policy"
"$PARENT/govoplan-audit" "$PARENT/govoplan-audit"
"$PARENT/govoplan-dashboard" "$PARENT/govoplan-dashboard"
@@ -113,7 +115,6 @@ TAG_ONLY_MODULE_REPOS=(
"$PARENT/govoplan-fit-connect" "$PARENT/govoplan-fit-connect"
"$PARENT/govoplan-forms" "$PARENT/govoplan-forms"
"$PARENT/govoplan-identity-trust" "$PARENT/govoplan-identity-trust"
"$PARENT/govoplan-idm"
"$PARENT/govoplan-ledger" "$PARENT/govoplan-ledger"
"$PARENT/govoplan-notifications" "$PARENT/govoplan-notifications"
"$PARENT/govoplan-payments" "$PARENT/govoplan-payments"
@@ -301,6 +302,9 @@ update_manifest_version() {
govoplan-identity) govoplan-identity)
manifest_path="$repo/src/govoplan_identity/backend/manifest.py" manifest_path="$repo/src/govoplan_identity/backend/manifest.py"
;; ;;
govoplan-idm)
manifest_path="$repo/src/govoplan_idm/backend/manifest.py"
;;
govoplan-policy) govoplan-policy)
manifest_path="$repo/src/govoplan_policy/backend/manifest.py" manifest_path="$repo/src/govoplan_policy/backend/manifest.py"
;; ;;

View File

@@ -1,12 +1,14 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from uuid import uuid4
from typing import Any from typing import Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.change_sequence import record_change 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 ( from govoplan_core.core.events import (
EventActorRef, EventActorRef,
EventObjectRef, EventObjectRef,
@@ -16,8 +18,8 @@ from govoplan_core.core.events import (
normalize_trace_id, normalize_trace_id,
publish_platform_event, publish_platform_event,
) )
from govoplan_core.core.runtime import get_registry
from govoplan_core.privacy.retention import sanitize_audit_details_for_policy from govoplan_core.privacy.retention import sanitize_audit_details_for_policy
from govoplan_audit.backend.db.models import AuditLog
AUDIT_MODULE_ID = "audit" AUDIT_MODULE_ID = "audit"
@@ -47,6 +49,7 @@ _ACTION_MODULE_PREFIXES = {
"configuration_change": "access", "configuration_change": "access",
"configuration_package": "access", "configuration_package": "access",
"group": "access", "group": "access",
"idm": "idm",
"profile": "access", "profile": "access",
"role": "access", "role": "access",
"system_access": "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) 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) trace = _compact_trace(item.details.get("_trace") if isinstance(item.details, Mapping) else None)
publish_platform_event( publish_platform_event(
PlatformEvent( PlatformEvent(
@@ -206,7 +235,7 @@ def audit_event(
correlation_id: str | None = None, correlation_id: str | None = None,
causation_id: str | None = None, causation_id: str | None = None,
commit: bool = False, commit: bool = False,
) -> AuditLog: ) -> AuditRecordRef:
"""Persist one audit event. """Persist one audit event.
The function deliberately accepts primitive IDs so it can be used from API The function deliberately accepts primitive IDs so it can be used from API
@@ -227,18 +256,17 @@ def audit_event(
trace, trace,
) )
item = AuditLog( item = _audit_recorder().record_event(session, AuditEvent(
event_type=action,
action=action,
scope=scope, scope=scope,
tenant_id=tenant_id, tenant_id=tenant_id,
user_id=user_id, user_id=user_id,
api_key_id=api_key_id, api_key_id=api_key_id,
action=action, resource_type=object_type,
object_type=object_type, resource_id=object_id,
object_id=object_id,
details=stored_details, details=stored_details,
) ))
session.add(item)
session.flush()
record_change( record_change(
session, session,
module_id=AUDIT_MODULE_ID, module_id=AUDIT_MODULE_ID,
@@ -269,7 +297,7 @@ def audit_from_principal(
correlation_id: str | None = None, correlation_id: str | None = None,
causation_id: str | None = None, causation_id: str | None = None,
commit: bool = False, commit: bool = False,
) -> AuditLog: ) -> AuditRecordRef:
return audit_event( return audit_event(
session, session,
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,

View File

@@ -3,11 +3,167 @@ from __future__ import annotations
"""Core auth dependency facade. """Core auth dependency facade.
Routers depend on this module instead of a concrete access-provider package. Routers depend on this module instead of a concrete access-provider package.
The current implementation delegates to the access module; replacing this The active auth module provides the request principal through the platform
facade is the remaining step toward a fully provider-neutral auth kernel. 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__ = [ __all__ = [
"ApiPrincipal", "ApiPrincipal",

View File

@@ -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())

View File

@@ -1,6 +1,6 @@
from __future__ import annotations 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 dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from typing import Literal, Protocol, cast, runtime_checkable from typing import Literal, Protocol, cast, runtime_checkable
@@ -24,9 +24,13 @@ CAPABILITY_ACCESS_ADMINISTRATION = "access.administration"
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER = "access.governanceMaterializer" CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER = "access.governanceMaterializer"
CAPABILITY_TENANCY_TENANT_RESOLVER = "tenancy.tenantResolver" CAPABILITY_TENANCY_TENANT_RESOLVER = "tenancy.tenantResolver"
CAPABILITY_AUDIT_SINK = "audit.sink" 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_PRINCIPAL_RESOLVER = "auth.principalResolver"
CAPABILITY_AUTH_PERMISSION_EVALUATOR = "auth.permissionEvaluator" CAPABILITY_AUTH_PERMISSION_EVALUATOR = "auth.permissionEvaluator"
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER = "auth.tenantContextSwitcher"
ACCESS_CAPABILITY_NAMES = frozenset( ACCESS_CAPABILITY_NAMES = frozenset(
{ {
@@ -41,15 +45,21 @@ ACCESS_CAPABILITY_NAMES = frozenset(
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER, CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
CAPABILITY_TENANCY_TENANT_RESOLVER, CAPABILITY_TENANCY_TENANT_RESOLVER,
CAPABILITY_AUDIT_SINK, CAPABILITY_AUDIT_SINK,
CAPABILITY_AUDIT_RECORDER,
CAPABILITY_AUDIT_RETENTION,
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
} }
) )
AUTH_CAPABILITY_NAMES = frozenset( AUTH_CAPABILITY_NAMES = frozenset(
{ {
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
} }
) )
@@ -302,6 +312,26 @@ class TenantOwnerCandidateRef:
display_name: str | None = None 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) @dataclass(frozen=True, slots=True)
class GovernanceTemplateMaterialization: class GovernanceTemplateMaterialization:
template_id: str template_id: str
@@ -321,7 +351,10 @@ class AuditEvent:
action: str action: str
outcome: AuditOutcome = "unknown" outcome: AuditOutcome = "unknown"
actor: PrincipalRef | None = None actor: PrincipalRef | None = None
scope: Literal["tenant", "system"] = "tenant"
tenant_id: str | None = None tenant_id: str | None = None
user_id: str | None = None
api_key_id: str | None = None
resource_type: str | None = None resource_type: str | None = None
resource_id: str | None = None resource_id: str | None = None
occurred_at: datetime | None = None occurred_at: datetime | None = None
@@ -330,12 +363,45 @@ class AuditEvent:
details: Mapping[str, object] = field(default_factory=dict) 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 @runtime_checkable
class PrincipalResolver(Protocol): class PrincipalResolver(Protocol):
def resolve_request(self, request: object, *, session: object | None = None) -> PrincipalRef: 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 @runtime_checkable
class TenantResolver(Protocol): class TenantResolver(Protocol):
def get_tenant(self, tenant_id: str) -> TenantRef | None: def get_tenant(self, tenant_id: str) -> TenantRef | None:
@@ -467,9 +533,24 @@ class TenantAccessProvisioner(Protocol):
) -> str: ) -> 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 @runtime_checkable
class AccessAdministration(Protocol): class AccessAdministration(Protocol):
def tenant_counts(self, session: object, tenant_id: str) -> Mapping[str, int]:
...
def system_account_count(self, session: object) -> 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_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 @runtime_checkable
class AccessGovernanceMaterializer(Protocol): class AccessGovernanceMaterializer(Protocol):
@@ -499,3 +606,22 @@ class AccessGovernanceMaterializer(Protocol):
class AuditSink(Protocol): class AuditSink(Protocol):
def record(self, event: AuditEvent) -> None: 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]:
...

View File

@@ -205,7 +205,21 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
policy_explanation_required=True, policy_explanation_required=True,
audit_event="organizations.model.updated", audit_event="organizations.model.updated",
rollback_history_required=True, 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( ConfigurationFieldSafety(
key="mail_profiles.credentials", key="mail_profiles.credentials",

View File

@@ -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]:
...

View File

@@ -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 "<generate-with-govoplan-config-env-template-generate-secrets>"
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

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections import defaultdict
from collections.abc import Iterable, Mapping from collections.abc import Iterable, Mapping
from contextlib import AbstractContextManager, closing from contextlib import AbstractContextManager, closing
from dataclasses import dataclass from dataclasses import dataclass
@@ -35,6 +36,7 @@ from govoplan_core.core.module_management import (
save_module_install_plan, save_module_install_plan,
) )
from govoplan_core.core.modules import ModuleManifest, MigrationRetirementPlan 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"] IssueSeverity = Literal["blocker", "warning", "info"]
ChecklistStatus = Literal["done", "pending", "blocked", "warning"] ChecklistStatus = Literal["done", "pending", "blocked", "warning"]
@@ -162,7 +164,9 @@ def module_install_preflight(
if not maintenance_mode: if not maintenance_mode:
issues.append(ModuleInstallerIssue("blocker", "maintenance_required", "Package changes require 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 frontend_rebuild_required = False
for item in planned_items: for item in planned_items:
@@ -965,10 +969,15 @@ def _module_verify_command(plan: ModuleInstallPlan) -> dict[str, Any] | None:
return _structured_command(argv) 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() current_core_version = _current_core_version()
issues: list[ModuleInstallerIssue] = [] issues: list[ModuleInstallerIssue] = []
for manifest in available.values(): manifests = _selected_manifests(available, module_ids)
for manifest in manifests.values():
compatibility = manifest.compatibility compatibility = manifest.compatibility
if compatibility.manifest_contract_version != SUPPORTED_MANIFEST_CONTRACT_VERSION: if compatibility.manifest_contract_version != SUPPORTED_MANIFEST_CONTRACT_VERSION:
issues.append(ModuleInstallerIssue( issues.append(ModuleInstallerIssue(
@@ -980,20 +989,21 @@ def module_manifest_compatibility_issues(available: Mapping[str, ModuleManifest]
), ),
manifest.id, 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( issues.append(ModuleInstallerIssue(
"blocker", "blocker",
"core_version_too_low", "core_version_too_low",
f"Module requires core >= {compatibility.core_version_min}; current core is {current_core_version}.", f"Module requires core >= {compatibility.core_version_min}; current core is {current_core_version}.",
manifest.id, 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( issues.append(ModuleInstallerIssue(
"blocker", "blocker",
"core_version_too_high", "core_version_too_high",
f"Module supports core <= {compatibility.core_version_max}; current core is {current_core_version}.", f"Module supports core <= {compatibility.core_version_max}; current core is {current_core_version}.",
manifest.id, manifest.id,
)) ))
issues.extend(_interface_contract_issues(manifests))
return tuple(issues) return tuple(issues)
@@ -1013,24 +1023,210 @@ def _current_core_version() -> str:
return "0.0.0" return "0.0.0"
def _compare_versions(left: str, right: str) -> int: def _selected_manifests(
left_parts = _version_tuple(left) available: Mapping[str, ModuleManifest],
right_parts = _version_tuple(right) module_ids: Iterable[str] | None,
max_length = max(len(left_parts), len(right_parts)) ) -> dict[str, ModuleManifest]:
padded_left = left_parts + (0,) * (max_length - len(left_parts)) if module_ids is None:
padded_right = right_parts + (0,) * (max_length - len(right_parts)) return dict(available)
if padded_left < padded_right: selected: dict[str, ModuleManifest] = {}
return -1 for module_id in module_ids:
if padded_left > padded_right: manifest = available.get(module_id)
return 1 if manifest is not None:
return 0 selected[module_id] = manifest
return selected
def _version_tuple(value: str) -> tuple[int, ...]: def _interface_contract_issues(available: Mapping[str, ModuleManifest]) -> tuple[ModuleInstallerIssue, ...]:
match = re.match(r"^\s*(\d+(?:\.\d+)*)", value) providers: dict[str, list[tuple[str, str]]] = defaultdict(list)
if not match: for manifest in available.values():
return (0,) for provided in manifest.provides_interfaces:
return tuple(int(part) for part in match.group(1).split(".")) 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( def _post_install_checklist(

View File

@@ -21,6 +21,7 @@ REQUIRED_PLATFORM_MODULES = ("access",)
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin") PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin")
INSTALL_PLAN_ACTIONS = ("install", "uninstall") INSTALL_PLAN_ACTIONS = ("install", "uninstall")
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked") INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
INSTALL_PLAN_SOURCES = ("manual", "catalog")
LOCAL_DEPENDENCY_REF_PREFIXES = ("file:", "path:", "workspace:", "link:") LOCAL_DEPENDENCY_REF_PREFIXES = ("file:", "path:", "workspace:", "link:")
@@ -38,6 +39,8 @@ class ModuleStatePlan:
class ModuleInstallPlanItem: class ModuleInstallPlanItem:
module_id: str module_id: str
action: str action: str
source: str = "manual"
catalog: Mapping[str, object] | None = None
python_package: str | None = None python_package: str | None = None
python_ref: str | None = None python_ref: str | None = None
webui_package: str | None = None webui_package: str | None = None
@@ -51,10 +54,13 @@ class ModuleInstallPlanItem:
payload: dict[str, object] = { payload: dict[str, object] = {
"module_id": self.module_id, "module_id": self.module_id,
"action": self.action, "action": self.action,
"source": self.source,
"status": self.status, "status": self.status,
} }
if self.destroy_data: if self.destroy_data:
payload["destroy_data"] = True payload["destroy_data"] = True
if self.catalog:
payload["catalog"] = dict(self.catalog)
for key in ("python_package", "python_ref", "webui_package", "webui_ref", "notes"): for key in ("python_package", "python_ref", "webui_package", "webui_ref", "notes"):
value = getattr(self, key) value = getattr(self, key)
if value: if value:
@@ -291,6 +297,8 @@ def normalize_module_install_plan_item(
module_id = _required_string(raw.get("module_id"), "module_id") module_id = _required_string(raw.get("module_id"), "module_id")
action = _required_string(raw.get("action"), "action") 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" status = _clean_optional_string(raw.get("status")) or "planned"
python_package = _clean_optional_string(raw.get("python_package")) python_package = _clean_optional_string(raw.get("python_package"))
python_ref = _clean_optional_string(raw.get("python_ref")) 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}.") raise ModuleManagementError(f"Unsupported install plan action for {module_id!r}: {action!r}.")
if status not in INSTALL_PLAN_STATUSES: if status not in INSTALL_PLAN_STATUSES:
raise ModuleManagementError(f"Unsupported install plan status for {module_id!r}: {status!r}.") 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: if action == "install" and not python_ref:
raise ModuleManagementError(f"Install plan item {module_id!r} needs a Python package reference.") raise ModuleManagementError(f"Install plan item {module_id!r} needs a Python package reference.")
if action == "uninstall" and not python_package: if action == "uninstall" and not python_package:
@@ -322,6 +332,8 @@ def normalize_module_install_plan_item(
return ModuleInstallPlanItem( return ModuleInstallPlanItem(
module_id=module_id, module_id=module_id,
action=action, action=action,
source=source,
catalog=catalog,
python_package=python_package, python_package=python_package,
python_ref=python_ref, python_ref=python_ref,
webui_package=webui_package, webui_package=webui_package,

View File

@@ -2,10 +2,12 @@ from __future__ import annotations
import base64 import base64
import binascii import binascii
from collections import defaultdict
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
import json import json
import os import os
import re
from typing import Any from typing import Any
import urllib.error import urllib.error
import urllib.request import urllib.request
@@ -14,6 +16,10 @@ from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey 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( def module_package_catalog(
path: Path | str | None = None, 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 freshness.get("warnings", ()) if item)
warnings.extend(str(item) for item in replay.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"]: if not signature_state["signed"]:
warnings.append("Catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.") warnings.append("Catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
elif not signature_state["trusted"]: elif not signature_state["trusted"]:
@@ -303,7 +310,10 @@ def _configured_enforce_sequence() -> bool:
def _configured_require_signature() -> 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, ...]: 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_package": _optional_str(value, "webui_package"),
"webui_ref": _optional_str(value, "webui_ref"), "webui_ref": _optional_str(value, "webui_ref"),
"license_features": _string_list(value.get("license_features")), "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"), "notes": _optional_str(value, "notes"),
"tags": _string_list(value.get("tags")), "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()] 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]: def _normalize_artifact_integrity(value: Any) -> dict[str, object]:
if value is None: if value is None:
return {} return {}

View File

@@ -108,6 +108,20 @@ class ModuleCompatibility:
manifest_contract_version: str = "1" 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) @dataclass(frozen=True, slots=True)
class ModuleUninstallGuardResult: class ModuleUninstallGuardResult:
severity: Literal["blocker", "warning", "info"] severity: Literal["blocker", "warning", "info"]
@@ -217,6 +231,8 @@ class ModuleManifest:
optional_dependencies: tuple[str, ...] = () optional_dependencies: tuple[str, ...] = ()
required_capabilities: tuple[str, ...] = () required_capabilities: tuple[str, ...] = ()
optional_capabilities: tuple[str, ...] = () optional_capabilities: tuple[str, ...] = ()
provides_interfaces: tuple[ModuleInterfaceProvider, ...] = ()
requires_interfaces: tuple[ModuleInterfaceRequirement, ...] = ()
permissions: tuple[PermissionDefinition, ...] = () permissions: tuple[PermissionDefinition, ...] = ()
role_templates: tuple[RoleTemplate, ...] = () role_templates: tuple[RoleTemplate, ...] = ()
route_factory: RouteFactory | None = None route_factory: RouteFactory | None = None

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable from typing import Literal, Protocol, runtime_checkable
@@ -10,7 +9,6 @@ ORGANIZATIONS_MODULE_ID = "organizations"
CAPABILITY_ORGANIZATION_DIRECTORY = "organizations.directory" CAPABILITY_ORGANIZATION_DIRECTORY = "organizations.directory"
OrganizationStatus = Literal["active", "inactive", "suspended"] OrganizationStatus = Literal["active", "inactive", "suspended"]
FunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -39,23 +37,6 @@ class OrganizationFunctionRef:
status: OrganizationStatus = "active" 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 @runtime_checkable
class OrganizationDirectory(Protocol): class OrganizationDirectory(Protocol):
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None: def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
@@ -74,22 +55,3 @@ class OrganizationDirectory(Protocol):
include_subunits: bool = False, include_subunits: bool = False,
) -> Sequence[OrganizationFunctionRef]: ) -> 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]:
...

View File

@@ -1,11 +1,13 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field 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 from urllib.parse import quote, unquote
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"] PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
CAPABILITY_POLICY_PRIVACY_RETENTION = "policy.privacyRetention"
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign") POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign")
@@ -122,3 +124,39 @@ class PolicyDecision:
"requirements": list(self.requirements), "requirements": list(self.requirements),
"details": dict(self.details), "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:
...

View File

@@ -8,6 +8,8 @@ from dataclasses import dataclass
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
CapabilityFactory, CapabilityFactory,
DeleteVetoProvider, DeleteVetoProvider,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleContext, ModuleContext,
ModuleManifest, ModuleManifest,
NavItem, NavItem,
@@ -18,11 +20,13 @@ from govoplan_core.core.modules import (
SUPPORTED_MANIFEST_CONTRACT_VERSION, SUPPORTED_MANIFEST_CONTRACT_VERSION,
TenantSummaryProvider, 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_]*$") _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_.-]*$") _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_]*$") _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_]*:\*$") _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): class RegistryError(ValueError):
@@ -177,6 +181,8 @@ class PlatformRegistry:
raise RegistryError(f"Duplicate permission scope: {permission.scope}") raise RegistryError(f"Duplicate permission scope: {permission.scope}")
seen_permissions[permission.scope] = permission seen_permissions[permission.scope] = permission
_validate_interface_closure(ordered)
known_scopes = set(seen_permissions) known_scopes = set(seen_permissions)
for manifest in ordered: for manifest in ordered:
for template in manifest.role_templates: 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_dependency_list(manifest.id, "optional_dependencies", manifest.optional_dependencies)
_validate_capability_list(manifest.id, "required_capabilities", manifest.required_capabilities) _validate_capability_list(manifest.id, "required_capabilities", manifest.required_capabilities)
_validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_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) overlap = set(manifest.dependencies) & set(manifest.optional_dependencies)
if overlap: if overlap:
joined = ", ".join(sorted(overlap)) joined = ", ".join(sorted(overlap))
@@ -275,6 +283,47 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
_validate_nav_item(manifest.id, item) _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: def _validate_dependency_list(module_id: str, field_name: str, dependencies: tuple[str, ...]) -> None:
if len(dependencies) != len(set(dependencies)): if len(dependencies) != len(set(dependencies)):
raise RegistryError(f"Module {module_id!r} has duplicate {field_name}") 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}") 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: def _validate_nav_item(module_id: str, item: NavItem) -> None:
if not item.path.startswith("/"): if not item.path.startswith("/"):
raise RegistryError(f"Navigation item for module {module_id!r} must start with '/': {item.path!r}") raise RegistryError(f"Navigation item for module {module_id!r} must start with '/': {item.path!r}")

View File

@@ -10,10 +10,14 @@ def configure_runtime(context: ModuleContext) -> None:
_context = context _context = context
def clear_runtime() -> None:
global _context
_context = None
def get_runtime_context() -> ModuleContext | None: def get_runtime_context() -> ModuleContext | None:
return _context return _context
def get_registry() -> object | None: def get_registry() -> object | None:
return _context.registry if _context is not None else None return _context.registry if _context is not None else None

View File

@@ -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("."))

View File

@@ -4,15 +4,17 @@ from dataclasses import dataclass
from sqlalchemy.orm import Session 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.base import Base
from govoplan_core.db.session import get_database 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.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.security.permissions import TENANT_SCOPES from govoplan_core.security.permissions import TENANT_SCOPES
from govoplan_core.server.registry import available_module_manifests, build_platform_registry from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.settings import settings from govoplan_core.settings import settings
from govoplan_access.backend.admin.service import ensure_default_roles, get_or_create_account from govoplan_core.tenancy.scope import Tenant
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 create_scope_tables from govoplan_core.tenancy.scope import create_scope_tables
DEFAULT_SCOPES = sorted(TENANT_SCOPES) DEFAULT_SCOPES = sorted(TENANT_SCOPES)
@@ -21,8 +23,8 @@ DEFAULT_SCOPES = sorted(TENANT_SCOPES)
@dataclass(slots=True) @dataclass(slots=True)
class BootstrapResult: class BootstrapResult:
tenant: Tenant tenant: Tenant
user: User user: UserRef
created_api_key: CreatedApiKey | None created_api_key: CreatedApiKeyRef | None
def create_all_tables() -> None: def create_all_tables() -> None:
@@ -42,6 +44,21 @@ def create_all_tables() -> None:
Base.metadata.create_all(bind=engine) 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( def bootstrap_dev_data(
session: Session, session: Session,
*, *,
@@ -56,72 +73,16 @@ def bootstrap_dev_data(
session.add(tenant) session.add(tenant)
session.flush() session.flush()
tenant_roles = ensure_default_roles(session, tenant) try:
system_roles = ensure_default_roles(session, None) result = _access_provisioner().ensure_development_admin(
session,
account, _, _ = get_or_create_account( tenant=tenant,
session, user_email=user_email,
email=user_email, user_password=user_password,
display_name="Development Admin", api_key_secret=api_key_secret,
password=user_password, scopes=DEFAULT_SCOPES,
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,
) )
session.add(user) except AdminValidationError as exc:
session.flush() raise RuntimeError(str(exc)) from exc
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,
)
session.commit() 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)

View File

@@ -1,29 +1,18 @@
from __future__ import annotations from __future__ import annotations
import hashlib """Compatibility facade for privacy retention policy.
import json
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator Policy-owned behavior is provided by ``govoplan-policy`` through the
from sqlalchemy.orm import Session ``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 dataclasses import dataclass
from govoplan_core.core.campaigns import ( from typing import Any, Mapping
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT,
CAPABILITY_CAMPAIGNS_RETENTION, from govoplan_core.core.policy import CAPABILITY_POLICY_PRIVACY_RETENTION, PrivacyRetentionService
CampaignPolicyContext,
CampaignPolicyContextProvider,
CampaignRetentionProvider,
)
from govoplan_core.core.policy import policy_source_step
from govoplan_core.core.runtime import get_registry 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" PRIVACY_POLICY_SETTINGS_KEY = "privacy_retention_policy"
RETENTION_DAY_KEYS = ( RETENTION_DAY_KEYS = (
@@ -38,17 +27,109 @@ RETENTION_POLICY_FIELD_KEYS = (
*RETENTION_DAY_KEYS, *RETENTION_DAY_KEYS,
"audit_detail_level", "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]: class PrivacyPolicyError(RuntimeError):
return {key: True for key in RETENTION_POLICY_FIELD_KEYS} 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: def _normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
if value in (None, ""): if value in (None, ""):
return default_allow_lower_level_limits() if fill_defaults else 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") raise ValueError("allow_lower_level_limits must be an object")
normalized = default_allow_lower_level_limits() if fill_defaults else {} normalized = default_allow_lower_level_limits() if fill_defaults else {}
for key, allowed in value.items(): 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) normalized[clean_key] = bool(allowed)
return normalized 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 def privacy_policy_from_settings(item: object) -> Any:
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0) service = _runtime_service()
generated_eml_retention_days: int | None = Field(default=None, ge=0) if service is not None:
stored_report_detail_retention_days: int | None = Field(default=None, ge=0) return service.privacy_policy_from_settings(item)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0) return _SystemPrivacyPolicy(_policy_data(_settings_payload(item)))
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] = "full"
allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits)
@field_validator(
"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") def privacy_policy_from_session(*args: Any, **kwargs: Any) -> Any:
@classmethod return _service().privacy_policy_from_session(*args, **kwargs)
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return _normalize_allow_lower_level_limits(value, fill_defaults=True)
class PrivacyRetentionPolicyPatch(BaseModel): def set_privacy_policy(item: object, policy: Any) -> Any:
model_config = ConfigDict(extra="forbid") 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") def effective_privacy_policy(*args: Any, **kwargs: Any) -> Any:
@classmethod return _service().effective_privacy_policy(*args, **kwargs)
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=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()
__all__ = list(_EXPORTS)
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] = "<redacted>"
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

View File

@@ -6,8 +6,8 @@ from sqlalchemy.orm import Session
from govoplan_core.admin.common import AdminValidationError from govoplan_core.admin.common import AdminValidationError
from govoplan_core.admin.settings import get_system_settings 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_core.core.runtime import get_registry
from govoplan_access.backend.db.models import ApiKey, Group, User
from govoplan_core.tenancy.scope import Tenant 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 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]: def tenant_counts(session: Session, tenant_id: str) -> dict[str, int]:
module_counts = _tenant_module_counts(session, tenant_id) 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 { return {
"users": session.query(User).filter(User.tenant_id == tenant_id).count(), "users": int(access_counts.get("users", 0)),
"active_users": session.query(User).filter(User.tenant_id == tenant_id, User.is_active.is_(True)).count(), "active_users": int(access_counts.get("active_users", 0)),
"groups": session.query(Group).filter(Group.tenant_id == tenant_id).count(), "groups": int(access_counts.get("groups", 0)),
"campaigns": module_counts.get("campaigns", 0), "campaigns": module_counts.get("campaigns", 0),
"files": module_counts.get("files", 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))),
} }

View File

@@ -22,6 +22,8 @@ from govoplan_core.core.access import (
CAPABILITY_ACCESS_EXPLANATION, CAPABILITY_ACCESS_EXPLANATION,
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY, CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
CAPABILITY_ACCESS_TENANT_PROVISIONER, CAPABILITY_ACCESS_TENANT_PROVISIONER,
CAPABILITY_AUDIT_RECORDER,
CAPABILITY_AUDIT_RETENTION,
CAPABILITY_AUDIT_SINK, CAPABILITY_AUDIT_SINK,
CAPABILITY_TENANCY_TENANT_RESOLVER, CAPABILITY_TENANCY_TENANT_RESOLVER,
AccessDecision, AccessDecision,
@@ -34,7 +36,12 @@ from govoplan_core.core.access import (
AccessSubjectRef, AccessSubjectRef,
AccountRef, AccountRef,
AuditEvent, AuditEvent,
AuditRecordRef,
AuditRecorder,
AuditRetentionProvider,
AuditSink, AuditSink,
CreatedApiKeyRef,
DevelopmentBootstrapRef,
FunctionAssignmentRef, FunctionAssignmentRef,
FunctionDelegationRef, FunctionDelegationRef,
FunctionRef, FunctionRef,
@@ -69,7 +76,9 @@ from govoplan_core.core.campaigns import (
CampaignRetentionProvider, CampaignRetentionProvider,
) )
from govoplan_core.core.modules import ModuleContext, ModuleManifest 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.registry import PlatformRegistry
from govoplan_core.core.runtime import clear_runtime, configure_runtime
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.server.app import create_app from govoplan_core.server.app import create_app
from govoplan_core.server.config import GovoplanServerConfig from govoplan_core.server.config import GovoplanServerConfig
@@ -265,8 +274,19 @@ class _FakeTenantAccessProvisioner:
del session, tenant, owner_account_id del session, tenant, owner_account_id
return "user-1" 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: 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: def system_account_count(self, session: object) -> int:
del session del session
return 1 return 1
@@ -287,6 +307,22 @@ class _FakeAccessAdministration:
del session, operator, value del session, operator, value
return ("user-1",) 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: class _FakeAccessGovernanceMaterializer:
def __init__(self) -> None: def __init__(self) -> None:
@@ -408,6 +444,28 @@ class _FakeAuditSink:
self.events.append(event) 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): class AccessContractTests(unittest.TestCase):
def test_access_capability_names_are_stable(self) -> None: def test_access_capability_names_are_stable(self) -> None:
self.assertEqual("access", ACCESS_MODULE_ID) self.assertEqual("access", ACCESS_MODULE_ID)
@@ -428,6 +486,8 @@ class AccessContractTests(unittest.TestCase):
self.assertEqual("tenancy.tenantResolver", CAPABILITY_TENANCY_TENANT_RESOLVER) self.assertEqual("tenancy.tenantResolver", CAPABILITY_TENANCY_TENANT_RESOLVER)
self.assertEqual("security.secretProvider", CAPABILITY_SECURITY_SECRET_PROVIDER) self.assertEqual("security.secretProvider", CAPABILITY_SECURITY_SECRET_PROVIDER)
self.assertEqual("audit.sink", CAPABILITY_AUDIT_SINK) 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))) self.assertEqual(len(ACCESS_CAPABILITY_NAMES), len(set(ACCESS_CAPABILITY_NAMES)))
def test_access_refs_are_small_immutable_dtos(self) -> None: def test_access_refs_are_small_immutable_dtos(self) -> None:
@@ -533,6 +593,8 @@ class AccessContractTests(unittest.TestCase):
self.assertIsInstance(_FakeCampaignRetentionProvider(), CampaignRetentionProvider) self.assertIsInstance(_FakeCampaignRetentionProvider(), CampaignRetentionProvider)
self.assertIsInstance(_FakeSecretProvider(), SecretProvider) self.assertIsInstance(_FakeSecretProvider(), SecretProvider)
self.assertIsInstance(_FakeAuditSink(), AuditSink) self.assertIsInstance(_FakeAuditSink(), AuditSink)
self.assertIsInstance(_FakeAuditRecorder(), AuditRecorder)
self.assertIsInstance(_FakeAuditRetentionProvider(), AuditRetentionProvider)
def test_campaign_mail_policy_context_capability_shape(self) -> None: def test_campaign_mail_policy_context_capability_shape(self) -> None:
provider = _FakeCampaignMailPolicyContextProvider() provider = _FakeCampaignMailPolicyContextProvider()
@@ -843,6 +905,132 @@ class AccessContractTests(unittest.TestCase):
finally: finally:
shutil.rmtree(root, ignore_errors=True) 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: def test_api_principal_dependency_uses_access_resolver_capability(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-access-contract-")) root = Path(tempfile.mkdtemp(prefix="govoplan-access-contract-"))
try: try:

View File

@@ -20,12 +20,22 @@ from govoplan_core.core.events import (
event_context, event_context,
normalize_trace_id, normalize_trace_id,
) )
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.registry import PlatformRegistry 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.db.base import Base
from govoplan_core.server.fastapi import create_govoplan_app 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 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): class CoreEventTests(unittest.TestCase):
def test_event_bus_adds_trace_ids_and_propagates_causation_to_nested_events(self) -> None: def test_event_bus_adds_trace_ids_and_propagates_causation_to_nested_events(self) -> None:
bus = EventBus() bus = EventBus()
@@ -127,6 +137,7 @@ class CoreEventTests(unittest.TestCase):
root = Path(tempfile.mkdtemp(prefix="govoplan-audit-trace-")) root = Path(tempfile.mkdtemp(prefix="govoplan-audit-trace-"))
try: try:
with temporary_database(f"sqlite:///{root / 'audit.db'}") as database: 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_access.backend.db import models as access_models # noqa: F401
from govoplan_admin.backend.db import models as admin_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 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({"correlation_id": "corr-1", "causation_id": "cause-1"}, item.details["_trace"])
self.assertEqual("<redacted>", item.details["password"]) self.assertEqual("<redacted>", item.details["password"])
finally: finally:
clear_runtime()
shutil.rmtree(root, ignore_errors=True) shutil.rmtree(root, ignore_errors=True)
def test_audit_event_publishes_governed_platform_event(self) -> None: def test_audit_event_publishes_governed_platform_event(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-audit-event-")) root = Path(tempfile.mkdtemp(prefix="govoplan-audit-event-"))
try: try:
with temporary_database(f"sqlite:///{root / 'audit.db'}") as database: 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_access.backend.db import models as access_models # noqa: F401
from govoplan_admin.backend.db import models as admin_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 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(item.id, event.payload["audit_log_id"])
self.assertEqual("<redacted>", event.payload["details"]["password"]) self.assertEqual("<redacted>", event.payload["details"]["password"])
finally: finally:
clear_runtime()
shutil.rmtree(root, ignore_errors=True) shutil.rmtree(root, ignore_errors=True)
def test_audit_events_map_existing_module_action_prefixes(self) -> None: def test_audit_events_map_existing_module_action_prefixes(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-audit-module-events-")) root = Path(tempfile.mkdtemp(prefix="govoplan-audit-module-events-"))
try: try:
with temporary_database(f"sqlite:///{root / 'audit.db'}") as database: 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_access.backend.db import models as access_models # noqa: F401
from govoplan_admin.backend.db import models as admin_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 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} by_type = {event.type: event.module_id for event in seen if event.type in cases}
self.assertEqual(cases, by_type) self.assertEqual(cases, by_type)
finally: finally:
clear_runtime()
shutil.rmtree(root, ignore_errors=True) shutil.rmtree(root, ignore_errors=True)
def test_audit_operation_context_keeps_lifecycle_ids_and_sanitizes_details(self) -> None: def test_audit_operation_context_keeps_lifecycle_ids_and_sanitizes_details(self) -> None:

View File

@@ -11,21 +11,26 @@ from govoplan_core.core.identity import (
IdentityDirectory, IdentityDirectory,
IdentityRef, IdentityRef,
) )
from govoplan_core.core.idm import (
CAPABILITY_IDM_DIRECTORY,
IdmDirectory,
OrganizationFunctionAssignmentRef,
)
from govoplan_core.core.organizations import ( from govoplan_core.core.organizations import (
CAPABILITY_ORGANIZATION_DIRECTORY, CAPABILITY_ORGANIZATION_DIRECTORY,
OrganizationDirectory, OrganizationDirectory,
OrganizationFunctionAssignmentRef,
OrganizationFunctionRef, OrganizationFunctionRef,
OrganizationUnitRef, OrganizationUnitRef,
) )
from govoplan_core.core.modules import ModuleContext, ModuleManifest from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.base import Base 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_identity.backend.db.models import Identity, IdentityAccountLink
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment
from govoplan_organizations.backend.db.models import ( from govoplan_organizations.backend.db.models import (
OrganizationFunction, OrganizationFunction,
OrganizationFunctionAssignment,
OrganizationFunctionType, OrganizationFunctionType,
OrganizationRelation, OrganizationRelation,
OrganizationRelationType, OrganizationRelationType,
@@ -73,15 +78,6 @@ class _FakeOrganizationDirectory:
slug="registry-clerk", slug="registry-clerk",
name="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: def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
return self.unit if organization_unit_id == self.unit.id else None return self.unit if organization_unit_id == self.unit.id else None
@@ -96,17 +92,29 @@ class _FakeOrganizationDirectory:
del include_subunits del include_subunits
return (self.function,) if organization_unit_id == self.unit.id else () 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 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: if account_id != self.assignment.account_id:
return () return ()
if tenant_id is not None and tenant_id != self.assignment.tenant_id: if tenant_id is not None and tenant_id != self.assignment.tenant_id:
return () return ()
return (self.assignment,) 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: if identity_id != self.assignment.identity_id:
return () return ()
if tenant_id is not None and tenant_id != self.assignment.tenant_id: 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: def test_protocol_shapes_match_reference_implementations(self) -> None:
self.assertIsInstance(_FakeIdentityDirectory(), IdentityDirectory) self.assertIsInstance(_FakeIdentityDirectory(), IdentityDirectory)
self.assertIsInstance(_FakeOrganizationDirectory(), OrganizationDirectory) self.assertIsInstance(_FakeOrganizationDirectory(), OrganizationDirectory)
self.assertIsInstance(_FakeIdmDirectory(), IdmDirectory)
def test_capabilities_register_and_resolve_through_platform_registry(self) -> None: def test_capabilities_register_and_resolve_through_platform_registry(self) -> None:
identity_directory = _FakeIdentityDirectory() identity_directory = _FakeIdentityDirectory()
organization_directory = _FakeOrganizationDirectory() organization_directory = _FakeOrganizationDirectory()
idm_directory = _FakeIdmDirectory()
manifest = ModuleManifest( manifest = ModuleManifest(
id="directory-contract-test", id="directory-contract-test",
name="Directory Contract Test", name="Directory Contract Test",
@@ -129,6 +139,7 @@ class IdentityOrganizationContractTests(unittest.TestCase):
capability_factories={ capability_factories={
CAPABILITY_IDENTITY_DIRECTORY: lambda context: identity_directory, CAPABILITY_IDENTITY_DIRECTORY: lambda context: identity_directory,
CAPABILITY_ORGANIZATION_DIRECTORY: lambda context: organization_directory, CAPABILITY_ORGANIZATION_DIRECTORY: lambda context: organization_directory,
CAPABILITY_IDM_DIRECTORY: lambda context: idm_directory,
}, },
) )
registry = PlatformRegistry() registry = PlatformRegistry()
@@ -137,6 +148,64 @@ class IdentityOrganizationContractTests(unittest.TestCase):
self.assertIs(identity_directory, registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)) self.assertIs(identity_directory, registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY))
self.assertIs(organization_directory, registry.require_capability(CAPABILITY_ORGANIZATION_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: def test_sql_identity_and_organization_directories_return_dtos(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-directory-contracts-")) root = Path(tempfile.mkdtemp(prefix="govoplan-directory-contracts-"))
@@ -210,7 +279,7 @@ class IdentityOrganizationContractTests(unittest.TestCase):
slug="registry-clerk", slug="registry-clerk",
name="Registry Clerk", name="Registry Clerk",
) )
assignment = OrganizationFunctionAssignment( assignment = IdmOrganizationFunctionAssignment(
id="assignment-directory", id="assignment-directory",
tenant_id=tenant.id, tenant_id=tenant.id,
identity_id=identity.id, identity_id=identity.id,
@@ -235,10 +304,12 @@ class IdentityOrganizationContractTests(unittest.TestCase):
session.commit() session.commit()
from govoplan_identity.backend.directory import SqlIdentityDirectory from govoplan_identity.backend.directory import SqlIdentityDirectory
from govoplan_idm.backend.directory import SqlIdmDirectory
from govoplan_organizations.backend.directory import SqlOrganizationDirectory from govoplan_organizations.backend.directory import SqlOrganizationDirectory
identity_directory = SqlIdentityDirectory() 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("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] 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("function-type-directory", organization_directory.get_function("function-directory").function_type_id) # type: ignore[union-attr]
self.assertEqual( self.assertEqual(
["assignment-directory"], ["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( self.assertEqual(
["assignment-directory"], ["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: finally:
shutil.rmtree(root, ignore_errors=True) shutil.rmtree(root, ignore_errors=True)

403
tests/test_idm_api.py Normal file
View File

@@ -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()

View File

@@ -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=<generate", self_hosted)
self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like)
self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
if __name__ == "__main__":
unittest.main()

View File

@@ -57,6 +57,7 @@ from govoplan_core.core.module_installer import (
list_module_installer_requests, list_module_installer_requests,
module_installer_daemon_status, module_installer_daemon_status,
module_install_preflight, module_install_preflight,
module_manifest_compatibility_issues,
module_installer_lock_status, module_installer_lock_status,
queue_module_installer_request, queue_module_installer_request,
read_module_installer_request, read_module_installer_request,
@@ -96,7 +97,7 @@ from govoplan_core.core.module_package_catalog import (
) )
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleUninstallGuardResult from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleUninstallGuardResult
from govoplan_core.core.module_guards import drop_table_retirement_provider from govoplan_core.core.module_guards import drop_table_retirement_provider
from govoplan_core.core.modules import MigrationSpec, ModuleManifest from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry, RegistryError from govoplan_core.core.registry import PlatformRegistry, RegistryError
from govoplan_core.admin.models import SystemSettings from govoplan_core.admin.models import SystemSettings
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
@@ -108,7 +109,7 @@ from govoplan_core.server.app import create_app
from govoplan_core.server.config import GovoplanServerConfig from govoplan_core.server.config import GovoplanServerConfig
from govoplan_core.server.registry import available_module_manifests, build_platform_registry from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.server.route_validation import RouteCollisionError from govoplan_core.server.route_validation import RouteCollisionError
from govoplan_core.tenancy.scope import create_scope_tables from govoplan_core.tenancy.scope import Tenant, create_scope_tables
from govoplan_files.backend.storage.backends import LocalFilesystemStorageBackend, StorageBackendError from govoplan_files.backend.storage.backends import LocalFilesystemStorageBackend, StorageBackendError
_MANIFEST_PROJECT_MODULES = { _MANIFEST_PROJECT_MODULES = {
@@ -557,6 +558,14 @@ finally:
self.assertEqual(200, login.status_code, login.text) self.assertEqual(200, login.status_code, login.text)
headers = {"Authorization": f"Bearer {login.json()['access_token']}"} headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
tenancy_switch = client.post(
"/api/v1/tenancy/switch-tenant",
headers=headers,
json={"tenant_id": login.json()["tenant"]["id"]},
)
self.assertEqual(200, tenancy_switch.status_code, tenancy_switch.text)
self.assertEqual(login.json()["tenant"]["id"], tenancy_switch.json()["tenant_id"])
created = client.post( created = client.post(
"/api/v1/admin/tenants", "/api/v1/admin/tenants",
headers=headers, headers=headers,
@@ -572,11 +581,69 @@ finally:
updated = client.patch( updated = client.patch(
f"/api/v1/admin/tenants/{tenant_id}", f"/api/v1/admin/tenants/{tenant_id}",
headers=headers, headers=headers,
json={"name": f"Lifecycle {name} Updated", "is_active": False}, json={"name": f"Lifecycle {name} Updated"},
) )
self.assertEqual(200, updated.status_code, updated.text) self.assertEqual(200, updated.status_code, updated.text)
self.assertEqual(f"Lifecycle {name} Updated", updated.json()["name"]) self.assertEqual(f"Lifecycle {name} Updated", updated.json()["name"])
self.assertFalse(updated.json()["is_active"])
current_tenant_retire = client.request(
"DELETE",
f"/api/v1/admin/tenants/{login.json()['tenant']['id']}",
headers=headers,
json={"mode": "retire", "reason": "active tenant guard"},
)
self.assertEqual(409, current_tenant_retire.status_code, current_tenant_retire.text)
plan = client.get(
f"/api/v1/admin/tenants/{tenant_id}/deletion-plan",
headers=headers,
)
self.assertEqual(200, plan.status_code, plan.text)
self.assertTrue(plan.json()["allowed"])
self.assertFalse(plan.json()["destructive_supported"])
destructive = client.request(
"DELETE",
f"/api/v1/admin/tenants/{tenant_id}",
headers=headers,
json={"mode": "destroy", "reason": "not supported"},
)
self.assertEqual(409, destructive.status_code, destructive.text)
issue_codes = {item["code"] for item in destructive.json()["detail"]["plan"]["issues"]}
self.assertIn("tenant_data_present", issue_codes)
with database.session() as session:
empty_tenant = Tenant(
slug=f"empty-destroy-{name}",
name=f"Empty Destroy {name}",
settings={},
is_active=True,
)
session.add(empty_tenant)
session.commit()
empty_tenant_id = empty_tenant.id
destroyed = client.request(
"DELETE",
f"/api/v1/admin/tenants/{empty_tenant_id}",
headers=headers,
json={"mode": "destroy", "reason": "empty tenant cleanup"},
)
self.assertEqual(200, destroyed.status_code, destroyed.text)
self.assertEqual("destroy", destroyed.json()["plan"]["action"])
self.assertTrue(destroyed.json()["plan"]["destructive_supported"])
retired = client.request(
"DELETE",
f"/api/v1/admin/tenants/{tenant_id}",
headers=headers,
json={"mode": "retire", "reason": "module permutation test"},
)
self.assertEqual(200, retired.status_code, retired.text)
retired_item = retired.json()["item"]
self.assertFalse(retired_item["is_active"])
self.assertEqual("retired", retired_item["settings"]["lifecycle"]["state"])
self.assertEqual("module permutation test", retired_item["settings"]["lifecycle"]["reason"])
def test_registry_rejects_missing_required_capability(self) -> None: def test_registry_rejects_missing_required_capability(self) -> None:
registry = PlatformRegistry() registry = PlatformRegistry()
@@ -590,6 +657,68 @@ finally:
with self.assertRaisesRegex(RegistryError, "requires unavailable capability"): with self.assertRaisesRegex(RegistryError, "requires unavailable capability"):
registry.validate() 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: def test_access_and_organizations_do_not_hard_depend_on_tenancy(self) -> None:
manifests = available_module_manifests() manifests = available_module_manifests()
@@ -630,11 +759,79 @@ finally:
saved = saved_module_install_plan(session) saved = saved_module_install_plan(session)
self.assertEqual(("files",), tuple(item.module_id for item in saved.items)) self.assertEqual(("files",), tuple(item.module_id for item in saved.items))
self.assertEqual("manual", saved.items[0].source)
self.assertEqual(( self.assertEqual((
"python -m pip install 'govoplan-files @ git+ssh://git.example.invalid/add-ideas/govoplan-files.git@v0.1.4'", "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", "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)) ), 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: def test_module_install_plan_rejects_local_dependency_refs(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-install-plan-local-", dir=_TEST_ROOT)) root = Path(tempfile.mkdtemp(prefix="govoplan-module-install-plan-local-", dir=_TEST_ROOT))
settings = _settings(root) settings = _settings(root)
@@ -768,6 +965,166 @@ finally:
self.assertIn("core_version_too_low", {issue.code for issue in preflight.issues}) self.assertIn("core_version_too_low", {issue.code for issue in preflight.issues})
self.assertTrue(preflight.checklist) 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: 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)) root = Path(tempfile.mkdtemp(prefix="govoplan-installer-install-rollback-", dir=_TEST_ROOT))
settings = _settings(root) settings = _settings(root)
@@ -1379,6 +1736,8 @@ finally:
"version": "0.1.4", "version": "0.1.4",
"python_package": "govoplan-files", "python_package": "govoplan-files",
"python_ref": "govoplan-files==0.1.4", "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": { "artifact_integrity": {
"python": { "python": {
"ref": "govoplan-files==0.1.4", "ref": "govoplan-files==0.1.4",
@@ -1396,12 +1755,48 @@ finally:
self.assertEqual("files", catalog[0]["module_id"]) self.assertEqual("files", catalog[0]["module_id"])
self.assertEqual("install", catalog[0]["action"]) self.assertEqual("install", catalog[0]["action"])
self.assertEqual(["official"], catalog[0]["tags"]) 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"]) self.assertEqual("0" * 64, catalog[0]["artifact_integrity"]["python"]["sha256"])
validation = validate_module_package_catalog(catalog_path) validation = validate_module_package_catalog(catalog_path)
self.assertTrue(validation["valid"]) self.assertTrue(validation["valid"])
self.assertEqual("files", validation["modules"][0]["module_id"]) 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: def test_module_package_catalog_validation_reports_bad_entries(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-invalid-", dir=_TEST_ROOT)) root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-invalid-", dir=_TEST_ROOT))
catalog_path = root / "catalog.json" catalog_path = root / "catalog.json"
@@ -1412,6 +1807,54 @@ finally:
self.assertFalse(validation["valid"]) self.assertFalse(validation["valid"])
self.assertIn("module_id", str(validation["error"])) 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: def test_module_package_catalog_validates_signed_approved_channel(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-signed-", dir=_TEST_ROOT)) root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-signed-", dir=_TEST_ROOT))
catalog_path = root / "catalog.json" catalog_path = root / "catalog.json"
@@ -1454,6 +1897,47 @@ finally:
self.assertTrue(validation["trusted"]) self.assertTrue(validation["trusted"])
self.assertEqual("stable", validation["channel"]) 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: 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)) root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-remote-", dir=_TEST_ROOT))
catalog_path = root / "catalog.json" catalog_path = root / "catalog.json"

View File

@@ -15,6 +15,7 @@
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui", "@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
"@govoplan/docs-webui": "file:../../govoplan-docs/webui", "@govoplan/docs-webui": "file:../../govoplan-docs/webui",
"@govoplan/files-webui": "file:../../govoplan-files/webui", "@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
"@govoplan/mail-webui": "file:../../govoplan-mail/webui", "@govoplan/mail-webui": "file:../../govoplan-mail/webui",
"@govoplan/ops-webui": "file:../../govoplan-ops/webui", "@govoplan/ops-webui": "file:../../govoplan-ops/webui",
"@govoplan/organizations-webui": "file:../../govoplan-organizations/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": { "../../govoplan-mail/webui": {
"name": "@govoplan/mail-webui", "name": "@govoplan/mail-webui",
"version": "0.1.6", "version": "0.1.6",
@@ -980,6 +1000,10 @@
"resolved": "../../govoplan-files/webui", "resolved": "../../govoplan-files/webui",
"link": true "link": true
}, },
"node_modules/@govoplan/idm-webui": {
"resolved": "../../govoplan-idm/webui",
"link": true
},
"node_modules/@govoplan/mail-webui": { "node_modules/@govoplan/mail-webui": {
"resolved": "../../govoplan-mail/webui", "resolved": "../../govoplan-mail/webui",
"link": true "link": true

View File

@@ -33,6 +33,7 @@
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui", "@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
"@govoplan/docs-webui": "file:../../govoplan-docs/webui", "@govoplan/docs-webui": "file:../../govoplan-docs/webui",
"@govoplan/files-webui": "file:../../govoplan-files/webui", "@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
"@govoplan/mail-webui": "file:../../govoplan-mail/webui", "@govoplan/mail-webui": "file:../../govoplan-mail/webui",
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui", "@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
"@govoplan/ops-webui": "file:../../govoplan-ops/webui" "@govoplan/ops-webui": "file:../../govoplan-ops/webui"

View File

@@ -28,6 +28,7 @@
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.6", "@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/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/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/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/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", "@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.6",

View File

@@ -7,6 +7,7 @@ const packageByModule = {
dashboard: "@govoplan/dashboard-webui", dashboard: "@govoplan/dashboard-webui",
docs: "@govoplan/docs-webui", docs: "@govoplan/docs-webui",
files: "@govoplan/files-webui", files: "@govoplan/files-webui",
idm: "@govoplan/idm-webui",
mail: "@govoplan/mail-webui", mail: "@govoplan/mail-webui",
organizations: "@govoplan/organizations-webui", organizations: "@govoplan/organizations-webui",
ops: "@govoplan/ops-webui" ops: "@govoplan/ops-webui"
@@ -21,11 +22,12 @@ const cases = [
{ name: "files-only", modules: ["files"] }, { name: "files-only", modules: ["files"] },
{ name: "mail-only", modules: ["mail"] }, { name: "mail-only", modules: ["mail"] },
{ name: "organizations-only", modules: ["organizations"] }, { name: "organizations-only", modules: ["organizations"] },
{ name: "idm-with-organizations", modules: ["organizations", "idm"] },
{ name: "campaign-only", modules: ["campaigns"] }, { name: "campaign-only", modules: ["campaigns"] },
{ name: "campaign-with-files-no-mail", modules: ["campaigns", "files"] }, { name: "campaign-with-files-no-mail", modules: ["campaigns", "files"] },
{ name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] }, { name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] },
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] }, { 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; const npmExec = process.env.npm_execpath;

View File

@@ -756,6 +756,14 @@
min-width: 0; 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 { .module-install-plan-row label span {
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;

View File

@@ -277,6 +277,55 @@ export type DashboardWidgetsUiCapability = {
widgets: DashboardWidgetContribution[]; 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 MailSecurity = "plain" | "tls" | "starttls";
export type MailProfileScope = "system" | "tenant" | "user" | "group" | "campaign"; export type MailProfileScope = "system" | "tenant" | "user" | "group" | "campaign";

View File

@@ -18,6 +18,7 @@ const defaultWebModulePackages = [
"@govoplan/dashboard-webui", "@govoplan/dashboard-webui",
"@govoplan/docs-webui", "@govoplan/docs-webui",
"@govoplan/files-webui", "@govoplan/files-webui",
"@govoplan/idm-webui",
"@govoplan/mail-webui", "@govoplan/mail-webui",
"@govoplan/organizations-webui", "@govoplan/organizations-webui",
"@govoplan/ops-webui" "@govoplan/ops-webui"
@@ -95,6 +96,7 @@ export default defineConfig({
fileURLToPath(new URL('../../govoplan-dashboard/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-dashboard/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-docs/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-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-mail/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-organizations/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-organizations/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-campaign/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-campaign/webui', import.meta.url)),