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