15 Commits

Author SHA1 Message Date
a00ef54821 Release v0.1.7
All checks were successful
Dependency Audit / dependency-audit (push) Successful in 1m35s
2026-07-11 03:11:06 +02:00
edb4687826 Cover access canonical directory migration
All checks were successful
Dependency Audit / dependency-audit (push) Successful in 1m33s
2026-07-11 01:13:53 +02:00
fcfe0b69a3 Install cloned WebUI dependencies in release integration
All checks were successful
Dependency Audit / dependency-audit (push) Successful in 1m38s
2026-07-11 01:00:09 +02:00
715bdcbebe Treat empty unittest discovery as release skip
All checks were successful
Dependency Audit / dependency-audit (push) Successful in 1m28s
2026-07-11 00:43:14 +02:00
060f4da751 Add resource access explanation contract
Some checks failed
Dependency Audit / dependency-audit (push) Has been cancelled
2026-07-11 00:39:39 +02:00
c32951393a Skip empty cloned backend test suites
All checks were successful
Dependency Audit / dependency-audit (push) Successful in 1m31s
2026-07-11 00:38:07 +02:00
7b85d6deae Install release WebUI modules as real packages
All checks were successful
Dependency Audit / dependency-audit (push) Successful in 1m34s
2026-07-11 00:32:25 +02:00
2d2d9e7bc7 Temporarily limit heavy CI workflows to manual runs
Some checks failed
Dependency Audit / dependency-audit (push) Has been cancelled
2026-07-11 00:25:40 +02:00
dc1a250797 Install release WebUI modules sequentially in CI
Some checks failed
Dependency Audit / dependency-audit (push) Successful in 1m36s
Release Integration / release-integration (push) Failing after 1m32s
Module Matrix / module-matrix (push) Successful in 3m54s
2026-07-11 00:23:44 +02:00
7b7cc8ada7 Fail CI after exhausted WebUI install retries
Some checks failed
Dependency Audit / dependency-audit (push) Successful in 1m38s
Release Integration / release-integration (push) Has been cancelled
Module Matrix / module-matrix (push) Has been cancelled
2026-07-11 00:19:17 +02:00
722c9e5d1c Retry WebUI release dependency installs in CI
Some checks failed
Dependency Audit / dependency-audit (push) Successful in 1m44s
Module Matrix / module-matrix (push) Successful in 4m9s
Release Integration / release-integration (push) Failing after 2m47s
2026-07-11 00:01:44 +02:00
63e54a67be Use checkout Alembic root in release integration
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 1m36s
Release Integration / release-integration (push) Has been cancelled
Module Matrix / module-matrix (push) Has been cancelled
2026-07-10 23:58:04 +02:00
12b623bec9 Include IDM in release integration install
Some checks failed
Dependency Audit / dependency-audit (push) Successful in 1m47s
Module Matrix / module-matrix (push) Failing after 2m37s
Release Integration / release-integration (push) Failing after 1m30s
2026-07-10 23:36:52 +02:00
b788afcae1 Harden module update compatibility cleanup
Some checks failed
Dependency Audit / dependency-audit (push) Successful in 1m46s
Module Matrix / module-matrix (push) Failing after 2m38s
Release Integration / release-integration (push) Failing after 1m41s
2026-07-10 23:27:48 +02:00
2b0cdf13f3 Add release integration workflow
Some checks failed
Dependency Audit / dependency-audit (push) Successful in 1m44s
Module Matrix / module-matrix (push) Successful in 4m19s
Release Integration / release-integration (push) Failing after 1m40s
2026-07-10 23:19:09 +02:00
44 changed files with 4839 additions and 1090 deletions

View File

@@ -41,10 +41,6 @@ jobs:
.venv/bin/python -m pip install 'pip-audit>=2.9,<3'
- name: Install WebUI release dependencies
working-directory: webui
run: |
node -e "const fs=require('fs'); const pkg=JSON.parse(fs.readFileSync('package.json')); const rel=JSON.parse(fs.readFileSync('package.release.json')); for (const key of ['dependencies','devDependencies','peerDependencies','optionalDependencies','overrides']) if (rel[key]) pkg[key]=rel[key]; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');"
rm -f package-lock.json
npm cache clean --force
npm install --prefer-online
run: bash ../scripts/install-webui-release-dependencies.sh .
- name: Run dependency audits
run: bash scripts/check-dependency-audits.sh

View File

@@ -1,10 +1,8 @@
name: Module Matrix
on:
workflow_dispatch:
pull_request:
push:
branches:
- main
jobs:
module-matrix:
@@ -39,10 +37,6 @@ jobs:
.venv/bin/python -m pip install '.[dev]'
- name: Install WebUI release dependencies with test scripts
working-directory: webui
run: |
node -e "const fs=require('fs'); const pkg=JSON.parse(fs.readFileSync('package.json')); const rel=JSON.parse(fs.readFileSync('package.release.json')); for (const key of ['dependencies','devDependencies','peerDependencies','optionalDependencies','overrides']) if (rel[key]) pkg[key]=rel[key]; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');"
rm -f package-lock.json
npm cache clean --force
npm install --prefer-online
run: bash ../scripts/install-webui-release-dependencies.sh .
- name: Run module matrix and contract tests
run: bash scripts/check-module-matrix.sh

View File

@@ -0,0 +1,41 @@
name: Release Integration
on:
workflow_dispatch:
jobs:
release-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Configure SSH for release dependencies
env:
GOVOPLAN_RELEASE_SSH_KEY_B64: ${{ secrets.GOVOPLAN_RELEASE_SSH_KEY_B64 }}
run: |
mkdir -p ~/.ssh
chmod 700 ~/.ssh
if [ -z "${GOVOPLAN_RELEASE_SSH_KEY_B64:-}" ]; then
echo "GOVOPLAN_RELEASE_SSH_KEY_B64 secret is required for git+ssh release dependencies."
exit 1
fi
printf '%s' "$GOVOPLAN_RELEASE_SSH_KEY_B64" | base64 -d > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
echo 'git.add-ideas.de ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDe48IOof2fJS1dTbJtLWQnWnr+JorZXKIFdOAM9ct8G' > ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
- name: Install backend release integration dependencies
run: |
python -m venv .venv
.venv/bin/python -m pip install --upgrade pip
.venv/bin/python -m pip install -r requirements-release.txt
.venv/bin/python -m pip install '.[dev]'
- name: Install WebUI release dependencies
working-directory: webui
run: bash ../scripts/install-webui-release-dependencies.sh .
- name: Run release integration checks
run: bash scripts/check-release-integration.sh

View File

@@ -1,5 +1,6 @@
[![Module Matrix](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/module-matrix.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/module-matrix.yml)
[![Dependency Audit](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/dependency-audit.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/dependency-audit.yml)
[![Module Matrix](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/module-matrix.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan-core/actions?workflow=module-matrix.yml&actor=0&status=0)
[![Release Integration](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/release-integration.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan-core/actions?workflow=release-integration.yml&actor=0&status=0)
[![Dependency Audit](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/dependency-audit.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan-core/actions?workflow=dependency-audit.yml&actor=0&status=0)
# govoplan-core

View File

@@ -1,6 +1,6 @@
# GovOPlaN RBAC And Resource-Access Model
**Updated:** 2026-07-09
**Updated:** 2026-07-11
## Authorization Equation
@@ -254,6 +254,61 @@ space/folder/file ownership:
External file connections and spaces are additionally constrained by connector
policy and owner/group assignment in the files module.
## Resource Access Explanations
The access module exposes a diagnostic endpoint for explaining why a principal
can see or operate on a concrete resource:
```text
GET /api/v1/admin/access/resource-explanation
```
Required query values:
| Field | Meaning |
| --- | --- |
| `user_id` | Tenant membership to explain. The current module UIs pass the signed-in user. |
| `resource_type` | Module-owned type such as `file`, `folder`, or `campaign`. |
| `resource_id` | Stable module resource identifier. |
| `action` | Permission/action being explained, for example `files:file:read`. |
| `tenant_id` | Optional tenant override for system/admin contexts. |
The access module always contributes effective-scope provenance for the action.
Installed modules may add resource provenance by exposing a
`ResourceAccessExplanationProvider` through a capability consumed by access.
Providers should return only facts they own, using these provenance kinds:
| Kind | Meaning |
| --- | --- |
| `resource` | The concrete resource or an explicit not-found result. |
| `owner` | Matching user/group ownership. |
| `share` | Matching explicit user/group/tenant share. |
| `policy` | Administrative bypass or policy-derived grant. |
| `role` / `right` | Scope and role provenance from access itself. |
Files currently registers `files.access` and explains file assets plus folders.
Persisted folders use their database ID. Folder rows inferred from file paths use
a deterministic virtual ID:
```text
virtual-folder:v1:<tenant_id>:<owner_type>:<owner_id>:<base64url(normalized_path)>
```
The files provider validates virtual folder IDs before returning provenance: the
tenant and owner must match the resource ID, and at least one active file must
exist below the normalized folder path. This keeps virtual folder explanations
stable without forcing every inferred tree node to become a stored folder row.
Campaign currently registers `campaigns.access` and explains the campaign
ownership/sharing object itself. Finer-grained campaign sub-objects are tracked
separately in `govoplan-campaign#50` until their resource identifiers and access
rules are decided.
Cross-user resource explanation is a policy feature, not a module-local UI
detail. Until `govoplan-policy#6` is resolved, module UIs should default to
current-user explanation and avoid importing access-admin user-picking
components.
## Mail Servers
| Scope | Meaning |

View File

@@ -133,13 +133,12 @@ Access:
Tenancy:
- `tenancy.tenant.created`
- `tenancy.tenant.updated`
- `tenancy.tenant.suspended`
- `tenancy.tenant.reactivated`
- `tenancy.tenant.delete_requested`
- `tenancy.tenant.delete_blocked`
- `tenancy.tenant.deleted`
- `tenant.created`
- `tenant.updated`
- `tenant.suspended`
- `tenant.resumed`
- `tenant.deletion_requested`
- `tenant.erasure_completed`
Policy:

View File

@@ -458,6 +458,17 @@ routers and live module activation fail fast if two routers register the same
HTTP method and path. That keeps OpenAPI output and FastAPI route order from
silently masking a module collision.
Tenant deletion and cleanup use the registry-owned delete-veto contract. A
module that owns tenant-bound data may declare `delete_veto_providers` on its
manifest for resource types such as `tenant` or `group`. Providers receive
`(session, tenant_id, resource_id)` and should return `DeleteVetoIssue`, an
iterable of `DeleteVetoIssue`, or `None`; older exception-based providers are
still treated as blocking vetoes. Core attributes each issue to the provider
module and adds resource context before the tenancy module exposes the issues
through the deletion plan. `blocker` issues prevent destructive or retire
operations, `warning` issues explain retained data, and `info` issues document
non-blocking lifecycle facts.
## Database And Migrations
Core owns the database/session lifecycle. Modules access the database through core session dependencies and register their models/migrations through their manifest.
@@ -489,11 +500,37 @@ does not trust the website by location alone. A catalog must pass the configured
signature, channel, freshness, and replay rules before a catalog entry can be
planned. Catalog entries may declare `license_features`; core checks those
against the configured offline license before adding the entry to the install
plan.
plan. Catalog entries may also declare `migration_safety` as `automatic`,
`requires_review`, `forward_only`, or `destructive`; forward-only and
destructive entries require explicit operator acknowledgement in the install
plan before installer preflight allows activation. Forward-only and destructive
catalog entries must also declare a tested recovery path. Catalog update entries
can define direct-update windows with `current_version_min` and
`current_version_max_exclusive`, mark intermediate `bridge_release` targets, and
explicitly opt into reviewed downgrade or same-version package-refresh plans.
Module migration order can be declared with `migration_after` and
`migration_before` in manifests or release catalogs; installer preflight turns
that metadata, module dependencies, and named interface relationships into an
ordered migration plan.
Modules that need live-data work outside Alembic schema revisions may declare
`migration_tasks` on `MigrationSpec`. This is deliberately narrower than a
general lifecycle hook system. Each task has a stable `task_id`, one of four
phases (`pre_migration_check`, `pre_migration_prepare`,
`post_migration_backfill`, `post_migration_verify`), a short operator-facing
summary, a task version, safety metadata, and an idempotent executor. Installer
preflight blocks non-idempotent tasks, forward-only/destructive tasks without
operator acknowledgement, and installed manifest tasks that have no executor.
Catalog task metadata is surfaced before activation as pending because the
executor can only be verified after the package is installed.
Modules should provide:
- pinned backend and WebUI package refs for official catalog entries
- module dependency metadata for catalog target-state planning
- migration-safety metadata for catalog update planning
- migration task metadata when live-data checks, preparation, backfills, or
verification must run around Alembic
- compatibility metadata in the module manifest
- named interface contracts in the manifest and catalog entry when the module
provides or consumes cross-module APIs
@@ -977,8 +1014,21 @@ The package install-plan API records operator intent only:
also reports catalog validity, channel, signature, trust state, and the
configured path.
- `POST /api/v1/admin/system/modules/install-plan/catalog/{module_id}` saves
a planned install row from a validated catalog entry. Catalog signature and
approved-channel policy are enforced before the row is saved.
a planned install or update row from a validated catalog entry. Installed
modules are planned as updates. Catalog signature and approved-channel policy
are enforced before the row is saved. When the selected catalog row requires
companion dependency or interface-provider updates, the endpoint adds those
rows to the plan automatically. The saved plan row can also carry a
data-safety acknowledgement used by preflight for forward-only or destructive
catalog entries.
- Install-plan preflight returns a structured `target_plan` summary so the
admin UI can show current version, target version, package refs,
migration-safety level, update-window and bridge metadata, recovery metadata,
and acknowledgement state without requiring JSON editing.
- Install-plan preflight also returns a structured `migration_plan` summary with
target enabled modules and ordered module migration steps. When the installer
runs with migration enabled, the database migration command receives that
target module set and ordered module list.
- `POST /api/v1/admin/system/modules/{module_id}/uninstall-plan` saves a
planned non-destructive uninstall row for an installed module after it has
been disabled. The Python distribution name is resolved from the installed

View File

@@ -111,10 +111,12 @@ scripts/push-release-tag.sh --version 0.1.6
`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.
`requires_interfaces` from the manifest. It also copies module migration order
and `migration_tasks` metadata when present. If a manifest cannot be
discovered, the entry falls back to the release version passed with `--version`
and omits interface and migration-task 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
@@ -205,6 +207,27 @@ 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
- `dependencies` and `optional_dependencies`, the module ids expected in the
target module set
- `migration_safety`, one of `automatic`, `requires_review`, `forward_only`,
or `destructive`
- `migration_notes`, operator-facing data/migration guidance for review,
forward-only, or destructive changes
- `migration_after` and `migration_before`, explicit module ids used to order
module-owned migration heads when a release needs a live-data sequencing rule
- `migration_tasks`, constrained live-data tasks that run around Alembic
migration phases. Each task declares `task_id`, `phase`, `summary`,
`task_version`, `safety`, `idempotent`, and optionally `timeout_seconds`.
The allowed phases are `pre_migration_check`, `pre_migration_prepare`,
`post_migration_backfill`, and `post_migration_verify`.
- `current_version_min` and `current_version_max_exclusive`, the installed
version window from which this catalog target may be applied directly
- `bridge_release` and `bridge_notes`, marking a target as an intermediate
compatibility release in a staged update path
- `allow_downgrade` and `allow_same_version`, explicit opt-ins for reviewed
rollback or package-refresh plans
- `recovery_tested` and `recovery_notes`, documenting the rehearsal for
forward-only or destructive data changes
- `provides_interfaces`, named interface contracts exported by this module
- `requires_interfaces`, named interface contracts and version ranges required
by this module
@@ -264,16 +287,32 @@ 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
- catalog-sourced installs and updates require a configured, valid package
catalog before activation
- invalid, untrusted, expired, not-yet-valid, replayed, or unapproved-channel
catalogs block catalog-sourced installs
catalogs block catalog-sourced installs and updates
- 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
- selected catalog entries whose target dependencies are neither installed nor
planned block activation before the installer runs
- catalog update targets older than the installed module version block unless
the catalog entry declares `allow_downgrade: true`
- catalog update targets equal to the installed module version block unless the
catalog entry declares `allow_same_version: true`
- catalog update targets with a `current_version_min` /
`current_version_max_exclusive` window block when the installed version is
outside that window; publish and apply a bridge release instead
- catalog entries marked `forward_only` or `destructive` block activation until
the plan row has an explicit data-safety acknowledgement
- catalog entries marked `forward_only` or `destructive` also block unless the
catalog entry declares `recovery_tested: true` and either the catalog entry or
operator plan row contains recovery notes
- catalog entries marked `destructive` also require catalog migration notes or
operator notes describing the cleanup or retirement plan
### Update Paths
@@ -283,6 +322,41 @@ 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.
Install-plan rows support explicit `install`, `update`, and `uninstall`
actions. Catalog planning writes `update` when the module is already installed.
Preflight resolves the target set from installed manifests plus the planned
catalog entries. Unplanned catalog entries are not treated as installed. When a
catalog entry would satisfy a missing dependency or named interface, preflight
blocks activation with a companion-update issue; the admin catalog planner adds
those companion rows automatically when it can resolve them from the current
catalog. The preflight response also includes a structured `target_plan` summary
with each planned module's action, current version, catalog target version,
package refs, migration-safety level, current-version update window, bridge
metadata, recovery metadata, and acknowledgement state.
Database migrations are planned against that same target module set. When the
installer is run with `--migrate`, it calls `govoplan_core.commands.init_db`
with the target enabled modules rather than the pre-update startup module list,
so newly installed module migration directories are discovered before
activation. Preflight also returns a structured migration plan. Its step order is
derived from:
- manifest and catalog `migration_after` / `migration_before` declarations
- module dependencies and optional dependencies when both modules are in the
target plan
- named interface provider/consumer relationships when both sides are in the
target plan
Preflight blocks cycles in that ordering graph. It also blocks non-idempotent
module migration tasks, forward-only/destructive tasks without operator
acknowledgement, and installed manifest tasks that declare no executor.
Catalog-only task executors are marked as pending because they can only be
confirmed after the target package is installed. The migrator runs pre-migration
tasks, upgrades the ordered module heads first, finishes with Alembic `heads`,
and then runs post-migration tasks, so Alembic's revision graph remains
authoritative while GovOPlaN still gives operators a module-aware live-data
order.
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
@@ -303,11 +377,25 @@ Live data upgrades need an even stricter rule:
must publish an intermediate compatibility release rather than a circular
update chain
The release catalog is the first safety gate for this. Generated catalogs mark
modules with registered migrations as `requires_review` by default. Release
authors should keep that value for ordinary reversible migrations, raise it to
`forward_only` when database rollback requires restoring a snapshot, and raise
it to `destructive` when the update removes or irreversibly rewrites persisted
data. Forward-only and destructive entries must include `recovery_tested: true`
and recovery notes after a verified restore or forward-recovery rehearsal. The
admin install-plan UI exposes the safety level and lets operators record an
explicit acknowledgement; preflight keeps acknowledged forward-only/destructive
changes visible as warnings.
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.
module has a compatible target version. Use `current_version_min` and
`current_version_max_exclusive` to make those direct-update windows explicit in
the catalog, and set `bridge_release: true` on intermediate targets that exist
primarily to carry installations safely across a compatibility gap.
Trusted catalog keys are configured locally:

View File

@@ -1,4 +1,100 @@
{
"version": 1,
"releases": []
"releases": [
{
"heads": [
{
"owner": "govoplan-campaign",
"revision": "2c3d4e5f7081"
},
{
"owner": "govoplan-mail",
"revision": "3d4e5f708192"
},
{
"owner": "govoplan-core",
"revision": "4f2a9c8e7b6d"
},
{
"owner": "govoplan-identity",
"revision": "5c6d7e8f9a10"
},
{
"owner": "govoplan-organizations",
"revision": "6d7e8f9a0b1c"
},
{
"owner": "govoplan-idm",
"revision": "8f9a0b1c2d3e"
},
{
"owner": "govoplan-calendar",
"revision": "9e0f1a2b3c4d"
},
{
"owner": "govoplan-files",
"revision": "a7b8c9d0e1f3"
}
],
"owner_heads": [
{
"owner": "govoplan-access",
"revisions": [
"4a5b6c7d8e9f"
]
},
{
"owner": "govoplan-calendar",
"revisions": [
"9e0f1a2b3c4d"
]
},
{
"owner": "govoplan-campaign",
"revisions": [
"2c3d4e5f7081"
]
},
{
"owner": "govoplan-core",
"revisions": [
"4f2a9c8e7b6d"
]
},
{
"owner": "govoplan-files",
"revisions": [
"a7b8c9d0e1f3"
]
},
{
"owner": "govoplan-identity",
"revisions": [
"5c6d7e8f9a10"
]
},
{
"owner": "govoplan-idm",
"revisions": [
"8f9a0b1c2d3e"
]
},
{
"owner": "govoplan-mail",
"revisions": [
"3d4e5f708192"
]
},
{
"owner": "govoplan-organizations",
"revisions": [
"6d7e8f9a0b1c"
]
}
],
"recorded_at": "2026-07-11T00:19:07Z",
"release": "0.1.7",
"squash_policy": "reviewed-manual"
}
],
"version": 1
}

View File

@@ -15,6 +15,29 @@
"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",
"migration_safety": "forward_only",
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
"migration_after": ["access"],
"migration_before": ["campaigns"],
"migration_tasks": [
{
"task_id": "backfill_spaces",
"phase": "post_migration_backfill",
"summary": "Backfill file spaces after the schema migration.",
"task_version": "1",
"safety": "forward_only",
"idempotent": true,
"timeout_seconds": 300
}
],
"current_version_min": "0.1.0",
"current_version_max_exclusive": "0.2.0",
"bridge_release": true,
"bridge_notes": "Keeps the 0.1.x campaign attachment interface while introducing the 0.2.x contract.",
"allow_downgrade": false,
"allow_same_version": false,
"recovery_tested": true,
"recovery_notes": "Snapshot restore and forward recovery were rehearsed on the release candidate dataset.",
"provides_interfaces": [
{
"name": "files.campaign_attachments",

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-core"
version = "0.1.6"
version = "0.1.7"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md"
requires-python = ">=3.12"

View File

@@ -4,6 +4,7 @@
govoplan-tenancy @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-tenancy.git@v0.1.6
govoplan-organizations @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git@v0.1.6
govoplan-identity @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-identity.git@v0.1.6
govoplan-idm @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git@v0.1.6
govoplan-access @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git@v0.1.6
govoplan-admin @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git@v0.1.6
govoplan-policy @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git@v0.1.6

View File

@@ -0,0 +1,278 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PYTHON="${PYTHON:-$ROOT/.venv/bin/python}"
NPM="${NPM:-/home/zemion/.nvm/versions/node/v22.22.3/bin/npm}"
NODE_BIN="$(dirname "$NPM")"
WEBUI_BIN="$ROOT/webui/node_modules/.bin"
NPM_USERCONFIG="$(mktemp "${TMPDIR:-/tmp}/govoplan-npmrc.XXXXXXXX")"
WORK_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/govoplan-release-integration.XXXXXXXX")"
trap 'rm -rf "$WORK_ROOT"; rm -f "$NPM_USERCONFIG"' EXIT
if [[ ! -x "$PYTHON" ]]; then
PYTHON=python
fi
if [[ ! -x "$NPM" ]]; then
NPM=npm
NODE_BIN="$(dirname "$(command -v "$NPM")")"
fi
cd "$ROOT"
RELEASE_VERSION="$("$PYTHON" - <<'PY'
from pathlib import Path
import tomllib
with (Path.cwd() / "pyproject.toml").open("rb") as handle:
print(tomllib.load(handle)["project"]["version"])
PY
)"
RELEASE_TAG="${GOVOPLAN_RELEASE_TAG:-v$RELEASE_VERSION}"
export RELEASE_TAG RELEASE_VERSION
export GOVOPLAN_CORE_SOURCE_ROOT="$ROOT"
export PATH="$WEBUI_BIN:$NODE_BIN:$PATH"
export NPM_CONFIG_USERCONFIG="$NPM_USERCONFIG"
unset npm_config_tmp NPM_CONFIG_TMP
run_step() {
echo
echo "==> $*"
}
retry() {
local attempt status
for attempt in 1 2 3; do
if "$@"; then
return 0
fi
status=$?
if [[ "$attempt" == 3 ]]; then
return "$status"
fi
sleep $((attempt * 10))
done
}
run_discovered_tests() {
local suite_dir="$1"
local status
if ! find "$suite_dir" -type f -name 'test*.py' -print -quit | grep -q .; then
echo "No unittest test files found under $suite_dir; skipping."
return 0
fi
set +e
"$PYTHON" -m unittest discover -s "$suite_dir"
status=$?
set -e
if [[ "$status" == 5 ]]; then
echo "No unittest tests discovered under $suite_dir; skipping."
return 0
fi
return "$status"
}
install_cloned_webui_dependencies() {
local webui_dir="$1"
if [[ ! -f "$webui_dir/package.json" ]]; then
echo "No package.json found under $webui_dir; skipping WebUI dependency install."
return 0
fi
(
cd "$webui_dir"
retry "$NPM" install --prefer-online --include=dev --no-save
)
}
run_step "Validate release refs and installed package metadata"
"$PYTHON" - <<'PY'
from __future__ import annotations
import importlib.metadata as metadata
import json
import os
import re
import subprocess
import sys
from pathlib import Path
root = Path.cwd()
release_requirements = root / "requirements-release.txt"
release_webui = root / "webui" / "package.release.json"
expected_version = os.environ["RELEASE_VERSION"]
expected_tag = os.environ["RELEASE_TAG"]
errors: list[str] = []
python_requirements: list[tuple[str, str, str]] = []
for line in release_requirements.read_text(encoding="utf-8").splitlines():
match = re.match(r"^(govoplan-[a-z0-9-]+)\s+@\s+git\+ssh://(.+?\.git)@(v[0-9]+\.[0-9]+\.[0-9]+)$", line.strip())
if match:
python_requirements.append((match.group(1), f"ssh://{match.group(2)}", match.group(3)))
if not python_requirements:
errors.append("No GovOPlaN git+ssh release requirements found.")
for package_name, repo_url, tag in python_requirements:
if tag != expected_tag:
errors.append(f"{package_name} release requirement uses {tag!r}, expected {expected_tag!r}.")
version = metadata.version(package_name)
if version != tag.removeprefix("v"):
errors.append(f"{package_name} installed version {version!r} does not match {tag!r}.")
result = subprocess.run(
["git", "ls-remote", "--tags", repo_url, f"refs/tags/{tag}", f"refs/tags/{tag}^{{}}"],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=45,
check=False,
)
if result.returncode != 0 or f"refs/tags/{tag}" not in result.stdout:
errors.append(f"{package_name} tag {tag} is not readable from {repo_url}: {result.stderr.strip()}")
release_package = json.loads(release_webui.read_text(encoding="utf-8"))
if release_package.get("version") != expected_version:
errors.append(f"Core release WebUI version is {release_package.get('version')!r}, expected {expected_version!r}.")
webui_dependencies = release_package.get("dependencies", {})
for package_name, spec in sorted(webui_dependencies.items()):
if not package_name.startswith("@govoplan/"):
continue
tag = spec.rsplit("#", 1)[-1] if "#" in spec else ""
if tag != expected_tag:
errors.append(f"{package_name} release dependency uses {tag!r}, expected {expected_tag}.")
package_json = root / "webui" / "node_modules" / package_name / "package.json"
if not package_json.exists():
errors.append(f"{package_name} is missing from release node_modules.")
continue
installed = json.loads(package_json.read_text(encoding="utf-8"))
if installed.get("version") != expected_version:
errors.append(f"{package_name} installed version {installed.get('version')!r}, expected {expected_version!r}.")
shared_peers = ("lucide-react", "react", "react-dom", "react-router-dom")
root_peers = release_package.get("peerDependencies", {})
for package_json in sorted((root / "webui" / "node_modules" / "@govoplan").glob("*/package.json")):
package = json.loads(package_json.read_text(encoding="utf-8"))
peers = package.get("peerDependencies", {})
for name in shared_peers:
if name in peers and name in root_peers and peers[name] != root_peers[name]:
errors.append(f"{package.get('name')} peer {name}={peers[name]!r}, expected {root_peers[name]!r}.")
if errors:
print("\n".join(errors), file=sys.stderr)
raise SystemExit(1)
print(f"Release refs and metadata passed for {len(python_requirements)} Python packages and {len(webui_dependencies)} WebUI packages.")
PY
run_step "Validate installed module manifests and registry"
"$PYTHON" - <<'PY'
from __future__ import annotations
import sys
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
expected = {
"access",
"admin",
"audit",
"calendar",
"campaigns",
"dashboard",
"docs",
"files",
"identity",
"idm",
"mail",
"ops",
"organizations",
"policy",
"tenancy",
}
manifests = available_module_manifests()
missing = sorted(expected - set(manifests))
if missing:
print(f"Missing installed module manifests: {', '.join(missing)}", file=sys.stderr)
raise SystemExit(1)
registry = build_platform_registry(tuple(sorted(expected)))
snapshot = registry.validate()
print("Registry modules:", ", ".join(manifest.id for manifest in snapshot.manifests))
PY
run_step "Run SQLite migration smoke for release modules"
"$PYTHON" - <<'PY'
from __future__ import annotations
import tempfile
from pathlib import Path
from govoplan_core.db.migrations import migrate_database
enabled_modules = (
"tenancy",
"organizations",
"identity",
"idm",
"access",
"admin",
"dashboard",
"policy",
"audit",
"campaigns",
"files",
"mail",
"calendar",
"docs",
"ops",
)
with tempfile.TemporaryDirectory(prefix="govoplan-release-migration-") as tmp:
database_url = f"sqlite:///{Path(tmp) / 'release-integration.sqlite3'}"
result = migrate_database(database_url=database_url, enabled_modules=enabled_modules)
if not result.current_revision:
raise SystemExit("Migration smoke did not produce an Alembic revision.")
print(f"SQLite release migration smoke reached {result.current_revision}.")
PY
run_step "Clone release module test sources"
for repo in govoplan-mail govoplan-calendar govoplan-campaign; do
git clone --depth 1 --branch "$RELEASE_TAG" "git@git.add-ideas.de:add-ideas/${repo}.git" "$WORK_ROOT/$repo"
done
run_step "Run core backend release tests"
"$PYTHON" -m unittest \
tests.test_module_system \
tests.test_access_contracts \
tests.test_identity_organization_contracts \
tests.test_core_events \
tests.test_policy_contracts
"$PYTHON" scripts/check_dependency_boundaries.py
run_step "Run cloned module backend tests"
run_discovered_tests "$WORK_ROOT/govoplan-mail/tests"
run_discovered_tests "$WORK_ROOT/govoplan-calendar/tests"
run_discovered_tests "$WORK_ROOT/govoplan-campaign/tests"
run_step "Run core WebUI release tests and build"
cd "$ROOT/webui"
"$NPM" run test:mail-components
"$NPM" run test:module-capabilities
"$NPM" run test:module-permutations
"$NPM" run build
run_step "Run cloned module WebUI tests"
install_cloned_webui_dependencies "$WORK_ROOT/govoplan-mail/webui"
cd "$WORK_ROOT/govoplan-mail/webui"
"$NPM" run test:mail-ui
install_cloned_webui_dependencies "$WORK_ROOT/govoplan-campaign/webui"
cd "$WORK_ROOT/govoplan-campaign/webui"
"$NPM" run test:policy-ui
"$NPM" run test:template-preview
"$NPM" run test:import-utils
echo
echo "Release integration check passed."

View File

@@ -87,6 +87,7 @@ CATALOG_MODULES = (
name="Policy",
description="Policy and governance capability module.",
tags=("official", "platform-module"),
webui_package="@govoplan/policy-webui",
),
CatalogModule(
module_id="audit",
@@ -95,6 +96,7 @@ CATALOG_MODULES = (
name="Audit",
description="Audit-log storage and audit administration routes.",
tags=("official", "platform-module"),
webui_package="@govoplan/audit-webui",
),
CatalogModule(
module_id="dashboard",
@@ -253,8 +255,8 @@ def _catalog_payload(
if module.webui_package:
entry["webui_package"] = module.webui_package
entry["webui_ref"] = f"{repository_base}/{module.repo}.git#{module_tag}"
manifest_interfaces = _manifest_interface_metadata(manifest)
entry.update(manifest_interfaces)
manifest_metadata = _manifest_catalog_metadata(manifest)
entry.update(manifest_metadata)
if module.provides_interfaces:
entry["provides_interfaces"] = [dict(item) for item in module.provides_interfaces]
if module.requires_interfaces:
@@ -292,10 +294,36 @@ def _discovered_catalog_manifests() -> dict[str, ModuleManifest]:
return {}
def _manifest_interface_metadata(manifest: ModuleManifest | None) -> dict[str, object]:
def _manifest_catalog_metadata(manifest: ModuleManifest | None) -> dict[str, object]:
if manifest is None:
return {}
payload: dict[str, object] = {}
if manifest.dependencies:
payload["dependencies"] = list(manifest.dependencies)
if manifest.optional_dependencies:
payload["optional_dependencies"] = list(manifest.optional_dependencies)
if manifest.migration_spec is not None:
payload["migration_safety"] = "requires_review"
payload["migration_notes"] = "Module owns database migrations; review release notes and migration output before activation."
if manifest.migration_spec.migration_after:
payload["migration_after"] = list(manifest.migration_spec.migration_after)
if manifest.migration_spec.migration_before:
payload["migration_before"] = list(manifest.migration_spec.migration_before)
if manifest.migration_spec.migration_tasks:
tasks: list[dict[str, object]] = []
for task in manifest.migration_spec.migration_tasks:
task_payload: dict[str, object] = {
"task_id": task.task_id,
"phase": task.phase,
"summary": task.summary,
"task_version": task.task_version,
"safety": task.safety,
"idempotent": task.idempotent,
}
if task.timeout_seconds is not None:
task_payload["timeout_seconds"] = task.timeout_seconds
tasks.append(task_payload)
payload["migration_tasks"] = tasks
if manifest.provides_interfaces:
payload["provides_interfaces"] = [
{"name": item.name, "version": item.version}

View File

@@ -28,6 +28,7 @@ fail() {
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WEBUI="$ROOT/webui"
NPM_BIN="${NPM:-}"
NODE_BIN="${NODE:-}"
if [[ -z "$NPM_BIN" ]]; then
if [[ -x "/home/zemion/.nvm/versions/node/v22.22.3/bin/npm" ]]; then
@@ -36,6 +37,13 @@ if [[ -z "$NPM_BIN" ]]; then
NPM_BIN="npm"
fi
fi
if [[ -z "$NODE_BIN" ]]; then
if [[ -x "$(dirname "$NPM_BIN")/node" ]]; then
NODE_BIN="$(dirname "$NPM_BIN")/node"
else
NODE_BIN="node"
fi
fi
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -58,6 +66,7 @@ done
[[ -f "$WEBUI/package.release.json" ]] || fail "missing $WEBUI/package.release.json"
command -v "$NPM_BIN" >/dev/null 2>&1 || fail "npm executable not found: $NPM_BIN"
command -v "$NODE_BIN" >/dev/null 2>&1 || fail "node executable not found: $NODE_BIN"
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/govoplan-release-lock.XXXXXXXX")"
cleanup() {
@@ -75,6 +84,23 @@ echo "Temporary workspace: $TMP_DIR"
(
cd "$TMP_DIR"
PATH="$(dirname "$NPM_BIN"):$PATH" "$NPM_BIN" install --package-lock-only --ignore-scripts
mapfile -t GIT_PACKAGES < <(
PATH="$(dirname "$NODE_BIN"):$PATH" "$NODE_BIN" <<'NODE'
const fs = require("fs");
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
for (const group of ["dependencies", "devDependencies", "optionalDependencies"]) {
for (const [name, spec] of Object.entries(pkg[group] || {})) {
if (typeof spec === "string" && spec.startsWith("git+")) {
console.log(name);
}
}
}
NODE
)
if [[ "${#GIT_PACKAGES[@]}" -gt 0 ]]; then
echo "Refreshing git package lock entries: ${GIT_PACKAGES[*]}"
PATH="$(dirname "$NPM_BIN"):$PATH" "$NPM_BIN" update --package-lock-only --ignore-scripts "${GIT_PACKAGES[@]}"
fi
)
cp "$TMP_DIR/package-lock.json" "$WEBUI/package-lock.release.json"

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WEBUI_DIR="${1:-$ROOT/webui}"
WORK_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/govoplan-webui-release-deps.XXXXXXXX")"
GOVOPLAN_DEPS="$WORK_ROOT/govoplan-webui-deps.tsv"
export GOVOPLAN_DEPS
trap 'rm -rf "$WORK_ROOT"' EXIT
retry() {
local attempt status
for attempt in 1 2 3; do
if "$@"; then
return 0
fi
status=$?
if [[ "$attempt" == 3 ]]; then
return "$status"
fi
sleep $((attempt * 10))
done
}
cd "$WEBUI_DIR"
node <<'JS'
const fs = require("fs");
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
const rel = JSON.parse(fs.readFileSync("package.release.json", "utf8"));
const govoplanDeps = [];
const dependencies = {...(rel.dependencies || {})};
for (const [name, spec] of Object.entries(dependencies)) {
if (name.startsWith("@govoplan/")) {
govoplanDeps.push([name, spec]);
delete dependencies[name];
}
}
for (const key of ["devDependencies", "peerDependencies", "optionalDependencies", "overrides"]) {
if (rel[key]) pkg[key] = rel[key];
}
pkg.dependencies = dependencies;
fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\n");
fs.writeFileSync(process.env.GOVOPLAN_DEPS, govoplanDeps.map((item) => item.join("\t")).join("\n") + "\n");
JS
rm -f package-lock.json
npm cache clean --force
retry npm install --prefer-online
module_paths=()
while IFS=$'\t' read -r package_name spec; do
[[ -n "${package_name:-}" ]] || continue
git_url="${spec%%#*}"
git_ref="${spec#*#}"
if [[ "$git_url" == "$spec" || -z "$git_ref" ]]; then
echo "Unsupported GovOPlaN WebUI git spec for $package_name: $spec" >&2
exit 1
fi
clone_url="${git_url#git+}"
clone_dir="$WORK_ROOT/${package_name#@govoplan/}"
echo "Installing $package_name from $clone_url#$git_ref"
retry git clone --depth 1 --branch "$git_ref" "$clone_url" "$clone_dir"
module_paths+=("file:$clone_dir")
done < "$GOVOPLAN_DEPS"
if [[ "${#module_paths[@]}" -gt 0 ]]; then
retry npm install --prefer-online --no-save --install-links "${module_paths[@]}"
fi

View File

@@ -92,19 +92,39 @@ PARENT="$(dirname "$ROOT")"
PACKAGE_MODULE_REPOS=(
"$PARENT/govoplan-access"
"$PARENT/govoplan-admin"
"$PARENT/govoplan-tenancy"
"$PARENT/govoplan-organizations"
"$PARENT/govoplan-approvals"
"$PARENT/govoplan-assets"
"$PARENT/govoplan-audit"
"$PARENT/govoplan-booking"
"$PARENT/govoplan-calendar"
"$PARENT/govoplan-campaign"
"$PARENT/govoplan-certificates"
"$PARENT/govoplan-committee"
"$PARENT/govoplan-consultation"
"$PARENT/govoplan-contracts"
"$PARENT/govoplan-dashboard"
"$PARENT/govoplan-docs"
"$PARENT/govoplan-facilities"
"$PARENT/govoplan-files"
"$PARENT/govoplan-forms-runtime"
"$PARENT/govoplan-grants"
"$PARENT/govoplan-helpdesk"
"$PARENT/govoplan-identity"
"$PARENT/govoplan-idm"
"$PARENT/govoplan-policy"
"$PARENT/govoplan-audit"
"$PARENT/govoplan-dashboard"
"$PARENT/govoplan-files"
"$PARENT/govoplan-inspections"
"$PARENT/govoplan-issue-reporting"
"$PARENT/govoplan-learning"
"$PARENT/govoplan-mail"
"$PARENT/govoplan-campaign"
"$PARENT/govoplan-calendar"
"$PARENT/govoplan-docs"
"$PARENT/govoplan-ops"
"$PARENT/govoplan-organizations"
"$PARENT/govoplan-permits"
"$PARENT/govoplan-policy"
"$PARENT/govoplan-procurement"
"$PARENT/govoplan-records"
"$PARENT/govoplan-resources"
"$PARENT/govoplan-risk-compliance"
"$PARENT/govoplan-tenancy"
"$PARENT/govoplan-transparency"
)
TAG_ONLY_MODULE_REPOS=(
"$PARENT/govoplan-addresses"
@@ -120,6 +140,7 @@ TAG_ONLY_MODULE_REPOS=(
"$PARENT/govoplan-notifications"
"$PARENT/govoplan-payments"
"$PARENT/govoplan-portal"
"$PARENT/govoplan-postbox"
"$PARENT/govoplan-reporting"
"$PARENT/govoplan-scheduling"
"$PARENT/govoplan-search"
@@ -173,6 +194,9 @@ if [[ -z "$NPM_BIN" ]]; then
fi
fi
export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o BatchMode=yes -o ConnectTimeout=15}"
GIT_REMOTE_TIMEOUT="${GIT_REMOTE_TIMEOUT:-30}"
normalize_bump() {
local value="${1,,}"
case "$value" in
@@ -282,56 +306,20 @@ update_manifest_version() {
local repo="$1"
local project_name="$2"
local version="$3"
local manifest_path=""
local manifest_paths=()
case "$project_name" in
govoplan-core)
return 0
;;
govoplan-access)
manifest_path="$repo/src/govoplan_access/backend/manifest.py"
;;
govoplan-admin)
manifest_path="$repo/src/govoplan_admin/backend/manifest.py"
;;
govoplan-tenancy)
manifest_path="$repo/src/govoplan_tenancy/backend/manifest.py"
;;
govoplan-organizations)
manifest_path="$repo/src/govoplan_organizations/backend/manifest.py"
;;
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"
;;
govoplan-audit)
manifest_path="$repo/src/govoplan_audit/backend/manifest.py"
;;
govoplan-files)
manifest_path="$repo/src/govoplan_files/backend/manifest.py"
;;
govoplan-mail)
manifest_path="$repo/src/govoplan_mail/backend/manifest.py"
;;
govoplan-campaign)
manifest_path="$repo/src/govoplan_campaign/backend/manifest.py"
;;
govoplan-calendar)
manifest_path="$repo/src/govoplan_calendar/backend/manifest.py"
;;
*)
fail "unknown project for manifest version update: $project_name"
;;
esac
if [[ "$project_name" == "govoplan-core" ]]; then
return 0
fi
[[ -f "$manifest_path" ]] || fail "missing module manifest: $manifest_path"
while IFS= read -r -d '' manifest_path; do
manifest_paths+=("$manifest_path")
done < <(find "$repo/src" -path "*/backend/manifest.py" -print0 2>/dev/null)
"$PYTHON" - "$manifest_path" "$version" <<'PYCODE'
[[ "${#manifest_paths[@]}" -gt 0 ]] || fail "missing module manifest under $repo/src"
for manifest_path in "${manifest_paths[@]}"; do
"$PYTHON" - "$manifest_path" "$version" <<'PYCODE'
from __future__ import annotations
import pathlib
@@ -347,10 +335,18 @@ text, count = re.subn(
text,
count=1,
)
if count == 0:
text, count = re.subn(
r'(?m)^(MODULE_VERSION\s*=\s*)["\'][^"\']+["\'](\s*)$',
rf'\1"{new_version}"\2',
text,
count=1,
)
if count != 1:
raise SystemExit(f"could not update ModuleManifest.version in {path}")
path.write_text(text)
PYCODE
done
}
update_webui_package() {
@@ -729,7 +725,7 @@ for repo in "${REPOS[@]}"; do
fi
set +e
git -C "$repo" ls-remote --exit-code --tags "$REMOTE" "refs/tags/$TAG" >/dev/null
timeout "$GIT_REMOTE_TIMEOUT" git -C "$repo" ls-remote --exit-code --tags "$REMOTE" "refs/tags/$TAG" >/dev/null
ls_remote_status=$?
set -e
@@ -790,9 +786,14 @@ for repo in "${MODULE_REPOS[@]}"; do
if [[ "$DRY_RUN" -eq 0 ]]; then
if git -C "$repo" diff --cached --quiet; then
if [[ "${REPO_KINDS[$repo]}" == "package" ]]; then
fail "no staged changes for ${PROJECT_NAMES[$repo]} after version bump"
if [[ "$TARGET_VERSION" == "${OLD_VERSIONS[$repo]}" ]]; then
echo "No staged changes for ${PROJECT_NAMES[$repo]}; version is already $TARGET_VERSION, tagging current HEAD."
else
fail "no staged changes for ${PROJECT_NAMES[$repo]} after version bump"
fi
else
echo "No staged changes for ${PROJECT_NAMES[$repo]}; tagging current HEAD."
fi
echo "No staged changes for ${PROJECT_NAMES[$repo]}; tagging current HEAD."
else
run git -C "$repo" commit -m "$COMMIT_MESSAGE"
fi

View File

@@ -1,9 +1,17 @@
from __future__ import annotations
import argparse
import json
from pathlib import Path
from govoplan_core.db.bootstrap import bootstrap_dev_data
from govoplan_core.db.migrations import migrate_database
from govoplan_core.db.migrations import (
ModuleMigrationTaskExecutionError,
POST_MIGRATION_TASK_PHASES,
PRE_MIGRATION_TASK_PHASES,
migrate_database,
run_registered_module_migration_tasks,
)
from govoplan_core.db.session import configure_database, get_database
from govoplan_core.settings import settings
@@ -11,12 +19,43 @@ from govoplan_core.settings import settings
def main() -> None:
parser = argparse.ArgumentParser(description="Initialize the GovOPlaN database")
parser.add_argument("--database-url", default=settings.database_url, help="Database URL to migrate")
parser.add_argument("--enabled-module", action="append", default=[], help="Target enabled module id used to discover module migrations; may be repeated.")
parser.add_argument("--migration-module", action="append", default=[], help="Module id whose migration heads should be upgraded in this order before final heads.")
parser.add_argument("--migration-task-record-output", type=Path, help="Write executed module migration task records to this JSON file.")
parser.add_argument("--with-dev-data", action="store_true", help="Create default tenant/user/roles and a development API key")
parser.add_argument("--dev-api-key", default=settings.dev_bootstrap_api_key, help="Development API key secret to create")
args = parser.parse_args()
configure_database(args.database_url)
migration = migrate_database(database_url=args.database_url)
enabled_modules = tuple(args.enabled_module) if args.enabled_module else None
migration_order = tuple(args.migration_module) if args.migration_module else None
task_records: list[dict[str, object]] = []
try:
_run_migration_tasks(
task_records,
database_url=args.database_url,
enabled_modules=enabled_modules,
migration_order=migration_order,
phases=PRE_MIGRATION_TASK_PHASES,
)
migration = migrate_database(
database_url=args.database_url,
enabled_modules=enabled_modules,
migration_module_order=migration_order,
)
_run_migration_tasks(
task_records,
database_url=args.database_url,
enabled_modules=enabled_modules,
migration_order=migration_order,
phases=POST_MIGRATION_TASK_PHASES,
)
finally:
if args.migration_task_record_output:
args.migration_task_record_output.parent.mkdir(parents=True, exist_ok=True)
args.migration_task_record_output.write_text(json.dumps(task_records, indent=2, sort_keys=True) + "\n", encoding="utf-8")
if task_records:
print(f"Executed {len(task_records)} module migration task(s).")
if migration.reconciled_revision:
print(f"Reconciled legacy database marker to {migration.reconciled_revision}.")
print(f"Database schema upgraded to {migration.current_revision}.")
@@ -33,5 +72,26 @@ def main() -> None:
print("Development API key already exists or was not requested.")
def _run_migration_tasks(
target: list[dict[str, object]],
*,
database_url: str,
enabled_modules: tuple[str, ...] | None,
migration_order: tuple[str, ...] | None,
phases: tuple[str, ...],
) -> None:
try:
records = run_registered_module_migration_tasks(
database_url=database_url,
enabled_modules=enabled_modules,
migration_module_order=migration_order,
phases=phases,
)
except ModuleMigrationTaskExecutionError as exc:
target.extend(exc.records)
raise
target.extend(records)
if __name__ == "__main__":
main()

View File

@@ -85,6 +85,9 @@ AccessProvenanceKind = Literal[
"identity",
"account",
"tenant_membership",
"resource",
"owner",
"share",
"organization_unit",
"function",
"group",
@@ -496,6 +499,20 @@ class ResourceAccessService(Protocol):
...
@runtime_checkable
class ResourceAccessExplanationProvider(Protocol):
def explain_resource_provenance(
self,
session: object,
principal: PrincipalRef,
*,
resource_type: str,
resource_id: str,
action: str,
) -> Sequence[AccessDecisionProvenance]:
...
@runtime_checkable
class AccessExplanationService(Protocol):
def explain_scope_provenance(

View File

@@ -0,0 +1,13 @@
from __future__ import annotations
from typing import Protocol, runtime_checkable
from govoplan_core.core.access import ResourceAccessExplanationProvider
CAPABILITY_FILES_ACCESS = "files.access"
@runtime_checkable
class FileAccessProvider(ResourceAccessExplanationProvider, Protocol):
"""Resource-level access explanation provider for Files-owned resources."""

View File

@@ -12,6 +12,29 @@ from govoplan_core.core.registry import PlatformRegistry
class MigrationMetadataPlan:
metadata: tuple[MetaData, ...]
script_locations: tuple[str, ...]
modules: tuple[ModuleMigrationMetadata, ...] = ()
@dataclass(frozen=True, slots=True)
class ModuleMigrationMetadata:
module_id: str
script_location: str | None = None
has_metadata: bool = False
migration_after: tuple[str, ...] = ()
migration_before: tuple[str, ...] = ()
migration_tasks: tuple[ModuleMigrationTaskMetadata, ...] = ()
@dataclass(frozen=True, slots=True)
class ModuleMigrationTaskMetadata:
task_id: str
phase: str
summary: str
task_version: str = "1"
safety: str = "automatic"
idempotent: bool = True
timeout_seconds: int | None = None
has_executor: bool = False
def migration_metadata_plan(registry: PlatformRegistry, *, extra_metadata: Iterable[MetaData] = ()) -> MigrationMetadataPlan:
@@ -28,4 +51,28 @@ def migration_metadata_plan(registry: PlatformRegistry, *, extra_metadata: Itera
metadata.append(spec.metadata)
if spec.script_location:
script_locations.append(spec.script_location)
return MigrationMetadataPlan(metadata=tuple(metadata), script_locations=tuple(script_locations))
modules = tuple(
ModuleMigrationMetadata(
module_id=manifest.id,
script_location=manifest.migration_spec.script_location,
has_metadata=isinstance(manifest.migration_spec.metadata, MetaData),
migration_after=tuple(manifest.migration_spec.migration_after),
migration_before=tuple(manifest.migration_spec.migration_before),
migration_tasks=tuple(
ModuleMigrationTaskMetadata(
task_id=task.task_id,
phase=task.phase,
summary=task.summary,
task_version=task.task_version,
safety=task.safety,
idempotent=task.idempotent,
timeout_seconds=task.timeout_seconds,
has_executor=task.executor is not None,
)
for task in manifest.migration_spec.migration_tasks
),
)
for manifest in registry.manifests()
if manifest.migration_spec is not None
)
return MigrationMetadataPlan(metadata=tuple(metadata), script_locations=tuple(script_locations), modules=modules)

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,7 @@ MODULE_SETTINGS_KEY = "module_management"
INSTALL_PLAN_KEY = "install_plan"
REQUIRED_PLATFORM_MODULES = ("access",)
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin")
INSTALL_PLAN_ACTIONS = ("install", "uninstall")
INSTALL_PLAN_ACTIONS = ("install", "update", "uninstall")
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
INSTALL_PLAN_SOURCES = ("manual", "catalog")
LOCAL_DEPENDENCY_REF_PREFIXES = ("file:", "path:", "workspace:", "link:")
@@ -46,6 +46,7 @@ class ModuleInstallPlanItem:
webui_package: str | None = None
webui_ref: str | None = None
artifact_integrity: Mapping[str, object] | None = None
data_safety_acknowledged: bool = False
destroy_data: bool = False
status: str = "planned"
notes: str | None = None
@@ -59,6 +60,8 @@ class ModuleInstallPlanItem:
}
if self.destroy_data:
payload["destroy_data"] = True
if self.data_safety_acknowledged:
payload["data_safety_acknowledged"] = True
if self.catalog:
payload["catalog"] = dict(self.catalog)
for key in ("python_package", "python_ref", "webui_package", "webui_ref", "notes"):
@@ -206,7 +209,7 @@ def module_install_plan_commands(
for item in items:
if item.status not in included_statuses:
continue
if item.action == "install":
if item.action in {"install", "update"}:
if item.python_ref:
commands.append(f"python -m pip install {shlex.quote(item.python_ref)}")
if item.webui_package and item.webui_ref:
@@ -281,7 +284,7 @@ def desired_modules_after_package_plan(
removed = {item.module_id for item in planned_items if item.action == "uninstall"}
desired = [module_id for module_id in desired if module_id not in removed]
if activate_installed_modules:
desired.extend(item.module_id for item in planned_items if item.action == "install")
desired.extend(item.module_id for item in planned_items if item.action in {"install", "update"})
return tuple(dict.fromkeys(desired))
@@ -305,6 +308,7 @@ def normalize_module_install_plan_item(
webui_package = _clean_optional_string(raw.get("webui_package"))
webui_ref = _clean_optional_string(raw.get("webui_ref"))
artifact_integrity = _clean_optional_mapping(raw.get("artifact_integrity"), field="artifact_integrity", module_id=module_id)
data_safety_acknowledged = _clean_bool(raw.get("data_safety_acknowledged"))
destroy_data = _clean_bool(raw.get("destroy_data"))
notes = _clean_optional_string(raw.get("notes"))
@@ -314,13 +318,13 @@ def normalize_module_install_plan_item(
raise ModuleManagementError(f"Unsupported install plan status for {module_id!r}: {status!r}.")
if source not in INSTALL_PLAN_SOURCES:
raise ModuleManagementError(f"Unsupported install plan source for {module_id!r}: {source!r}.")
if action == "install" and not python_ref:
if action in {"install", "update"} and not python_ref:
raise ModuleManagementError(f"Install plan item {module_id!r} needs a Python package reference.")
if action == "uninstall" and not python_package:
raise ModuleManagementError(f"Uninstall plan item {module_id!r} needs a Python package name.")
if action != "uninstall" and destroy_data:
raise ModuleManagementError(f"Install plan item {module_id!r} can only destroy data during uninstall.")
if action == "install" and bool(webui_package) != bool(webui_ref):
if action in {"install", "update"} and bool(webui_package) != bool(webui_ref):
raise ModuleManagementError(f"Install plan item {module_id!r} needs both WebUI package and WebUI reference, or neither.")
if action == "uninstall" and webui_ref and not webui_package:
raise ModuleManagementError(f"Uninstall plan item {module_id!r} has a WebUI reference but no WebUI package.")
@@ -339,6 +343,7 @@ def normalize_module_install_plan_item(
webui_package=webui_package,
webui_ref=webui_ref,
artifact_integrity=artifact_integrity,
data_safety_acknowledged=data_safety_acknowledged,
destroy_data=destroy_data,
status=status,
notes=notes,

View File

@@ -19,6 +19,13 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey,
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_]*)+$")
CATALOG_MIGRATION_SAFETY = ("automatic", "requires_review", "forward_only", "destructive")
CATALOG_MIGRATION_TASK_PHASES = (
"pre_migration_check",
"pre_migration_prepare",
"post_migration_backfill",
"post_migration_verify",
)
def module_package_catalog(
@@ -598,7 +605,7 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
raise ValueError("Module package catalog entries must be objects.")
module_id = _required_str(value, "module_id")
action = str(value.get("action") or "install")
if action not in {"install", "uninstall"}:
if action not in {"install", "update", "uninstall"}:
raise ValueError(f"Unsupported catalog action for {module_id!r}: {action!r}")
item = {
"module_id": module_id,
@@ -606,6 +613,21 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
"description": _optional_str(value, "description"),
"version": _optional_str(value, "version"),
"action": action,
"dependencies": _string_list(value.get("dependencies")),
"optional_dependencies": _string_list(value.get("optional_dependencies")),
"migration_safety": _catalog_migration_safety(value.get("migration_safety"), module_id=module_id),
"migration_notes": _optional_str(value, "migration_notes"),
"migration_after": _string_list(value.get("migration_after")),
"migration_before": _string_list(value.get("migration_before")),
"migration_tasks": _normalize_catalog_migration_tasks(value.get("migration_tasks"), module_id=module_id),
"current_version_min": _optional_str(value, "current_version_min"),
"current_version_max_exclusive": _optional_str(value, "current_version_max_exclusive"),
"bridge_release": _optional_bool(value, "bridge_release"),
"bridge_notes": _optional_str(value, "bridge_notes"),
"allow_downgrade": _optional_bool(value, "allow_downgrade"),
"allow_same_version": _optional_bool(value, "allow_same_version"),
"recovery_tested": _optional_bool(value, "recovery_tested"),
"recovery_notes": _optional_str(value, "recovery_notes"),
"python_package": _optional_str(value, "python_package"),
"python_ref": _optional_str(value, "python_ref"),
"webui_package": _optional_str(value, "webui_package"),
@@ -616,12 +638,91 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
"notes": _optional_str(value, "notes"),
"tags": _string_list(value.get("tags")),
}
if not version_range_is_valid(
version_min=item["current_version_min"] if isinstance(item["current_version_min"], str) else None,
version_max_exclusive=item["current_version_max_exclusive"] if isinstance(item["current_version_max_exclusive"], str) else None,
):
version_range = format_version_range(
version_min=item["current_version_min"] if isinstance(item["current_version_min"], str) else None,
version_max_exclusive=item["current_version_max_exclusive"] if isinstance(item["current_version_max_exclusive"], str) else None,
)
raise ValueError(f"Module package catalog entry {module_id!r} has invalid current-version range {version_range!r}.")
artifact_integrity = _normalize_artifact_integrity(value.get("artifact_integrity"))
if artifact_integrity:
item["artifact_integrity"] = artifact_integrity
return item
def _catalog_migration_safety(value: Any, *, module_id: str) -> str:
if value is None:
return "automatic"
safety = str(value).strip()
if safety not in CATALOG_MIGRATION_SAFETY:
allowed = ", ".join(CATALOG_MIGRATION_SAFETY)
raise ValueError(f"Unsupported migration_safety for {module_id!r}: {safety!r}; expected one of {allowed}.")
return safety
def _normalize_catalog_migration_tasks(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 migration_tasks 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 migration_tasks entries for {module_id!r} must be objects.")
task_id = _catalog_task_required_str(raw, "task_id", module_id=module_id)
if task_id in seen:
raise ValueError(f"Module package catalog entry {module_id!r} declares migration task {task_id!r} more than once.")
seen.add(task_id)
phase = _catalog_task_required_str(raw, "phase", module_id=module_id)
if phase not in CATALOG_MIGRATION_TASK_PHASES:
allowed = ", ".join(CATALOG_MIGRATION_TASK_PHASES)
raise ValueError(f"Unsupported migration task phase for {module_id!r}/{task_id!r}: {phase!r}; expected one of {allowed}.")
summary = _catalog_task_required_str(raw, "summary", module_id=module_id)
safety = _catalog_migration_safety(raw.get("safety"), module_id=module_id)
item: dict[str, object] = {
"task_id": task_id,
"phase": phase,
"summary": summary,
"task_version": _optional_str(raw, "task_version") or "1",
"safety": safety,
"idempotent": _catalog_optional_bool_default(raw, "idempotent", default=True),
}
timeout_seconds = _catalog_optional_positive_int(raw, "timeout_seconds", module_id=module_id, task_id=task_id)
if timeout_seconds is not None:
item["timeout_seconds"] = timeout_seconds
normalized.append(item)
return normalized
def _catalog_task_required_str(value: dict[str, Any], key: str, *, module_id: str) -> str:
item = _optional_str(value, key)
if not item:
raise ValueError(f"Module package catalog migration task for {module_id!r} is missing {key!r}.")
return item
def _catalog_optional_bool_default(value: dict[str, Any], key: str, *, default: bool) -> bool:
if key not in value:
return default
return _optional_bool(value, key)
def _catalog_optional_positive_int(value: dict[str, Any], key: str, *, module_id: str, task_id: str) -> int | None:
if value.get(key) is None:
return None
try:
integer = int(value[key])
except (TypeError, ValueError) as exc:
raise ValueError(f"Module package catalog migration task {module_id!r}/{task_id!r} has invalid {key!r}.") from exc
if integer <= 0:
raise ValueError(f"Module package catalog migration task {module_id!r}/{task_id!r} requires positive {key!r}.")
return integer
def _required_str(value: dict[str, Any], key: str) -> str:
item = _optional_str(value, key)
if not item:

View File

@@ -13,6 +13,14 @@ SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION = "1"
PermissionLevel = Literal["system", "tenant"]
SubjectType = Literal["account", "membership", "group", "service_account", "tenant"]
MigrationTaskPhase = Literal[
"pre_migration_check",
"pre_migration_prepare",
"post_migration_backfill",
"post_migration_verify",
]
MigrationTaskSafety = Literal["automatic", "requires_review", "forward_only", "destructive"]
MigrationTaskStatus = Literal["ok", "warning", "blocked", "skipped"]
@dataclass(frozen=True, slots=True)
@@ -91,11 +99,49 @@ MigrationRetirementExecutor = Callable[[object, str], None]
MigrationRetirementProvider = Callable[[object | None, str], MigrationRetirementPlan]
@dataclass(frozen=True, slots=True)
class ModuleMigrationTaskContext:
module_id: str
task_id: str
phase: MigrationTaskPhase
database_url: str
target_version: str | None = None
session: object | None = None
dry_run: bool = False
metadata: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ModuleMigrationTaskResult:
status: MigrationTaskStatus = "ok"
message: str | None = None
details: Mapping[str, Any] = field(default_factory=dict)
ModuleMigrationTaskExecutor = Callable[[ModuleMigrationTaskContext], ModuleMigrationTaskResult | None]
@dataclass(frozen=True, slots=True)
class ModuleMigrationTask:
task_id: str
phase: MigrationTaskPhase
summary: str
task_version: str = "1"
safety: MigrationTaskSafety = "automatic"
idempotent: bool = True
timeout_seconds: int | None = None
executor: ModuleMigrationTaskExecutor | None = None
metadata: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class MigrationSpec:
module_id: str
metadata: object | None = None
script_location: str | None = None
migration_after: tuple[str, ...] = ()
migration_before: tuple[str, ...] = ()
migration_tasks: tuple[ModuleMigrationTask, ...] = ()
retirement_supported: bool = False
retirement_provider: MigrationRetirementProvider | None = None
retirement_notes: str | None = None
@@ -215,7 +261,28 @@ class ResourceAclProvider(Protocol):
TenantSummaryProvider = Callable[[object, str], Mapping[str, int]]
DeleteVetoProvider = Callable[[object, str, str], None]
@dataclass(frozen=True, slots=True)
class DeleteVetoIssue:
severity: Literal["blocker", "warning", "info"]
code: str
message: str
module_id: str | None = None
details: Mapping[str, Any] = field(default_factory=dict)
DeleteVetoProviderResult = DeleteVetoIssue | Iterable[DeleteVetoIssue] | None
DeleteVetoProvider = Callable[[object, str, str], DeleteVetoProviderResult]
@dataclass(frozen=True, slots=True)
class DeleteVetoProviderRegistration:
module_id: str
resource_type: str
provider: DeleteVetoProvider
RouteFactory = Callable[[ModuleContext], "APIRouter"]
CapabilityFactory = Callable[[ModuleContext], object]
DocumentationProvider = Callable[[DocumentationContext], Iterable[DocumentationTopic]]

View File

@@ -3,11 +3,13 @@ from __future__ import annotations
import re
from collections import defaultdict, deque
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from dataclasses import dataclass, replace
from govoplan_core.core.modules import (
CapabilityFactory,
DeleteVetoIssue,
DeleteVetoProvider,
DeleteVetoProviderRegistration,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleContext,
@@ -45,7 +47,7 @@ class PlatformRegistry:
def __init__(self) -> None:
self._manifests: dict[str, ModuleManifest] = {}
self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {}
self._delete_veto_providers: dict[str, list[DeleteVetoProvider]] = defaultdict(list)
self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = defaultdict(list)
self._capability_factories: dict[str, CapabilityFactory] = {}
self._capabilities: dict[str, object] = {}
self._capability_context: ModuleContext | None = None
@@ -58,7 +60,7 @@ class PlatformRegistry:
self.register_tenant_summary_provider(manifest.id, provider)
for resource_type, providers in manifest.delete_veto_providers.items():
for provider in providers:
self.register_delete_veto(resource_type, provider)
self.register_delete_veto(manifest.id, resource_type, provider)
for name, factory in manifest.capability_factories.items():
self.register_capability_factory(manifest.id, name, factory)
return manifest
@@ -148,12 +150,39 @@ class PlatformRegistry:
def tenant_summary_providers(self) -> Mapping[str, TenantSummaryProvider]:
return dict(self._tenant_summary_providers)
def register_delete_veto(self, resource_type: str, provider: DeleteVetoProvider) -> None:
self._delete_veto_providers[resource_type].append(provider)
def register_delete_veto(self, module_id: str, resource_type: str, provider: DeleteVetoProvider) -> None:
self._delete_veto_providers[resource_type].append(DeleteVetoProviderRegistration(
module_id=module_id,
resource_type=resource_type,
provider=provider,
))
def delete_veto_providers(self, resource_type: str) -> tuple[DeleteVetoProvider, ...]:
return tuple(registration.provider for registration in self._delete_veto_providers.get(resource_type, ()))
def delete_veto_provider_registrations(self, resource_type: str) -> tuple[DeleteVetoProviderRegistration, ...]:
return tuple(self._delete_veto_providers.get(resource_type, ()))
def collect_delete_veto_issues(self, resource_type: str, session: object, tenant_id: str, resource_id: str) -> tuple[DeleteVetoIssue, ...]:
issues: list[DeleteVetoIssue] = []
for registration in self.delete_veto_provider_registrations(resource_type):
try:
result = registration.provider(session, tenant_id, resource_id)
except Exception as exc:
issues.append(DeleteVetoIssue(
severity="blocker",
code="module_veto",
message=str(exc),
module_id=registration.module_id,
details={
"resource_type": resource_type,
"resource_id": resource_id,
},
))
continue
issues.extend(_normalize_delete_veto_result(result, registration=registration, resource_id=resource_id))
return tuple(issues)
def validate(self) -> RegistrySnapshot:
ordered = tuple(self._topologically_sorted())
available_capabilities = set(self._capability_factories)
@@ -224,6 +253,52 @@ class PlatformRegistry:
return (self._manifests[module_id] for module_id in ordered)
def _normalize_delete_veto_result(
result: object,
*,
registration: DeleteVetoProviderRegistration,
resource_id: str,
) -> tuple[DeleteVetoIssue, ...]:
if result is None:
return ()
if isinstance(result, DeleteVetoIssue):
return (_attribute_delete_veto_issue(result, registration=registration, resource_id=resource_id),)
if isinstance(result, str):
return (DeleteVetoIssue(
severity="blocker",
code="module_veto",
message=result,
module_id=registration.module_id,
details={
"resource_type": registration.resource_type,
"resource_id": resource_id,
},
),)
if isinstance(result, Iterable):
return tuple(
_attribute_delete_veto_issue(issue, registration=registration, resource_id=resource_id)
for issue in result
if isinstance(issue, DeleteVetoIssue)
)
return ()
def _attribute_delete_veto_issue(
issue: DeleteVetoIssue,
*,
registration: DeleteVetoProviderRegistration,
resource_id: str,
) -> DeleteVetoIssue:
details = {
"resource_type": registration.resource_type,
"resource_id": resource_id,
**dict(issue.details),
}
if issue.module_id == registration.module_id and issue.details == details:
return issue
return replace(issue, module_id=issue.module_id or registration.module_id, details=details)
def _validate_manifest_shape(manifest: ModuleManifest) -> None:
if not _MODULE_ID_RE.match(manifest.id):
raise RegistryError(f"Module manifest id must match {_MODULE_ID_RE.pattern}: {manifest.id!r}")

View File

@@ -1,19 +1,24 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
import json
import os
from pathlib import Path
from typing import Any
from alembic import command
from alembic.config import Config
from alembic.runtime.migration import MigrationContext
from alembic.script import ScriptDirectory
from sqlalchemy import create_engine, inspect, text
from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.db.session import configure_database
from govoplan_core.core.modules import ModuleMigrationTaskContext, ModuleMigrationTaskResult
from govoplan_core.db.session import configure_database, get_database
from govoplan_core.server.default_config import get_server_config
from govoplan_core.server.config import ManifestFactory
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
@@ -33,6 +38,9 @@ REVISION_CORE_CHANGE_SEQUENCE = "3f4a5b6c7d8e"
REVISION_HIERARCHICAL_SETTINGS = "f5a6b7c8d9e0"
LEGACY_SCOPE_TABLE = "tenancy_tenants"
CORE_SCOPE_TABLE = "core_scopes"
PRE_MIGRATION_TASK_PHASES = ("pre_migration_check", "pre_migration_prepare")
POST_MIGRATION_TASK_PHASES = ("post_migration_backfill", "post_migration_verify")
MIGRATION_TASK_PHASES = (*PRE_MIGRATION_TASK_PHASES, *POST_MIGRATION_TASK_PHASES)
_NAMESPACE_TABLE_RENAMES = (
("tenants", "tenancy_tenants"),
@@ -160,12 +168,31 @@ class MigrationResult:
current_revision: str | None
class ModuleMigrationTaskExecutionError(RuntimeError):
def __init__(self, message: str, *, records: tuple[dict[str, object], ...]) -> None:
super().__init__(message)
self.records = records
def registered_module_migration_plan(
database_url: str | None = None,
*,
enabled_modules: tuple[str, ...] | list[str] | None = None,
manifest_factories: tuple[ManifestFactory, ...] = (),
) -> MigrationMetadataPlan:
return migration_metadata_plan(_registered_module_registry(
database_url=database_url,
enabled_modules=enabled_modules,
manifest_factories=manifest_factories,
))
def _registered_module_registry(
*,
database_url: str | None = None,
enabled_modules: tuple[str, ...] | list[str] | None = None,
manifest_factories: tuple[ManifestFactory, ...] = (),
):
server_config = get_server_config()
active_database_url = database_url or settings.database_url
if active_database_url:
@@ -187,11 +214,147 @@ def registered_module_migration_plan(
active_enabled_modules,
manifest_factories=active_manifest_factories,
)
return migration_metadata_plan(registry)
return registry
def run_registered_module_migration_tasks(
*,
database_url: str | None = None,
enabled_modules: tuple[str, ...] | list[str] | None = None,
migration_module_order: tuple[str, ...] | list[str] | None = None,
phases: tuple[str, ...] | list[str] = MIGRATION_TASK_PHASES,
dry_run: bool = False,
manifest_factories: tuple[ManifestFactory, ...] = (),
) -> tuple[dict[str, object], ...]:
active_phases = tuple(dict.fromkeys(str(item).strip() for item in phases if str(item).strip()))
invalid_phases = tuple(phase for phase in active_phases if phase not in MIGRATION_TASK_PHASES)
if invalid_phases:
raise ValueError("Unsupported module migration task phase(s): " + ", ".join(invalid_phases))
url = database_url or settings.database_url
registry = _registered_module_registry(
database_url=url,
enabled_modules=enabled_modules,
manifest_factories=manifest_factories,
)
manifests = {manifest.id: manifest for manifest in registry.manifests()}
ordered_ids = tuple(dict.fromkeys([
*(str(item).strip() for item in (migration_module_order or ()) if str(item).strip()),
*manifests.keys(),
]))
records: list[dict[str, object]] = []
database = get_database()
with database.SessionLocal() as session:
for phase in active_phases:
for module_id in ordered_ids:
manifest = manifests.get(module_id)
if manifest is None or manifest.migration_spec is None:
continue
for task in manifest.migration_spec.migration_tasks:
if task.phase != phase:
continue
record: dict[str, object] = {
"module_id": manifest.id,
"task_id": task.task_id,
"phase": task.phase,
"summary": task.summary,
"task_version": task.task_version,
"safety": task.safety,
"idempotent": task.idempotent,
"dry_run": dry_run,
}
if task.timeout_seconds is not None:
record["timeout_seconds"] = task.timeout_seconds
if not task.idempotent:
record.update({"status": "blocked", "message": "Task is not idempotent."})
records.append(record)
raise ModuleMigrationTaskExecutionError(
f"Module migration task {manifest.id}/{task.task_id} is not idempotent.",
records=tuple(records),
)
if dry_run:
record.update({"status": "skipped", "message": "Dry run; executor was not called."})
records.append(record)
continue
if task.executor is None:
record.update({"status": "blocked", "message": "Task has no executor."})
records.append(record)
raise ModuleMigrationTaskExecutionError(
f"Module migration task {manifest.id}/{task.task_id} has no executor.",
records=tuple(records),
)
context = ModuleMigrationTaskContext(
module_id=manifest.id,
task_id=task.task_id,
phase=task.phase,
database_url=url,
target_version=manifest.version,
session=session,
dry_run=dry_run,
metadata=task.metadata,
)
try:
result = task.executor(context)
normalized = _normalize_migration_task_result(result)
except Exception as exc:
session.rollback()
record.update({
"status": "blocked",
"message": f"{type(exc).__name__}: {exc}",
})
records.append(record)
raise ModuleMigrationTaskExecutionError(
f"Module migration task {manifest.id}/{task.task_id} failed.",
records=tuple(records),
) from exc
record.update({
"status": normalized.status,
"message": normalized.message,
"details": _jsonable_migration_task_details(normalized.details),
})
records.append(record)
if normalized.status == "blocked":
session.rollback()
raise ModuleMigrationTaskExecutionError(
normalized.message or f"Module migration task {manifest.id}/{task.task_id} blocked migration.",
records=tuple(records),
)
session.commit()
return tuple(records)
def _normalize_migration_task_result(result: ModuleMigrationTaskResult | None) -> ModuleMigrationTaskResult:
if result is None:
return ModuleMigrationTaskResult()
if not isinstance(result, ModuleMigrationTaskResult):
raise TypeError("Module migration task executors must return ModuleMigrationTaskResult or None.")
if result.status not in {"ok", "warning", "blocked", "skipped"}:
raise ValueError(f"Unsupported module migration task status: {result.status!r}")
return result
def _jsonable_migration_task_details(value: Mapping[str, Any]) -> Mapping[str, Any] | str:
try:
json.dumps(value)
except (TypeError, ValueError):
return str(dict(value))
return value
def _repo_root() -> Path:
return Path(__file__).resolve().parents[3]
packaged_root = Path(__file__).resolve().parents[3]
configured = os.environ.get("GOVOPLAN_CORE_SOURCE_ROOT")
candidates = [
Path(configured).expanduser() if configured else None,
Path.cwd(),
packaged_root,
]
for candidate in candidates:
if candidate is None:
continue
resolved = candidate.resolve()
if (resolved / "alembic.ini").exists() and (resolved / "alembic").is_dir():
return resolved
return packaged_root
def alembic_config(
@@ -505,6 +668,7 @@ def migrate_database(
database_url: str | None = None,
reconcile_legacy_schema: bool = True,
enabled_modules: tuple[str, ...] | list[str] | None = None,
migration_module_order: tuple[str, ...] | list[str] | None = None,
manifest_factories: tuple[ManifestFactory, ...] = (),
) -> MigrationResult:
url = database_url or settings.database_url
@@ -513,14 +677,19 @@ def migrate_database(
reconcile_change_sequence_retention_floor_drift(url)
previous = database_revision(url)
reconciled = reconcile_legacy_create_all_schema(url) if reconcile_legacy_schema else None
command.upgrade(
alembic_config(
database_url=url,
config = alembic_config(
database_url=url,
enabled_modules=enabled_modules,
manifest_factories=manifest_factories,
)
if migration_module_order:
plan = registered_module_migration_plan(
url,
enabled_modules=enabled_modules,
manifest_factories=manifest_factories,
),
"heads",
)
)
_upgrade_ordered_module_heads(config, plan, tuple(migration_module_order))
command.upgrade(config, "heads")
if reconcile_legacy_schema:
reconcile_scope_table_retirement_drift(url)
current = database_revision(url)
@@ -529,3 +698,43 @@ def migrate_database(
reconciled_revision=reconciled,
current_revision=current,
)
def _upgrade_ordered_module_heads(
config: Config,
plan: MigrationMetadataPlan,
module_order: tuple[str, ...],
) -> None:
module_heads = _module_heads_by_id(config, plan)
upgraded: set[str] = set()
for module_id in dict.fromkeys(module_order):
for head in module_heads.get(module_id, ()):
if head in upgraded:
continue
command.upgrade(config, head)
upgraded.add(head)
def _module_heads_by_id(config: Config, plan: MigrationMetadataPlan) -> dict[str, tuple[str, ...]]:
script = ScriptDirectory.from_config(config)
heads = script.get_heads()
module_locations = {
item.module_id: Path(item.script_location).resolve()
for item in plan.modules
if item.script_location
}
grouped: dict[str, list[str]] = {module_id: [] for module_id in module_locations}
for head in heads:
revision = script.get_revision(head)
path_text = getattr(revision, "path", None)
if revision is None or not path_text:
continue
revision_path = Path(path_text).resolve()
for module_id, location in module_locations.items():
try:
revision_path.relative_to(location)
except ValueError:
continue
grouped[module_id].append(head)
break
return {module_id: tuple(sorted(values)) for module_id, values in grouped.items() if values}

View File

@@ -6,6 +6,42 @@ from govoplan_core.security.permissions import scopes_grant
LEGACY_TO_MODULE_SCOPES: dict[str, str] = {
"admin:users:read": "access:membership:read",
"admin:users:create": "access:membership:create",
"admin:users:update": "access:membership:update",
"admin:users:suspend": "access:membership:update",
"admin:groups:read": "access:group:read",
"admin:groups:write": "access:group:write",
"admin:groups:manage_members": "access:group:manage_members",
"admin:roles:read": "access:role:read",
"admin:roles:write": "access:role:write",
"admin:roles:assign": "access:role:assign",
"admin:api_keys:read": "access:api_key:read",
"admin:api_keys:create": "access:api_key:create",
"admin:api_keys:revoke": "access:api_key:revoke",
"admin:settings:read": "access:setting:read",
"admin:settings:write": "access:setting:write",
"admin:policies:read": "access:policy:read",
"admin:policies:write": "access:policy:write",
"system:tenants:read": "access:tenant:read",
"system:tenants:create": "access:tenant:create",
"system:tenants:update": "access:tenant:update",
"system:tenants:suspend": "access:tenant:suspend",
"system:accounts:read": "access:account:read",
"system:accounts:create": "access:account:create",
"system:accounts:update": "access:account:update",
"system:accounts:suspend": "access:account:suspend",
"system:roles:read": "access:system_role:read",
"system:roles:write": "access:system_role:write",
"system:roles:assign": "access:system_role:assign",
"system:access:read": "access:system_role:read",
"system:access:assign": "access:system_role:assign",
"system:audit:read": "access:audit:read",
"system:settings:read": "access:system_setting:read",
"system:settings:write": "access:system_setting:write",
"system:maintenance:access": "access:maintenance:access",
"system:governance:read": "access:governance:read",
"system:governance:write": "access:governance:write",
"campaign:read": "campaigns:campaign:read",
"campaign:create": "campaigns:campaign:create",
"campaign:update": "campaigns:campaign:update",
@@ -44,14 +80,28 @@ LEGACY_TO_MODULE_SCOPES: dict[str, str] = {
"mail_servers:manage_credentials": "mail:secret:manage",
}
MODULE_TO_LEGACY_SCOPES = {module: legacy for legacy, module in LEGACY_TO_MODULE_SCOPES.items()}
MODULE_TENANT_SCOPES = frozenset(LEGACY_TO_MODULE_SCOPES.values())
MODULE_TO_LEGACY_SCOPE_ALIASES: dict[str, tuple[str, ...]] = {}
for legacy_scope, module_scope in LEGACY_TO_MODULE_SCOPES.items():
MODULE_TO_LEGACY_SCOPE_ALIASES[module_scope] = (
*MODULE_TO_LEGACY_SCOPE_ALIASES.get(module_scope, ()),
legacy_scope,
)
MODULE_TO_LEGACY_SCOPES = {
module_scope: aliases[0]
for module_scope, aliases in MODULE_TO_LEGACY_SCOPE_ALIASES.items()
}
MODULE_SYSTEM_SCOPES = frozenset(
module
for legacy, module in LEGACY_TO_MODULE_SCOPES.items()
if legacy.startswith("system:")
)
MODULE_TENANT_SCOPES = frozenset(LEGACY_TO_MODULE_SCOPES.values()) - MODULE_SYSTEM_SCOPES
def compatible_required_scopes(required: str) -> tuple[str, ...]:
legacy = MODULE_TO_LEGACY_SCOPES.get(required)
if legacy:
return (required, legacy)
legacy_aliases = MODULE_TO_LEGACY_SCOPE_ALIASES.get(required)
if legacy_aliases:
return (required, *legacy_aliases)
module = LEGACY_TO_MODULE_SCOPES.get(required)
if module:
return (required, module)
@@ -60,10 +110,18 @@ def compatible_required_scopes(required: str) -> tuple[str, ...]:
def scopes_grant_compatible(scopes: Iterable[str], required: str) -> bool:
granted = list(scopes)
if required in MODULE_SYSTEM_SCOPES:
return "*" in granted or "system:*" in granted or any(
scope != "tenant:*" and scopes_grant([scope], alias)
for scope in granted
for alias in compatible_required_scopes(required)
)
if scopes_grant(granted, required):
return True
if "tenant:*" in granted or "*" in granted:
if required in MODULE_TENANT_SCOPES:
return True
if "system:*" in granted or "*" in granted:
if required in MODULE_SYSTEM_SCOPES:
return True
return any(scopes_grant(granted, alias) for alias in compatible_required_scopes(required) if alias != required)

View File

@@ -32,6 +32,7 @@ from govoplan_core.core.access import (
AccessDecisionProvenance,
AccessExplanationService,
AccessGovernanceMaterializer,
ResourceAccessExplanationProvider,
AccessSemanticDirectory,
AccessSubjectRef,
AccountRef,
@@ -75,8 +76,10 @@ from govoplan_core.core.campaigns import (
CampaignPolicyContextProvider,
CampaignRetentionProvider,
)
from govoplan_core.core.files import CAPABILITY_FILES_ACCESS, FileAccessProvider
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.idm import CAPABILITY_IDM_DIRECTORY, OrganizationFunctionAssignmentRef
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory as CoreOrganizationDirectory, OrganizationFunctionRef as CoreOrganizationFunctionRef, OrganizationUnitRef as CoreOrganizationUnitRef
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import clear_runtime, configure_runtime
from govoplan_core.db.base import Base
@@ -85,6 +88,7 @@ from govoplan_core.server.config import GovoplanServerConfig
from govoplan_core.tenancy.scope import create_scope_tables
from govoplan_access.backend.db.models import (
Account,
ExternalFunctionRoleAssignment,
Function,
FunctionAssignment,
FunctionRoleAssignment,
@@ -237,6 +241,24 @@ class _FakeResourceAccessService:
return AccessDecision(allowed=True)
class _FakeResourceAccessExplanationProvider:
def explain_resource_provenance(
self,
session: object,
principal: PrincipalRef,
*,
resource_type: str,
resource_id: str,
action: str,
):
del session, principal, resource_type, action
return (AccessDecisionProvenance(kind="resource", id=resource_id, label="Demo resource"),)
class _FakeFileAccessProvider(_FakeResourceAccessExplanationProvider):
pass
class _FakeAccessExplanationService:
provenance = (
AccessDecisionProvenance(kind="identity", id="identity-1", label="Demo Person"),
@@ -479,6 +501,7 @@ class AccessContractTests(unittest.TestCase):
self.assertEqual("access.administration", CAPABILITY_ACCESS_ADMINISTRATION)
self.assertEqual("access.governanceMaterializer", CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER)
self.assertEqual("campaigns.access", CAPABILITY_CAMPAIGNS_ACCESS)
self.assertEqual("files.access", CAPABILITY_FILES_ACCESS)
self.assertEqual("campaigns.deliveryTasks", CAPABILITY_CAMPAIGNS_DELIVERY_TASKS)
self.assertEqual("campaigns.mailPolicyContext", CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT)
self.assertEqual("campaigns.policyContext", CAPABILITY_CAMPAIGNS_POLICY_CONTEXT)
@@ -582,6 +605,8 @@ class AccessContractTests(unittest.TestCase):
self.assertIsInstance(_FakeAccessSemanticDirectory(), AccessSemanticDirectory)
self.assertIsInstance(_FakePermissionEvaluator(), PermissionEvaluator)
self.assertIsInstance(_FakeResourceAccessService(), ResourceAccessService)
self.assertIsInstance(_FakeResourceAccessExplanationProvider(), ResourceAccessExplanationProvider)
self.assertIsInstance(_FakeFileAccessProvider(), FileAccessProvider)
self.assertIsInstance(_FakeAccessExplanationService(), AccessExplanationService)
self.assertIsInstance(_FakeTenantAccessProvisioner(), TenantAccessProvisioner)
self.assertIsInstance(_FakeAccessAdministration(), AccessAdministration)
@@ -1031,6 +1056,268 @@ class AccessContractTests(unittest.TestCase):
clear_runtime()
shutil.rmtree(root, ignore_errors=True)
def test_access_explanation_includes_idm_function_role_sources(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-access-explanation-"))
try:
tenant_id = "tenant-access-explanation"
account_id = "account-access-explanation"
user_id = "user-access-explanation"
role_id = "role-access-explanation"
function_id = "function-access-explanation"
organization_unit_id = "unit-access-explanation"
assignment_id = "assignment-access-explanation"
identity_id = "identity-access-explanation"
assignment = OrganizationFunctionAssignmentRef(
id=assignment_id,
tenant_id=tenant_id,
identity_id=identity_id,
account_id=account_id,
function_id=function_id,
organization_unit_id=organization_unit_id,
applies_to_subunits=True,
source="direct",
)
class FakeIdmDirectory:
def get_organization_function_assignment(self, requested_assignment_id: str):
return assignment if requested_assignment_id == assignment_id else None
def organization_function_assignments_for_identity(self, requested_identity_id: str, *, tenant_id: str | None = None):
if requested_identity_id == identity_id and tenant_id in (None, assignment.tenant_id):
return (assignment,)
return ()
def organization_function_assignments_for_account(self, requested_account_id: str, *, tenant_id: str | None = None):
if requested_account_id == account_id and tenant_id in (None, assignment.tenant_id):
return (assignment,)
return ()
class FakeOrganizationDirectory(CoreOrganizationDirectory):
def get_organization_unit(self, requested_organization_unit_id: str):
if requested_organization_unit_id != organization_unit_id:
return None
return CoreOrganizationUnitRef(
id=organization_unit_id,
tenant_id=tenant_id,
slug="registry",
name="Registry Office",
)
def organization_units_for_tenant(self, requested_tenant_id: str):
if requested_tenant_id != tenant_id:
return ()
unit = self.get_organization_unit(organization_unit_id)
return (unit,) if unit is not None else ()
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=organization_unit_id,
slug="registry-clerk",
name="Registry Clerk",
)
def functions_for_organization_unit(self, requested_organization_unit_id: str, *, include_subunits: bool = False):
del include_subunits
if requested_organization_unit_id != organization_unit_id:
return ()
function = self.get_function(function_id)
return (function,) if function is not None else ()
idm_directory = FakeIdmDirectory()
organization_directory = FakeOrganizationDirectory()
class FakeFileAccessProvider:
def explain_resource_provenance(
self,
session: object,
principal: PrincipalRef,
*,
resource_type: str,
resource_id: str,
action: str,
):
del session, principal, action
if resource_type != "file":
return ()
return (
AccessDecisionProvenance(
kind="resource",
id=resource_id,
label="Registry.pdf",
tenant_id=tenant_id,
source="files.file",
details={"resource_type": "file"},
),
)
resource_provider = FakeFileAccessProvider()
with temporary_database(f"sqlite:///{root / 'test.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="access-explanation", name="Access Explanation", settings={})
account = Account(
id=account_id,
email="explanation@example.test",
normalized_email="explanation@example.test",
display_name="Access Explanation User",
)
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="file-reader",
name="File Reader",
permissions=["files:file:read"],
)
mapping = ExternalFunctionRoleAssignment(
tenant_id=tenant_id,
source_module="organizations",
function_id=function_id,
role_id=role_id,
)
session.add_all([tenant, account, user, role, mapping])
session.commit()
from govoplan_access.backend.explanation import SqlAccessExplanationService, build_user_access_explanation
explanation = build_user_access_explanation(
session,
user,
idm_directory=idm_directory,
organization_directory=organization_directory,
)
self.assertEqual(1, len(explanation.function_facts))
self.assertEqual("Registry Clerk", explanation.function_facts[0].function_name)
self.assertEqual((role_id,), explanation.function_facts[0].role_ids)
source = next(item for item in explanation.role_sources if item.source_type == "idm_function_role")
self.assertEqual("Registry Clerk", source.function_name)
self.assertEqual("File Reader", source.role_name)
scope = next(item for item in explanation.scopes if item.scope == "files:file:read")
self.assertEqual(["idm_function_role"], [item.source_type for item in scope.sources])
service = SqlAccessExplanationService(
idm_directory=idm_directory,
organization_directory=organization_directory,
resource_explanation_providers=(resource_provider,),
)
provenance = service.explain_scope_provenance(
PrincipalRef(
account_id=account_id,
membership_id=user_id,
tenant_id=tenant_id,
identity_id=identity_id,
scopes=frozenset({"files:file:read"}),
),
"files:file:read",
)
self.assertTrue(any(item.kind == "function" and item.label == "Registry Clerk" for item in provenance))
self.assertTrue(any(item.kind == "role" and item.label == "File Reader" for item in provenance))
resource_provenance = service.explain_resource_provenance(
PrincipalRef(
account_id=account_id,
membership_id=user_id,
tenant_id=tenant_id,
identity_id=identity_id,
scopes=frozenset({"files:file:read"}),
),
resource_type="file",
resource_id="file-access-explanation",
action="files:file:read",
)
self.assertTrue(any(item.kind == "resource" and item.id == "file-access-explanation" for item in resource_provenance))
self.assertTrue(any(item.kind == "right" and item.id == "files:file:read" for item in resource_provenance))
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="access",
name="Access",
version="test",
capability_factories={CAPABILITY_ACCESS_EXPLANATION: lambda context: service},
)
)
registry.register(
ModuleManifest(
id="idm",
name="IDM",
version="test",
capability_factories={CAPABILITY_IDM_DIRECTORY: lambda context: idm_directory},
)
)
registry.register(
ModuleManifest(
id="organizations",
name="Organizations",
version="test",
capability_factories={CAPABILITY_ORGANIZATION_DIRECTORY: lambda context: organization_directory},
)
)
context = ModuleContext(registry=registry, settings=object())
registry.configure_capability_context(context)
configure_runtime(context)
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:users:read"}),
),
account=account,
user=user,
)
app.dependency_overrides[get_api_principal] = principal_override
with TestClient(app) as client:
response = client.get(f"/admin/users/{user_id}/access-explanation")
self.assertEqual(200, response.status_code, response.text)
payload = response.json()
self.assertIn("files:file:read", payload["user"]["effective_scopes"])
self.assertEqual("idm_function_role", payload["role_sources"][0]["source_type"])
self.assertEqual("Registry Clerk", payload["function_facts"][0]["function_name"])
resource_response = client.get(
"/admin/access/resource-explanation",
params={
"user_id": user_id,
"resource_type": "file",
"resource_id": "file-access-explanation",
"action": "files:file:read",
},
)
self.assertEqual(200, resource_response.status_code, resource_response.text)
resource_payload = resource_response.json()
self.assertTrue(any(item["kind"] == "resource" and item["id"] == "file-access-explanation" for item in resource_payload["provenance"]))
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:

View File

@@ -33,13 +33,14 @@ from govoplan_core.db.bootstrap import bootstrap_dev_data
from govoplan_core.db.session import configure_database, set_database
from govoplan_core.core.change_sequence import decode_sequence_watermark, prune_sequence_entries
from govoplan_core.core.pagination import encode_keyset_cursor, keyset_query_fingerprint
from govoplan_core.tenancy.scope import create_scope_tables, scope_registry
from govoplan_access.backend.permissions.catalog import permission_catalog as access_permission_catalog
_database = configure_database(os.environ["DATABASE_URL"])
engine = _database.engine
SessionLocal = _database.SessionLocal
from govoplan_core.server.app import app
from govoplan_core.security.permissions import SYSTEM_SCOPES
class ApiSmokeTests(unittest.TestCase):
@@ -56,7 +57,9 @@ class ApiSmokeTests(unittest.TestCase):
def setUp(self) -> None:
set_database(_database)
self.client.cookies.clear()
scope_registry.metadata.drop_all(bind=engine)
Base.metadata.drop_all(bind=engine)
create_scope_tables(engine)
Base.metadata.create_all(bind=engine)
shutil.rmtree(_TEST_ROOT / "files", ignore_errors=True)
shutil.rmtree(_TEST_ROOT / "mock-mailbox", ignore_errors=True)
@@ -5972,7 +5975,12 @@ class ApiSmokeTests(unittest.TestCase):
auditor = next(item for item in roles.json()["roles"] if item["slug"] == "system_auditor")
self.assertTrue(owner["is_builtin"])
self.assertEqual(owner["user_assignments"], 1)
self.assertEqual(owner["effective_permission_count"], len(SYSTEM_SCOPES))
expected_system_permission_count = sum(
1
for permission in access_permission_catalog(include_legacy=False)
if permission.level == "system"
)
self.assertEqual(owner["effective_permission_count"], expected_system_permission_count)
self.assertEqual(owner["permissions"], ["system:*"])
self.assertFalse(administrator["is_builtin"])
self.assertFalse(auditor["is_builtin"])

View File

@@ -0,0 +1,57 @@
from __future__ import annotations
import unittest
from govoplan_core.core.modules import DeleteVetoIssue, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
class DeleteVetoContractTests(unittest.TestCase):
def test_structured_provider_result_is_module_attributed(self) -> None:
registry = PlatformRegistry()
registry.register(ModuleManifest(
id="records",
name="Records",
version="test",
delete_veto_providers={
"tenant": (
lambda _session, _tenant_id, _resource_id: DeleteVetoIssue(
severity="warning",
code="records_retained",
message="Records will remain archived.",
),
),
},
))
issues = registry.collect_delete_veto_issues("tenant", object(), "tenant-1", "tenant-1")
self.assertEqual(1, len(issues))
self.assertEqual("warning", issues[0].severity)
self.assertEqual("records_retained", issues[0].code)
self.assertEqual("records", issues[0].module_id)
self.assertEqual({"resource_type": "tenant", "resource_id": "tenant-1"}, issues[0].details)
def test_exception_based_provider_remains_blocking_veto(self) -> None:
def veto(_session: object, _tenant_id: str, _resource_id: str) -> None:
raise RuntimeError("Tenant owns external records.")
registry = PlatformRegistry()
registry.register(ModuleManifest(
id="external_records",
name="External Records",
version="test",
delete_veto_providers={"tenant": (veto,)},
))
issues = registry.collect_delete_veto_issues("tenant", object(), "tenant-1", "tenant-1")
self.assertEqual(1, len(issues))
self.assertEqual("blocker", issues[0].severity)
self.assertEqual("module_veto", issues[0].code)
self.assertEqual("external_records", issues[0].module_id)
self.assertEqual("Tenant owns external records.", issues[0].message)
if __name__ == "__main__":
unittest.main()

View File

@@ -18,6 +18,7 @@ from govoplan_core.core.idm import (
)
from govoplan_core.core.organizations import (
CAPABILITY_ORGANIZATION_DIRECTORY,
ORGANIZATIONS_MODULE_ID,
OrganizationDirectory,
OrganizationFunctionRef,
OrganizationUnitRef,
@@ -25,8 +26,8 @@ from govoplan_core.core.organizations import (
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 ExternalFunctionRoleAssignment, Role, User
from govoplan_access.backend.semantic import collect_external_function_roles
from govoplan_access.backend.db.models import Account, ExternalFunctionRoleAssignment, Role, User
from govoplan_access.backend.semantic import collect_external_function_roles, identity_id_for_account
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment
from govoplan_organizations.backend.db.models import (
@@ -93,6 +94,17 @@ class _FakeOrganizationDirectory:
return (self.function,) if organization_unit_id == self.unit.id else ()
class _InactiveOrganizationDirectory(_FakeOrganizationDirectory):
function = OrganizationFunctionRef(
id="function-1",
tenant_id="tenant-1",
organization_unit_id=_FakeOrganizationDirectory.unit.id,
slug="registry-clerk",
name="Registry Clerk",
status="inactive",
)
class _FakeIdmDirectory:
assignment = OrganizationFunctionAssignmentRef(
id="assignment-1",
@@ -165,6 +177,74 @@ class IdentityOrganizationContractTests(unittest.TestCase):
finally:
shutil.rmtree(root, ignore_errors=True)
def test_access_directory_prefers_identity_directory_with_projection_fallback(self) -> None:
from govoplan_access.backend.directory import SqlAccessDirectory
root = Path(tempfile.mkdtemp(prefix="govoplan-access-identity-directory-"))
try:
with temporary_database(f"sqlite:///{root / 'access-identity.db'}") as database:
Base.metadata.create_all(bind=database.engine)
with database.session() as session:
session.add(
Account(
id="account-1",
email="demo@example.test",
normalized_email="demo@example.test",
display_name="Demo",
)
)
session.commit()
identity_directory = _FakeIdentityDirectory()
self.assertEqual(
"identity-1",
identity_id_for_account(session, "account-1", identity_directory=identity_directory),
)
directory = SqlAccessDirectory(identity_directory=_FakeIdentityDirectory())
self.assertEqual("Demo Person", directory.get_identity("identity-1").display_name) # type: ignore[union-attr]
self.assertEqual(["account-1"], [item.id for item in directory.accounts_for_identity("identity-1")])
projection_only = SqlAccessDirectory()
self.assertIsNone(projection_only.get_identity("identity-1"))
finally:
shutil.rmtree(root, ignore_errors=True)
def test_access_directory_prefers_organization_directory_for_functions(self) -> None:
from govoplan_access.backend.directory import SqlAccessDirectory
root = Path(tempfile.mkdtemp(prefix="govoplan-access-organization-directory-"))
try:
with temporary_database(f"sqlite:///{root / 'access-organizations.db'}") as database:
Base.metadata.create_all(bind=database.engine)
with database.session() as session:
role = Role(
id="role-org-function",
tenant_id="tenant-1",
slug="org-function-reader",
name="Org Function Reader",
permissions=["files:file:read"],
is_assignable=True,
)
mapping = ExternalFunctionRoleAssignment(
tenant_id="tenant-1",
source_module=ORGANIZATIONS_MODULE_ID,
function_id="function-1",
role_id=role.id,
settings={},
)
session.add_all([role, mapping])
session.commit()
directory = SqlAccessDirectory(organization_directory=_FakeOrganizationDirectory())
self.assertEqual("Registry", directory.get_organization_unit("ou-1").name) # type: ignore[union-attr]
function = directory.get_function("function-1")
self.assertEqual("Registry Clerk", function.name) # type: ignore[union-attr]
self.assertEqual(("role-org-function",), function.role_ids) # type: ignore[union-attr]
self.assertEqual(["function-1"], [item.id for item in directory.functions_for_organization_unit("ou-1")])
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:
@@ -202,8 +282,16 @@ class IdentityOrganizationContractTests(unittest.TestCase):
session,
user,
_FakeIdmDirectory().organization_function_assignments_for_account("account-1", tenant_id="tenant-1"),
organization_directory=_FakeOrganizationDirectory(),
)
self.assertEqual(["role-idm-function"], [item.id for item in roles])
inactive_roles = collect_external_function_roles(
session,
user,
_FakeIdmDirectory().organization_function_assignments_for_account("account-1", tenant_id="tenant-1"),
organization_directory=_InactiveOrganizationDirectory(),
)
self.assertEqual([], inactive_roles)
finally:
shutil.rmtree(root, ignore_errors=True)

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,16 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.6",
"version": "0.1.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.6",
"version": "0.1.7",
"dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui",
"@govoplan/admin-webui": "file:../../govoplan-admin/webui",
"@govoplan/audit-webui": "file:../../govoplan-audit/webui",
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
@@ -18,7 +19,8 @@
"@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"
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
"@govoplan/policy-webui": "file:../../govoplan-policy/webui"
},
"devDependencies": {
"@types/react": "^19.0.2",
@@ -41,12 +43,12 @@
},
"../../govoplan-access/webui": {
"name": "@govoplan/access-webui",
"version": "0.1.6",
"version": "0.1.7",
"devDependencies": {
"typescript": "^5.7.2"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"@govoplan/core-webui": "^0.1.7",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -60,12 +62,28 @@
},
"../../govoplan-admin/webui": {
"name": "@govoplan/admin-webui",
"version": "0.1.6",
"version": "0.1.7",
"devDependencies": {
"typescript": "^5.7.2"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"@govoplan/core-webui": "^0.1.7",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-audit/webui": {
"name": "@govoplan/audit-webui",
"version": "0.1.7",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.7",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -79,9 +97,9 @@
},
"../../govoplan-calendar/webui": {
"name": "@govoplan/calendar-webui",
"version": "0.1.6",
"version": "0.1.7",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"@govoplan/core-webui": "^0.1.7",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
@@ -98,15 +116,15 @@
},
"../../govoplan-campaign/webui": {
"name": "@govoplan/campaign-webui",
"version": "0.1.6",
"version": "0.1.7",
"dependencies": {
"read-excel-file": "^9.2.0"
"read-excel-file": "9.2.0"
},
"devDependencies": {
"typescript": "^5.7.2"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"@govoplan/core-webui": "^0.1.7",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -120,9 +138,9 @@
},
"../../govoplan-dashboard/webui": {
"name": "@govoplan/dashboard-webui",
"version": "0.1.6",
"version": "0.1.7",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"@govoplan/core-webui": "^0.1.7",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -136,9 +154,9 @@
},
"../../govoplan-docs/webui": {
"name": "@govoplan/docs-webui",
"version": "0.1.6",
"version": "0.1.7",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"@govoplan/core-webui": "^0.1.7",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
@@ -155,9 +173,9 @@
},
"../../govoplan-files/webui": {
"name": "@govoplan/files-webui",
"version": "0.1.6",
"version": "0.1.7",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"@govoplan/core-webui": "^0.1.7",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
@@ -174,9 +192,9 @@
},
"../../govoplan-idm/webui": {
"name": "@govoplan/idm-webui",
"version": "0.1.6",
"version": "0.1.7",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"@govoplan/core-webui": "^0.1.7",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
@@ -193,12 +211,12 @@
},
"../../govoplan-mail/webui": {
"name": "@govoplan/mail-webui",
"version": "0.1.6",
"version": "0.1.7",
"devDependencies": {
"typescript": "^5.7.2"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"@govoplan/core-webui": "^0.1.7",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -212,9 +230,9 @@
},
"../../govoplan-ops/webui": {
"name": "@govoplan/ops-webui",
"version": "0.1.6",
"version": "0.1.7",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"@govoplan/core-webui": "^0.1.7",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
@@ -231,9 +249,9 @@
},
"../../govoplan-organizations/webui": {
"name": "@govoplan/organizations-webui",
"version": "0.1.6",
"version": "0.1.7",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"@govoplan/core-webui": "^0.1.7",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
@@ -248,6 +266,22 @@
}
}
},
"../../govoplan-policy/webui": {
"name": "@govoplan/policy-webui",
"version": "0.1.7",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.7",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -980,6 +1014,10 @@
"resolved": "../../govoplan-admin/webui",
"link": true
},
"node_modules/@govoplan/audit-webui": {
"resolved": "../../govoplan-audit/webui",
"link": true
},
"node_modules/@govoplan/calendar-webui": {
"resolved": "../../govoplan-calendar/webui",
"link": true
@@ -1016,6 +1054,10 @@
"resolved": "../../govoplan-organizations/webui",
"link": true
},
"node_modules/@govoplan/policy-webui": {
"resolved": "../../govoplan-policy/webui",
"link": true
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",

View File

@@ -1,25 +1,32 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.6",
"version": "0.1.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.6",
"version": "0.1.7",
"dependencies": {
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.6",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.6",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.6",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.6",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.6",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.6"
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.7",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.7",
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.7",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.7",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.7",
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.7",
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#v0.1.7",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.7",
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.7",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.7",
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.7",
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.7",
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git#v0.1.7"
},
"devDependencies": {
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^0.555.0",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
@@ -27,7 +34,7 @@
"vite": "^6.0.6"
},
"peerDependencies": {
"lucide-react": "^0.555.0",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
@@ -713,11 +720,11 @@
}
},
"node_modules/@govoplan/access-webui": {
"version": "0.1.6",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#37828fe34099c17267e63cfbc3a78ae1075b6b52",
"version": "0.1.7",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#d08a41fb56053331cc6f6ffe2757b6f2e241f33a",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"lucide-react": "^0.555.0",
"@govoplan/core-webui": "^0.1.7",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
@@ -729,11 +736,27 @@
}
},
"node_modules/@govoplan/admin-webui": {
"version": "0.1.6",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#0f2a9beca76c695a70a709bce2d843e6b1349277",
"version": "0.1.7",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#24edb7eb8a4f7b63297718319a3bbdf74b3a9aeb",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"lucide-react": "^0.555.0",
"@govoplan/core-webui": "^0.1.7",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"node_modules/@govoplan/audit-webui": {
"version": "0.1.7",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#7ffb16d98198f7485275bb3204ed71fe13f9e76a",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.7",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
@@ -745,12 +768,12 @@
}
},
"node_modules/@govoplan/calendar-webui": {
"version": "0.1.6",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#c3c867391c39813593930f0272a1a0142c70872b",
"version": "0.1.7",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#39d2c28ab12f59d549f5e6d11584e8dbba1e3f52",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"@govoplan/core-webui": "^0.1.7",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^0.555.0",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
@@ -764,14 +787,14 @@
}
},
"node_modules/@govoplan/campaign-webui": {
"version": "0.1.6",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#57f6066bf72ba817ce5a1973405d2422ae59678d",
"version": "0.1.7",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#83b2082c74ae9d09c1fec7eb709368ffc5116ecb",
"dependencies": {
"read-excel-file": "^9.2.0"
"read-excel-file": "9.2.0"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"lucide-react": "^0.555.0",
"@govoplan/core-webui": "^0.1.7",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
@@ -782,13 +805,67 @@
}
}
},
"node_modules/@govoplan/files-webui": {
"version": "0.1.6",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#18e6c3eb9b776bd58c18a750725d6cd8a403bc5c",
"node_modules/@govoplan/dashboard-webui": {
"version": "0.1.7",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#a8cf9949c64be366ce1d45ec4495a99b53aa0ee3",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"@govoplan/core-webui": "^0.1.7",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"node_modules/@govoplan/docs-webui": {
"version": "0.1.7",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#370356882cf5becfdf6fc0c53f2a2b2b3db1e975",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.7",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^0.555.0",
"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
}
}
},
"node_modules/@govoplan/files-webui": {
"version": "0.1.7",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#620fdda2fc434005ccf9550f730687b9d84083ed",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.7",
"@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
}
}
},
"node_modules/@govoplan/idm-webui": {
"version": "0.1.7",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#b9b371a70470e30f470f8a9a3b03ee53e8657f84",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.7",
"@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",
@@ -802,11 +879,65 @@
}
},
"node_modules/@govoplan/mail-webui": {
"version": "0.1.6",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#b4b0c76455e5fddadd03af3b023c82678dedf36f",
"version": "0.1.7",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#286b6cafd977a223418b5cc9446fd1a95e29428c",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.6",
"lucide-react": "^0.555.0",
"@govoplan/core-webui": "^0.1.7",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"node_modules/@govoplan/ops-webui": {
"version": "0.1.7",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#812216ad13b513b9dd02b752bc3e8cf213e5e510",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.7",
"@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
}
}
},
"node_modules/@govoplan/organizations-webui": {
"version": "0.1.7",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#213157aca1079ecf9576bad77366be9601dfabb9",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.7",
"@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
}
}
},
"node_modules/@govoplan/policy-webui": {
"version": "0.1.7",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git#97f05a083fcc5c694e60f1939b47ee8cfbcac089",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.7",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
@@ -1584,9 +1715,9 @@
}
},
"node_modules/lucide-react": {
"version": "0.555.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz",
"integrity": "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==",
"version": "1.24.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz",
"integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.6",
"version": "0.1.7",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -28,6 +28,7 @@
"dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui",
"@govoplan/admin-webui": "file:../../govoplan-admin/webui",
"@govoplan/audit-webui": "file:../../govoplan-audit/webui",
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
@@ -36,7 +37,8 @@
"@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"
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
"@govoplan/policy-webui": "file:../../govoplan-policy/webui"
},
"devDependencies": {
"@types/react": "^19.0.2",

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.6",
"version": "0.1.7",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -22,17 +22,19 @@
"preview": "vite preview --host 127.0.0.1 --port 4173"
},
"dependencies": {
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.6",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.6",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.6",
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.6",
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#v0.1.6",
"@govoplan/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",
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.6"
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.7",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.7",
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.7",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.7",
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.7",
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#v0.1.7",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.7",
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.7",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.7",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.7",
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.7",
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.7",
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git#v0.1.7"
},
"devDependencies": {
"lucide-react": "^1.23.0",

View File

@@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process";
const packageByModule = {
access: "@govoplan/access-webui",
admin: "@govoplan/admin-webui",
audit: "@govoplan/audit-webui",
campaigns: "@govoplan/campaign-webui",
dashboard: "@govoplan/dashboard-webui",
docs: "@govoplan/docs-webui",
@@ -10,7 +11,8 @@ const packageByModule = {
idm: "@govoplan/idm-webui",
mail: "@govoplan/mail-webui",
organizations: "@govoplan/organizations-webui",
ops: "@govoplan/ops-webui"
ops: "@govoplan/ops-webui",
policy: "@govoplan/policy-webui"
};
const cases = [
@@ -18,6 +20,7 @@ const cases = [
{ name: "access-only", modules: ["access"] },
{ name: "admin-only", modules: ["admin"] },
{ name: "access-with-admin", modules: ["access", "admin"] },
{ name: "admin-with-policy-and-audit", modules: ["access", "admin", "policy", "audit"] },
{ name: "dashboard-only", modules: ["dashboard"] },
{ name: "files-only", modules: ["files"] },
{ name: "mail-only", modules: ["mail"] },
@@ -27,7 +30,7 @@ const cases = [
{ 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", "idm", "campaigns", "files", "mail", "docs", "ops"] }
{ name: "full-product", modules: ["access", "admin", "policy", "audit", "dashboard", "organizations", "idm", "campaigns", "files", "mail", "docs", "ops"] }
];
const npmExec = process.env.npm_execpath;

View File

@@ -1,787 +0,0 @@
import type { ApiSettings } from "../types";
import { apiFetch } from "./client";
export type PermissionItem = {
scope: string;
label: string;
description: string;
category: string;
level: "tenant" | "system";
system_template_id?: string | null;
system_required?: boolean;
};
export type AdminOverview = {
active_tenant_id: string;
active_tenant_name: string;
tenant_count?: number | null;
system_account_count?: number | null;
system_group_template_count?: number | null;
system_role_template_count?: number | null;
user_count: number;
active_user_count: number;
group_count: number;
role_count: number;
active_api_key_count: number;
capabilities: string[];
};
export type TenantAdminItem = {
id: string;
slug: string;
name: string;
description?: string | null;
default_locale: string;
settings: Record<string, unknown>;
allow_custom_groups?: boolean | null;
allow_custom_roles?: boolean | null;
allow_api_keys?: boolean | null;
effective_governance: Record<string, boolean>;
is_active: boolean;
counts: Record<string, number>;
created_at: string;
updated_at: string;
};
export type TenantOwnerCandidate = {
account_id: string;
email: string;
display_name?: string | null;
};
export type RoleSummary = {
id: string;
slug: string;
name: string;
description?: string | null;
permissions: string[];
effective_permission_count: number;
is_builtin: boolean;
is_assignable: boolean;
user_assignments: number;
group_assignments: number;
level: "tenant" | "system";
system_template_id?: string | null;
system_required?: boolean;
};
export type GroupSummary = {
id: string;
slug: string;
name: string;
description?: string | null;
is_active: boolean;
member_count: number;
member_ids: string[];
roles: RoleSummary[];
created_at: string;
updated_at: string;
system_template_id?: string | null;
system_required?: boolean;
};
export type UserAdminItem = {
id: string;
account_id: string;
tenant_id: string;
email: string;
display_name?: string | null;
is_active: boolean;
account_is_active: boolean;
password_reset_required: boolean;
last_login_at?: string | null;
groups: GroupSummary[];
roles: RoleSummary[];
effective_scopes: string[];
is_owner: boolean;
is_last_active_owner: boolean;
created_at: string;
updated_at: string;
};
export type SystemAccountItem = {
account_id: string;
email: string;
display_name?: string | null;
is_active: boolean;
memberships: Array<{ tenant_id: string; tenant_name: string; user_id: string; is_active: boolean; role_ids: string[]; group_ids: string[]; is_owner: boolean; is_last_active_owner: boolean }>;
roles: RoleSummary[];
last_login_at?: string | null;
};
export type SystemMembershipDraft = {
tenant_id: string;
is_active: boolean;
role_ids: string[];
group_ids: string[];
is_owner?: boolean;
is_last_active_owner?: boolean;
};
export type PrivacyRetentionPolicyFieldKey =
| "store_raw_campaign_json"
| "raw_campaign_json_retention_days"
| "generated_eml_retention_days"
| "stored_report_detail_retention_days"
| "mock_mailbox_retention_days"
| "audit_detail_retention_days"
| "audit_detail_level";
export type PrivacyRetentionLimitPermissions = Record<PrivacyRetentionPolicyFieldKey, boolean>;
export type PrivacyRetentionLimitPermissionPatch = Partial<PrivacyRetentionLimitPermissions>;
export type PrivacyRetentionPolicy = {
store_raw_campaign_json: boolean;
raw_campaign_json_retention_days?: number | null;
generated_eml_retention_days?: number | null;
stored_report_detail_retention_days?: number | null;
mock_mailbox_retention_days?: number | null;
audit_detail_retention_days?: number | null;
audit_detail_level: "full" | "redacted" | "minimal";
allow_lower_level_limits: PrivacyRetentionLimitPermissions;
};
export type PrivacyRetentionPolicyPatch = Partial<Omit<PrivacyRetentionPolicy, "allow_lower_level_limits">> & {
allow_lower_level_limits?: PrivacyRetentionLimitPermissionPatch;
};
export type PrivacyRetentionPolicyScope = "system" | "tenant" | "user" | "group" | "campaign";
export type PolicySourceStep = {
scope_type: string;
scope_id?: string | null;
path: string;
label: string;
applied_fields?: string[];
policy?: PrivacyRetentionPolicyPatch | PrivacyRetentionPolicy | null;
};
export type PolicyDecision = {
allowed: boolean;
reason?: string | null;
source_path: PolicySourceStep[];
requirements: string[];
details?: Record<string, unknown>;
};
export type PrivacyRetentionPolicyScopeResponse = {
scope_type: PrivacyRetentionPolicyScope;
scope_id?: string | null;
policy: PrivacyRetentionPolicyPatch;
effective_policy: PrivacyRetentionPolicy;
parent_policy?: PrivacyRetentionPolicy | null;
effective_policy_sources?: PolicySourceStep[];
parent_policy_sources?: PolicySourceStep[];
};
export type PrivacyRetentionPolicyExplainResponse = {
scope_type: PrivacyRetentionPolicyScope;
scope_id?: string | null;
decision: PolicyDecision;
effective_policy: PrivacyRetentionPolicy;
parent_policy?: PrivacyRetentionPolicy | null;
effective_policy_sources?: PolicySourceStep[];
parent_policy_sources?: PolicySourceStep[];
blocked_fields: PrivacyRetentionPolicyFieldKey[];
};
export type SystemSettingsItem = {
default_locale: string;
allow_tenant_custom_groups: boolean;
allow_tenant_custom_roles: boolean;
allow_tenant_api_keys: boolean;
privacy_retention_policy: PrivacyRetentionPolicy;
maintenance_mode?: { enabled: boolean; message?: string | null };
available_languages?: LanguagePackage[];
enabled_language_codes?: string[];
settings: Record<string, unknown>;
};
export type LanguagePackage = {
code: string;
label: string;
native_label?: string | null;
};
export type TenantSettingsItem = {
id: string;
slug: string;
name: string;
default_locale: string;
available_languages: LanguagePackage[];
system_enabled_language_codes: string[];
enabled_language_codes: string[];
settings: Record<string, unknown>;
};
export type RetentionRunResponse = {
result: {
dry_run: boolean;
policy: PrivacyRetentionPolicy;
cutoffs: Record<string, string | null>;
effective_policy_scope?: string;
counts: Record<string, Record<string, number>>;
};
};
export type ConfigurationSafetyField = {
key: string;
label: string;
owner_module: string;
scope: "system" | "tenant" | "user" | "group" | "campaign";
storage: string;
ui_managed: boolean;
risk: "low" | "medium" | "high" | "destructive";
secret_handling: "none" | "reference_only" | "env_only";
required_scopes: string[];
dry_run_required: boolean;
validation_required: boolean;
policy_explanation_required: boolean;
audit_event?: string | null;
maintenance_required: boolean;
two_person_approval_required: boolean;
rollback_history_required: boolean;
notes?: string | null;
};
export type ConfigurationChangeSafetyPlan = {
key: string;
allowed: boolean;
field?: ConfigurationSafetyField | null;
risk?: ConfigurationSafetyField["risk"] | null;
missing_scopes: string[];
dry_run_required: boolean;
dry_run_satisfied: boolean;
approval_required: boolean;
approval_satisfied: boolean;
maintenance_required: boolean;
maintenance_satisfied: boolean;
rollback_history_required: boolean;
secret_handling: ConfigurationSafetyField["secret_handling"];
audit_event?: string | null;
policy_explanation?: string | null;
blockers: string[];
warnings: string[];
};
export type ConfigurationChangeRequest = {
id: string;
key: string;
label?: string;
target?: Record<string, unknown>;
dry_run: boolean;
requested_by: string;
requested_at: string;
updated_at: string;
status: "pending_approval" | "approved" | "applied" | "rejected" | string;
approvals: Array<Record<string, unknown>>;
plan: ConfigurationChangeSafetyPlan;
value_preview?: unknown;
};
export type ConfigurationChangeRecord = {
id: string;
version: number;
key: string;
target?: Record<string, unknown>;
actor_user_id: string;
approval_request_id?: string | null;
approval_user_ids: string[];
before?: unknown;
after?: unknown;
rollback_value?: unknown;
status: string;
created_at: string;
};
export type ConfigurationPackageDiagnostic = {
severity: "blocker" | "warning" | "info";
code: string;
message: string;
module_id?: string | null;
object_ref?: string | null;
resolution?: string | null;
};
export type ConfigurationPackageRequiredData = {
key: string;
label: string;
data_type: string;
required: boolean;
secret: boolean;
description?: string | null;
};
export type ConfigurationPackagePlanItem = {
action: "create" | "update" | "bind" | "skip" | "blocked" | "noop";
module_id: string;
fragment_type: string;
fragment_id?: string | null;
summary?: string | null;
};
export type ConfigurationPackageFragment = {
module_id: string;
fragment_type: string;
fragment_id?: string | null;
payload: Record<string, unknown>;
};
export type ConfigurationPackageRunPayload = {
package: Record<string, unknown>;
tenant_id?: string | null;
supplied_data?: Record<string, unknown>;
change_request_id?: string | null;
};
export type GovernanceAssignment = {
tenant_id: string;
mode: "available" | "required";
};
export type GovernanceTemplateItem = {
id: string;
kind: "group" | "role";
slug: string;
name: string;
description?: string | null;
permissions: string[];
effective_permission_count: number;
is_active: boolean;
assignments: GovernanceAssignment[];
created_at: string;
updated_at: string;
};
export type ApiKeyAdminItem = {
id: string;
user_id: string;
user_email: string;
name: string;
prefix: string;
scopes: string[];
expires_at?: string | null;
last_used_at?: string | null;
revoked_at?: string | null;
created_at: string;
};
export type AuditAdminItem = {
id: string;
scope: "tenant" | "system";
tenant_id?: string | null;
actor_email?: string | null;
action: string;
object_type?: string | null;
object_id?: string | null;
details: Record<string, unknown>;
created_at: string;
};
export function fetchAdminOverview(settings: ApiSettings): Promise<AdminOverview> {
return apiFetch(settings, "/api/v1/admin/overview");
}
export async function fetchPermissionCatalog(settings: ApiSettings): Promise<PermissionItem[]> {
const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions");
return response.permissions;
}
export async function fetchTenants(settings: ApiSettings): Promise<TenantAdminItem[]> {
const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants");
return response.tenants;
}
export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise<TenantOwnerCandidate[]> {
const response = await apiFetch<{ accounts: TenantOwnerCandidate[] }>(settings, "/api/v1/admin/tenants/owner-candidates");
return response.accounts;
}
export function createTenant(settings: ApiSettings, payload: {
slug: string;
name: string;
owner_account_id?: string | null;
description?: string | null;
default_locale?: string;
settings?: Record<string, unknown>;
allow_custom_groups?: boolean | null;
allow_custom_roles?: boolean | null;
allow_api_keys?: boolean | null;
}): Promise<TenantAdminItem> {
return apiFetch(settings, "/api/v1/admin/tenants", { method: "POST", body: JSON.stringify(payload) });
}
export function updateTenant(settings: ApiSettings, tenantId: string, payload: Partial<{
name: string;
description: string | null;
default_locale: string;
settings: Record<string, unknown>;
allow_custom_groups?: boolean | null;
allow_custom_roles?: boolean | null;
allow_api_keys?: boolean | null;
effective_governance: Record<string, boolean>;
is_active: boolean;
}>): Promise<TenantAdminItem> {
return apiFetch(settings, `/api/v1/admin/tenants/${tenantId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function fetchTenantSettings(settings: ApiSettings): Promise<TenantSettingsItem> {
return apiFetch(settings, "/api/v1/admin/tenant/settings");
}
export function updateTenantSettings(settings: ApiSettings, payload: { default_locale: string; enabled_language_codes?: string[] | null }): Promise<TenantSettingsItem> {
return apiFetch(settings, "/api/v1/admin/tenant/settings", { method: "PATCH", body: JSON.stringify(payload) });
}
export async function fetchUsers(settings: ApiSettings): Promise<UserAdminItem[]> {
const response = await apiFetch<{ users: UserAdminItem[] }>(settings, "/api/v1/admin/users");
return response.users;
}
export function createUser(settings: ApiSettings, payload: {
email: string;
display_name?: string | null;
password?: string | null;
password_reset_required?: boolean;
is_active?: boolean;
group_ids?: string[];
role_ids?: string[];
}): Promise<{ user: UserAdminItem; account_created: boolean; temporary_password?: string | null }> {
return apiFetch(settings, "/api/v1/admin/users", { method: "POST", body: JSON.stringify(payload) });
}
export function updateUser(settings: ApiSettings, userId: string, payload: Partial<{
display_name: string | null;
is_active: boolean;
group_ids: string[];
role_ids: string[];
}>): Promise<UserAdminItem> {
return apiFetch(settings, `/api/v1/admin/users/${userId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> {
const response = await apiFetch<{ groups: GroupSummary[] }>(settings, "/api/v1/admin/groups");
return response.groups;
}
export function createGroup(settings: ApiSettings, payload: {
slug: string;
name: string;
description?: string | null;
is_active?: boolean;
member_ids?: string[];
role_ids?: string[];
}): Promise<GroupSummary> {
return apiFetch(settings, "/api/v1/admin/groups", { method: "POST", body: JSON.stringify(payload) });
}
export function updateGroup(settings: ApiSettings, groupId: string, payload: Partial<{
name: string;
description: string | null;
is_active: boolean;
member_ids: string[];
role_ids: string[];
}>): Promise<GroupSummary> {
return apiFetch(settings, `/api/v1/admin/groups/${groupId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export async function fetchRoles(settings: ApiSettings): Promise<RoleSummary[]> {
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/roles");
return response.roles;
}
export function createRole(settings: ApiSettings, payload: {
slug: string;
name: string;
description?: string | null;
permissions: string[];
}): Promise<RoleSummary> {
return apiFetch(settings, "/api/v1/admin/roles", { method: "POST", body: JSON.stringify(payload) });
}
export function updateRole(settings: ApiSettings, roleId: string, payload: {
name: string;
description?: string | null;
permissions: string[];
is_assignable: boolean;
}): Promise<RoleSummary> {
return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function deleteRole(settings: ApiSettings, roleId: string): Promise<void> {
return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "DELETE" });
}
export async function fetchSystemRoles(settings: ApiSettings): Promise<RoleSummary[]> {
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/system/roles");
return response.roles;
}
export function createSystemRole(settings: ApiSettings, payload: {
slug: string;
name: string;
description?: string | null;
permissions: string[];
}): Promise<RoleSummary> {
return apiFetch(settings, "/api/v1/admin/system/roles", { method: "POST", body: JSON.stringify(payload) });
}
export function updateSystemRole(settings: ApiSettings, roleId: string, payload: {
name: string;
description?: string | null;
permissions: string[];
is_assignable: boolean;
}): Promise<RoleSummary> {
return apiFetch(settings, `/api/v1/admin/system/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function deleteSystemRole(settings: ApiSettings, roleId: string): Promise<void> {
return apiFetch(settings, `/api/v1/admin/system/roles/${roleId}`, { method: "DELETE" });
}
export async function fetchSystemAccounts(settings: ApiSettings): Promise<{ accounts: SystemAccountItem[]; roles: RoleSummary[] }> {
return apiFetch(settings, "/api/v1/admin/system/accounts");
}
export function updateSystemAccount(settings: ApiSettings, accountId: string, payload: {
display_name?: string | null;
is_active?: boolean;
role_ids?: string[];
}): Promise<SystemAccountItem> {
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function updateSystemAccountRoles(settings: ApiSettings, accountId: string, roleIds: string[]): Promise<SystemAccountItem> {
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}/roles`, {
method: "PUT",
body: JSON.stringify({ role_ids: roleIds })
});
}
export async function fetchApiKeys(settings: ApiSettings, includeRevoked = false): Promise<ApiKeyAdminItem[]> {
const params = new URLSearchParams();
if (includeRevoked) params.set("include_revoked", "true");
const suffix = params.toString() ? `?${params.toString()}` : "";
const response = await apiFetch<{ api_keys: ApiKeyAdminItem[] }>(settings, `/api/v1/admin/api-keys${suffix}`);
return response.api_keys;
}
export function createApiKey(settings: ApiSettings, payload: {
name: string;
user_id?: string | null;
scopes: string[];
expires_at?: string | null;
}): Promise<ApiKeyAdminItem & { secret: string }> {
return apiFetch(settings, "/api/v1/admin/api-keys", { method: "POST", body: JSON.stringify(payload) });
}
export function revokeApiKey(settings: ApiSettings, keyId: string): Promise<ApiKeyAdminItem> {
return apiFetch(settings, `/api/v1/admin/api-keys/${keyId}/revoke`, { method: "POST" });
}
export type AuditQueryOptions = {
tenantId?: string | null;
allTenants?: boolean;
scope?: "tenant" | "system";
limit?: number;
offset?: number;
page?: number;
pageSize?: number;
sortBy?: "time" | "actor" | "action" | "object" | "tenant";
sortDirection?: "asc" | "desc";
filters?: Partial<Record<"time" | "actor" | "action" | "object" | "tenant", string>>;
};
export async function fetchAdminAudit(settings: ApiSettings, options: AuditQueryOptions = {}): Promise<{ items: AuditAdminItem[]; total: number; page: number; page_size: number; pages: number }> {
const params = new URLSearchParams();
if (options.tenantId) params.set("tenant_id", options.tenantId);
if (options.allTenants) params.set("all_tenants", "true");
if (options.scope) params.set("scope", options.scope);
if (options.limit) params.set("limit", String(options.limit));
if (options.offset) params.set("offset", String(options.offset));
if (options.page) params.set("page", String(options.page));
if (options.pageSize) params.set("page_size", String(options.pageSize));
if (options.sortBy) params.set("sort_by", options.sortBy);
if (options.sortDirection) params.set("sort_direction", options.sortDirection);
for (const [column, value] of Object.entries(options.filters ?? {})) {
if (value?.trim()) params.set(`filter_${column}`, value);
}
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/audit${suffix}`);
}
export function createSystemAccount(settings: ApiSettings, payload: {
email: string;
display_name?: string | null;
password?: string | null;
password_reset_required?: boolean;
is_active?: boolean;
role_ids?: string[];
memberships?: SystemMembershipDraft[];
}): Promise<{ account: SystemAccountItem; temporary_password?: string | null }> {
return apiFetch(settings, "/api/v1/admin/system/accounts", { method: "POST", body: JSON.stringify(payload) });
}
export function updateSystemMemberships(settings: ApiSettings, accountId: string, memberships: SystemMembershipDraft[]): Promise<SystemAccountItem> {
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}/memberships`, {
method: "PUT", body: JSON.stringify({ memberships })
});
}
export function fetchSystemSettings(settings: ApiSettings): Promise<SystemSettingsItem> {
return apiFetch(settings, "/api/v1/admin/system/settings");
}
export type SystemSettingsUpdatePayload = {
default_locale: string;
allow_tenant_custom_groups: boolean;
allow_tenant_custom_roles: boolean;
allow_tenant_api_keys: boolean;
privacy_retention_policy?: PrivacyRetentionPolicy | null;
maintenance_mode?: { enabled: boolean; message?: string | null } | null;
available_languages?: LanguagePackage[] | null;
enabled_language_codes?: string[] | null;
change_request_id?: string | null;
};
export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise<SystemSettingsItem> {
return apiFetch(settings, "/api/v1/admin/system/settings", { method: "PATCH", body: JSON.stringify(payload) });
}
export function getPrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, scopeId?: string | null): Promise<PrivacyRetentionPolicyScopeResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`);
}
export function explainPrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, scopeId?: string | null): Promise<PrivacyRetentionPolicyExplainResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}/explain${suffix}`);
}
export function updatePrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, policy: PrivacyRetentionPolicyPatch, scopeId?: string | null, changeRequestId?: string | null): Promise<PrivacyRetentionPolicyScopeResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`, { method: "PUT", body: JSON.stringify({ policy, change_request_id: changeRequestId ?? null }) });
}
export function runRetentionPolicy(settings: ApiSettings, dryRun = true): Promise<RetentionRunResponse> {
return apiFetch(settings, "/api/v1/admin/system/retention/run", { method: "POST", body: JSON.stringify({ dry_run: dryRun }) });
}
export async function fetchConfigurationSafetyCatalog(settings: ApiSettings, includeEnvOnly = false): Promise<ConfigurationSafetyField[]> {
const suffix = includeEnvOnly ? "?include_env_only=true" : "";
const response = await apiFetch<{ fields: ConfigurationSafetyField[] }>(settings, `/api/v1/admin/configuration-safety${suffix}`);
return response.fields;
}
export async function planConfigurationChange(settings: ApiSettings, payload: {
key: string;
value?: unknown;
dry_run?: boolean;
maintenance_mode?: boolean;
approval_count?: number;
}): Promise<ConfigurationChangeSafetyPlan> {
const response = await apiFetch<{ plan: ConfigurationChangeSafetyPlan }>(settings, "/api/v1/admin/configuration-safety/plan", {
method: "POST",
body: JSON.stringify(payload)
});
return response.plan;
}
export function fetchConfigurationChanges(settings: ApiSettings): Promise<{ requests: ConfigurationChangeRequest[]; history: ConfigurationChangeRecord[] }> {
return apiFetch(settings, "/api/v1/admin/configuration-changes");
}
export async function createConfigurationChangeRequest(settings: ApiSettings, payload: {
key: string;
value?: unknown;
dry_run?: boolean;
target?: Record<string, unknown>;
reason?: string | null;
}): Promise<ConfigurationChangeRequest> {
const response = await apiFetch<{ request: ConfigurationChangeRequest }>(settings, "/api/v1/admin/configuration-change-requests", {
method: "POST",
body: JSON.stringify(payload)
});
return response.request;
}
export async function approveConfigurationChangeRequest(settings: ApiSettings, requestId: string, reason?: string | null): Promise<ConfigurationChangeRequest> {
const response = await apiFetch<{ request: ConfigurationChangeRequest }>(settings, `/api/v1/admin/configuration-change-requests/${encodeURIComponent(requestId)}/approve`, {
method: "POST",
body: JSON.stringify({ reason: reason ?? null })
});
return response.request;
}
export function fetchConfigurationPackageCatalogValidation(settings: ApiSettings): Promise<{ validation: Record<string, unknown> }> {
return apiFetch(settings, "/api/v1/admin/configuration-packages/catalog");
}
export function dryRunConfigurationPackage(settings: ApiSettings, payload: ConfigurationPackageRunPayload): Promise<{
diagnostics: ConfigurationPackageDiagnostic[];
required_data: ConfigurationPackageRequiredData[];
plan: ConfigurationPackagePlanItem[];
}> {
return apiFetch(settings, "/api/v1/admin/configuration-packages/dry-run", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function applyConfigurationPackage(settings: ApiSettings, payload: ConfigurationPackageRunPayload): Promise<{
diagnostics: ConfigurationPackageDiagnostic[];
created_refs: Record<string, string>;
updated_refs: Record<string, string>;
}> {
return apiFetch(settings, "/api/v1/admin/configuration-packages/apply", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function exportConfigurationPackage(settings: ApiSettings, payload: {
tenant_id?: string | null;
scopes?: string[];
module_ids?: string[];
object_refs?: string[];
}): Promise<{
fragments: ConfigurationPackageFragment[];
data_requirements: ConfigurationPackageRequiredData[];
diagnostics: ConfigurationPackageDiagnostic[];
}> {
return apiFetch(settings, "/api/v1/admin/configuration-packages/export", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> {
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : "";
const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`);
return response.templates;
}
export function createGovernanceTemplate(settings: ApiSettings, payload: Omit<GovernanceTemplateItem, "id" | "created_at" | "updated_at" | "effective_permission_count"> & { change_request_id?: string | null }): Promise<GovernanceTemplateItem> {
return apiFetch(settings, "/api/v1/admin/system/governance-templates", { method: "POST", body: JSON.stringify(payload) });
}
export function updateGovernanceTemplate(settings: ApiSettings, templateId: string, payload: Omit<GovernanceTemplateItem, "id" | "kind" | "slug" | "created_at" | "updated_at" | "effective_permission_count"> & { change_request_id?: string | null }): Promise<GovernanceTemplateItem> {
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string, changeRequestId?: string | null): Promise<void> {
const suffix = changeRequestId ? `?change_request_id=${encodeURIComponent(changeRequestId)}` : "";
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}${suffix}`, { method: "DELETE" });
}

View File

@@ -0,0 +1,129 @@
import type { ApiSettings } from "../types";
import { apiFetch } from "./client";
export type PrivacyRetentionPolicyFieldKey =
| "store_raw_campaign_json"
| "raw_campaign_json_retention_days"
| "generated_eml_retention_days"
| "stored_report_detail_retention_days"
| "mock_mailbox_retention_days"
| "audit_detail_retention_days"
| "audit_detail_level";
export type PrivacyRetentionLimitPermissions = Record<PrivacyRetentionPolicyFieldKey, boolean>;
export type PrivacyRetentionLimitPermissionPatch = Partial<PrivacyRetentionLimitPermissions>;
export type PrivacyRetentionPolicy = {
store_raw_campaign_json: boolean;
raw_campaign_json_retention_days?: number | null;
generated_eml_retention_days?: number | null;
stored_report_detail_retention_days?: number | null;
mock_mailbox_retention_days?: number | null;
audit_detail_retention_days?: number | null;
audit_detail_level: "full" | "redacted" | "minimal";
allow_lower_level_limits: PrivacyRetentionLimitPermissions;
};
export type PrivacyRetentionPolicyPatch = Partial<Omit<PrivacyRetentionPolicy, "allow_lower_level_limits">> & {
allow_lower_level_limits?: PrivacyRetentionLimitPermissionPatch;
};
export type PrivacyRetentionPolicyScope = "system" | "tenant" | "user" | "group" | "campaign";
export type PolicySourceStep = {
scope_type: string;
scope_id?: string | null;
path: string;
label: string;
applied_fields?: string[];
policy?: PrivacyRetentionPolicyPatch | PrivacyRetentionPolicy | null;
};
export type PolicyDecision = {
allowed: boolean;
reason?: string | null;
source_path: PolicySourceStep[];
requirements: string[];
details?: Record<string, unknown>;
};
export type PrivacyRetentionPolicyScopeResponse = {
scope_type: PrivacyRetentionPolicyScope;
scope_id?: string | null;
policy: PrivacyRetentionPolicyPatch;
effective_policy: PrivacyRetentionPolicy;
parent_policy?: PrivacyRetentionPolicy | null;
effective_policy_sources?: PolicySourceStep[];
parent_policy_sources?: PolicySourceStep[];
};
export type PrivacyRetentionPolicyExplainResponse = {
scope_type: PrivacyRetentionPolicyScope;
scope_id?: string | null;
decision: PolicyDecision;
effective_policy: PrivacyRetentionPolicy;
parent_policy?: PrivacyRetentionPolicy | null;
effective_policy_sources?: PolicySourceStep[];
parent_policy_sources?: PolicySourceStep[];
blocked_fields: PrivacyRetentionPolicyFieldKey[];
};
export type RetentionRunResponse = {
result: {
dry_run: boolean;
policy: PrivacyRetentionPolicy;
cutoffs: Record<string, string | null>;
effective_policy_scope?: string;
counts: Record<string, Record<string, number>>;
};
};
function retentionPolicyQuery(scopeId?: string | null): string {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString();
return suffix ? `?${suffix}` : "";
}
export function getPrivacyRetentionPolicy(
settings: ApiSettings,
scope: PrivacyRetentionPolicyScope,
scopeId?: string | null
): Promise<PrivacyRetentionPolicyScopeResponse> {
return apiFetch(
settings,
`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${retentionPolicyQuery(scopeId)}`
);
}
export function explainPrivacyRetentionPolicy(
settings: ApiSettings,
scope: PrivacyRetentionPolicyScope,
scopeId?: string | null
): Promise<PrivacyRetentionPolicyExplainResponse> {
return apiFetch(
settings,
`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}/explain${retentionPolicyQuery(scopeId)}`
);
}
export function updatePrivacyRetentionPolicy(
settings: ApiSettings,
scope: PrivacyRetentionPolicyScope,
policy: PrivacyRetentionPolicyPatch,
scopeId?: string | null,
changeRequestId?: string | null
): Promise<PrivacyRetentionPolicyScopeResponse> {
return apiFetch(
settings,
`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${retentionPolicyQuery(scopeId)}`,
{ method: "PUT", body: JSON.stringify({ policy, change_request_id: changeRequestId ?? null }) }
);
}
export function runRetentionPolicy(settings: ApiSettings, dryRun = true): Promise<RetentionRunResponse> {
return apiFetch(settings, "/api/v1/admin/system/retention/run", {
method: "POST",
body: JSON.stringify({ dry_run: dryRun })
});
}

View File

@@ -10,7 +10,7 @@ import {
type PrivacyRetentionPolicyFieldKey,
type PrivacyRetentionPolicyPatch,
type PrivacyRetentionPolicyScope } from
"../../api/admin";
"../../api/privacyRetention";
import Button from "../../components/Button";
import Card from "../../components/Card";
import DismissibleAlert from "../../components/DismissibleAlert";

View File

@@ -3,6 +3,7 @@ export * from "./types";
export * from "./api/client";
export * from "./api/auth";
export * from "./api/platform";
export * from "./api/privacyRetention";
export * from "./platform/modules";
export * from "./platform/ModuleContext";
export * from "./platform/moduleEvents";

View File

@@ -1,6 +1,42 @@
import type { AuthInfo } from "../types";
const legacyToModuleScopes: Record<string, string> = {
"admin:users:read": "access:membership:read",
"admin:users:create": "access:membership:create",
"admin:users:update": "access:membership:update",
"admin:users:suspend": "access:membership:update",
"admin:groups:read": "access:group:read",
"admin:groups:write": "access:group:write",
"admin:groups:manage_members": "access:group:manage_members",
"admin:roles:read": "access:role:read",
"admin:roles:write": "access:role:write",
"admin:roles:assign": "access:role:assign",
"admin:api_keys:read": "access:api_key:read",
"admin:api_keys:create": "access:api_key:create",
"admin:api_keys:revoke": "access:api_key:revoke",
"admin:settings:read": "access:setting:read",
"admin:settings:write": "access:setting:write",
"admin:policies:read": "access:policy:read",
"admin:policies:write": "access:policy:write",
"system:tenants:read": "access:tenant:read",
"system:tenants:create": "access:tenant:create",
"system:tenants:update": "access:tenant:update",
"system:tenants:suspend": "access:tenant:suspend",
"system:accounts:read": "access:account:read",
"system:accounts:create": "access:account:create",
"system:accounts:update": "access:account:update",
"system:accounts:suspend": "access:account:suspend",
"system:roles:read": "access:system_role:read",
"system:roles:write": "access:system_role:write",
"system:roles:assign": "access:system_role:assign",
"system:access:read": "access:system_role:read",
"system:access:assign": "access:system_role:assign",
"system:audit:read": "access:audit:read",
"system:settings:read": "access:system_setting:read",
"system:settings:write": "access:system_setting:write",
"system:maintenance:access": "access:maintenance:access",
"system:governance:read": "access:governance:read",
"system:governance:write": "access:governance:write",
"campaign:read": "campaigns:campaign:read",
"campaign:create": "campaigns:campaign:create",
"campaign:update": "campaigns:campaign:update",
@@ -40,6 +76,16 @@ const legacyToModuleScopes: Record<string, string> = {
};
const moduleToLegacyScopes = Object.fromEntries(Object.entries(legacyToModuleScopes).map(([legacy, module]) => [module, legacy]));
const moduleSystemScopes = new Set(
Object.entries(legacyToModuleScopes)
.filter(([legacy]) => legacy.startsWith("system:"))
.map(([, module]) => module)
);
const moduleTenantScopes = new Set(
Object.entries(legacyToModuleScopes)
.filter(([legacy]) => !legacy.startsWith("system:"))
.map(([, module]) => module)
);
const legacyAliases: Record<string, string[]> = {
"campaign:write": ["campaign:create", "campaign:update", "campaign:copy", "campaign:archive", "campaign:delete", "campaign:share", "recipients:read", "recipients:write", "recipients:import"],
@@ -63,9 +109,10 @@ function primitiveScopeGrants(granted: string, required: string): boolean {
if (legacyAliases[granted]?.some((alias) => primitiveScopeGrants(alias, required))) return true;
if (granted === "*") return true;
if (granted === "tenant:*") {
return required === "tenant:*" || !required.startsWith("system:");
if (moduleSystemScopes.has(required)) return false;
return required === "tenant:*" || moduleTenantScopes.has(required) || !required.startsWith("system:");
}
if (granted === "system:*") return required.startsWith("system:") || required.startsWith("access:");
if (granted === "system:*") return required.startsWith("system:") || moduleSystemScopes.has(required);
if (granted.endsWith(":*") && required.startsWith(granted.slice(0, -1))) return true;
return false;
}
@@ -111,5 +158,8 @@ export const adminReadScopes = [
"system:governance:read",
"access:tenant:read",
"access:account:read",
"access:system_role:read",
"access:audit:read",
"access:system_setting:read",
"access:governance:read"
];

View File

@@ -13,6 +13,7 @@ const resolvedInstalledModulesVirtualId = `\0${installedModulesVirtualId}`;
const defaultWebModulePackages = [
"@govoplan/access-webui",
"@govoplan/admin-webui",
"@govoplan/audit-webui",
"@govoplan/calendar-webui",
"@govoplan/campaign-webui",
"@govoplan/dashboard-webui",
@@ -21,7 +22,8 @@ const defaultWebModulePackages = [
"@govoplan/idm-webui",
"@govoplan/mail-webui",
"@govoplan/organizations-webui",
"@govoplan/ops-webui"
"@govoplan/ops-webui",
"@govoplan/policy-webui"
];
function configuredWebModulePackages(): string[] {
@@ -92,6 +94,7 @@ export default defineConfig({
fileURLToPath(new URL('.', import.meta.url)),
fileURLToPath(new URL('../../govoplan-access/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-admin/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-audit/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-calendar/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-dashboard/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-docs/webui', import.meta.url)),
@@ -100,7 +103,8 @@ export default defineConfig({
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)),
fileURLToPath(new URL('../../govoplan-ops/webui', import.meta.url))
fileURLToPath(new URL('../../govoplan-ops/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-policy/webui', import.meta.url))
]
},
proxy: {