Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a00ef54821 | |||
| edb4687826 | |||
| fcfe0b69a3 | |||
| 715bdcbebe | |||
| 060f4da751 | |||
| c32951393a | |||
| 7b85d6deae | |||
| 2d2d9e7bc7 | |||
| dc1a250797 | |||
| 7b7cc8ada7 | |||
| 722c9e5d1c | |||
| 63e54a67be | |||
| 12b623bec9 | |||
| b788afcae1 | |||
| 2b0cdf13f3 |
@@ -41,10 +41,6 @@ jobs:
|
|||||||
.venv/bin/python -m pip install 'pip-audit>=2.9,<3'
|
.venv/bin/python -m pip install 'pip-audit>=2.9,<3'
|
||||||
- name: Install WebUI release dependencies
|
- name: Install WebUI release dependencies
|
||||||
working-directory: webui
|
working-directory: webui
|
||||||
run: |
|
run: bash ../scripts/install-webui-release-dependencies.sh .
|
||||||
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
|
|
||||||
- name: Run dependency audits
|
- name: Run dependency audits
|
||||||
run: bash scripts/check-dependency-audits.sh
|
run: bash scripts/check-dependency-audits.sh
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
name: Module Matrix
|
name: Module Matrix
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
module-matrix:
|
module-matrix:
|
||||||
@@ -39,10 +37,6 @@ jobs:
|
|||||||
.venv/bin/python -m pip install '.[dev]'
|
.venv/bin/python -m pip install '.[dev]'
|
||||||
- name: Install WebUI release dependencies with test scripts
|
- name: Install WebUI release dependencies with test scripts
|
||||||
working-directory: webui
|
working-directory: webui
|
||||||
run: |
|
run: bash ../scripts/install-webui-release-dependencies.sh .
|
||||||
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
|
|
||||||
- name: Run module matrix and contract tests
|
- name: Run module matrix and contract tests
|
||||||
run: bash scripts/check-module-matrix.sh
|
run: bash scripts/check-module-matrix.sh
|
||||||
|
|||||||
41
.gitea/workflows/release-integration.yml
Normal file
41
.gitea/workflows/release-integration.yml
Normal 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
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
[](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/module-matrix.yml)
|
[](https://git.add-ideas.de/add-ideas/govoplan-core/actions?workflow=module-matrix.yml&actor=0&status=0)
|
||||||
[](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/dependency-audit.yml)
|
[](https://git.add-ideas.de/add-ideas/govoplan-core/actions?workflow=release-integration.yml&actor=0&status=0)
|
||||||
|
[](https://git.add-ideas.de/add-ideas/govoplan-core/actions?workflow=dependency-audit.yml&actor=0&status=0)
|
||||||
|
|
||||||
# govoplan-core
|
# govoplan-core
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# GovOPlaN RBAC And Resource-Access Model
|
# GovOPlaN RBAC And Resource-Access Model
|
||||||
|
|
||||||
**Updated:** 2026-07-09
|
**Updated:** 2026-07-11
|
||||||
|
|
||||||
## Authorization Equation
|
## Authorization Equation
|
||||||
|
|
||||||
@@ -254,6 +254,61 @@ space/folder/file ownership:
|
|||||||
External file connections and spaces are additionally constrained by connector
|
External file connections and spaces are additionally constrained by connector
|
||||||
policy and owner/group assignment in the files module.
|
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
|
## Mail Servers
|
||||||
|
|
||||||
| Scope | Meaning |
|
| Scope | Meaning |
|
||||||
|
|||||||
@@ -133,13 +133,12 @@ Access:
|
|||||||
|
|
||||||
Tenancy:
|
Tenancy:
|
||||||
|
|
||||||
- `tenancy.tenant.created`
|
- `tenant.created`
|
||||||
- `tenancy.tenant.updated`
|
- `tenant.updated`
|
||||||
- `tenancy.tenant.suspended`
|
- `tenant.suspended`
|
||||||
- `tenancy.tenant.reactivated`
|
- `tenant.resumed`
|
||||||
- `tenancy.tenant.delete_requested`
|
- `tenant.deletion_requested`
|
||||||
- `tenancy.tenant.delete_blocked`
|
- `tenant.erasure_completed`
|
||||||
- `tenancy.tenant.deleted`
|
|
||||||
|
|
||||||
Policy:
|
Policy:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
HTTP method and path. That keeps OpenAPI output and FastAPI route order from
|
||||||
silently masking a module collision.
|
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
|
## 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.
|
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
|
signature, channel, freshness, and replay rules before a catalog entry can be
|
||||||
planned. Catalog entries may declare `license_features`; core checks those
|
planned. Catalog entries may declare `license_features`; core checks those
|
||||||
against the configured offline license before adding the entry to the install
|
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:
|
Modules should provide:
|
||||||
|
|
||||||
- pinned backend and WebUI package refs for official catalog entries
|
- 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
|
- compatibility metadata in the module manifest
|
||||||
- named interface contracts in the manifest and catalog entry when the module
|
- named interface contracts in the manifest and catalog entry when the module
|
||||||
provides or consumes cross-module APIs
|
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
|
also reports catalog validity, channel, signature, trust state, and the
|
||||||
configured path.
|
configured path.
|
||||||
- `POST /api/v1/admin/system/modules/install-plan/catalog/{module_id}` saves
|
- `POST /api/v1/admin/system/modules/install-plan/catalog/{module_id}` saves
|
||||||
a planned install row from a validated catalog entry. Catalog signature and
|
a planned install or update row from a validated catalog entry. Installed
|
||||||
approved-channel policy are enforced before the row is saved.
|
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
|
- `POST /api/v1/admin/system/modules/{module_id}/uninstall-plan` saves a
|
||||||
planned non-destructive uninstall row for an installed module after it has
|
planned non-destructive uninstall row for an installed module after it has
|
||||||
been disabled. The Python distribution name is resolved from the installed
|
been disabled. The Python distribution name is resolved from the installed
|
||||||
|
|||||||
@@ -111,10 +111,12 @@ scripts/push-release-tag.sh --version 0.1.6
|
|||||||
`ModuleManifest` objects while writing catalog entries. When a manifest is
|
`ModuleManifest` objects while writing catalog entries. When a manifest is
|
||||||
available, the catalog entry uses the manifest version, points package refs at
|
available, the catalog entry uses the manifest version, points package refs at
|
||||||
`v<manifest.version>`, and copies `provides_interfaces` /
|
`v<manifest.version>`, and copies `provides_interfaces` /
|
||||||
`requires_interfaces` from the manifest. If a manifest cannot be discovered,
|
`requires_interfaces` from the manifest. It also copies module migration order
|
||||||
the entry falls back to the release version passed with `--version` and omits
|
and `migration_tasks` metadata when present. If a manifest cannot be
|
||||||
interface metadata. This keeps the catalog aligned with independently
|
discovered, the entry falls back to the release version passed with `--version`
|
||||||
versioned module packages instead of relying on a hardcoded compatibility table.
|
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
|
The script also includes GovOPlaN roadmap/scaffold module repositories that do
|
||||||
not yet have package metadata. Those repositories are committed, tagged, and
|
not yet have package metadata. Those repositories are committed, tagged, and
|
||||||
@@ -205,6 +207,27 @@ Each module entry can declare:
|
|||||||
- WebUI package name and pinned install reference
|
- WebUI package name and pinned install reference
|
||||||
- display metadata and tags
|
- display metadata and tags
|
||||||
- `license_features`, the feature entitlements required to plan that install
|
- `license_features`, the feature entitlements required to plan that install
|
||||||
|
- `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
|
- `provides_interfaces`, named interface contracts exported by this module
|
||||||
- `requires_interfaces`, named interface contracts and version ranges required
|
- `requires_interfaces`, named interface contracts and version ranges required
|
||||||
by this module
|
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.
|
timestamps, signature state, trusted key id, and cache state where available.
|
||||||
Catalog provenance changes preflight severity:
|
Catalog provenance changes preflight severity:
|
||||||
|
|
||||||
- catalog-sourced installs require a configured, valid package catalog before
|
- catalog-sourced installs and updates require a configured, valid package
|
||||||
activation
|
catalog before activation
|
||||||
- invalid, untrusted, expired, not-yet-valid, replayed, or unapproved-channel
|
- 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
|
- the same catalog validation failures remain warnings for manual install
|
||||||
plans, so operators can still use offline or emergency package refs
|
plans, so operators can still use offline or emergency package refs
|
||||||
- valid-catalog warnings, such as intentionally unsigned local catalogs when
|
- valid-catalog warnings, such as intentionally unsigned local catalogs when
|
||||||
signature enforcement is disabled, remain warnings
|
signature enforcement is disabled, remain warnings
|
||||||
- selected catalog entries with unsatisfied non-optional named interface ranges
|
- selected catalog entries with unsatisfied non-optional named interface ranges
|
||||||
block activation before the installer runs
|
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
|
### 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
|
the environment before activation, then validate the discovered manifests and
|
||||||
activate the resulting set together.
|
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
|
This avoids circular "upgrade A first / upgrade B first" traps: named interface
|
||||||
requirements are solved against the target set, not against each intermediate
|
requirements are solved against the target set, not against each intermediate
|
||||||
package-install moment. If the target set cannot satisfy all non-optional
|
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
|
must publish an intermediate compatibility release rather than a circular
|
||||||
update chain
|
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
|
In practice, circular dependencies are avoided by designing interfaces with
|
||||||
compatibility windows and by publishing bridge releases. A bridge release keeps
|
compatibility windows and by publishing bridge releases. A bridge release keeps
|
||||||
the old interface while introducing the new one, allowing dependent modules to
|
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
|
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:
|
Trusted catalog keys are configured locally:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,29 @@
|
|||||||
"python_ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.4",
|
"python_ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.4",
|
||||||
"webui_package": "@govoplan/files-webui",
|
"webui_package": "@govoplan/files-webui",
|
||||||
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4",
|
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4",
|
||||||
|
"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": [
|
"provides_interfaces": [
|
||||||
{
|
{
|
||||||
"name": "files.campaign_attachments",
|
"name": "files.campaign_attachments",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-core"
|
name = "govoplan-core"
|
||||||
version = "0.1.6"
|
version = "0.1.7"
|
||||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
govoplan-tenancy @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-tenancy.git@v0.1.6
|
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-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-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-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-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
|
govoplan-policy @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git@v0.1.6
|
||||||
|
|||||||
278
scripts/check-release-integration.sh
Normal file
278
scripts/check-release-integration.sh
Normal 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."
|
||||||
@@ -87,6 +87,7 @@ CATALOG_MODULES = (
|
|||||||
name="Policy",
|
name="Policy",
|
||||||
description="Policy and governance capability module.",
|
description="Policy and governance capability module.",
|
||||||
tags=("official", "platform-module"),
|
tags=("official", "platform-module"),
|
||||||
|
webui_package="@govoplan/policy-webui",
|
||||||
),
|
),
|
||||||
CatalogModule(
|
CatalogModule(
|
||||||
module_id="audit",
|
module_id="audit",
|
||||||
@@ -95,6 +96,7 @@ CATALOG_MODULES = (
|
|||||||
name="Audit",
|
name="Audit",
|
||||||
description="Audit-log storage and audit administration routes.",
|
description="Audit-log storage and audit administration routes.",
|
||||||
tags=("official", "platform-module"),
|
tags=("official", "platform-module"),
|
||||||
|
webui_package="@govoplan/audit-webui",
|
||||||
),
|
),
|
||||||
CatalogModule(
|
CatalogModule(
|
||||||
module_id="dashboard",
|
module_id="dashboard",
|
||||||
@@ -253,8 +255,8 @@ def _catalog_payload(
|
|||||||
if module.webui_package:
|
if module.webui_package:
|
||||||
entry["webui_package"] = module.webui_package
|
entry["webui_package"] = module.webui_package
|
||||||
entry["webui_ref"] = f"{repository_base}/{module.repo}.git#{module_tag}"
|
entry["webui_ref"] = f"{repository_base}/{module.repo}.git#{module_tag}"
|
||||||
manifest_interfaces = _manifest_interface_metadata(manifest)
|
manifest_metadata = _manifest_catalog_metadata(manifest)
|
||||||
entry.update(manifest_interfaces)
|
entry.update(manifest_metadata)
|
||||||
if module.provides_interfaces:
|
if module.provides_interfaces:
|
||||||
entry["provides_interfaces"] = [dict(item) for item in module.provides_interfaces]
|
entry["provides_interfaces"] = [dict(item) for item in module.provides_interfaces]
|
||||||
if module.requires_interfaces:
|
if module.requires_interfaces:
|
||||||
@@ -292,10 +294,36 @@ def _discovered_catalog_manifests() -> dict[str, ModuleManifest]:
|
|||||||
return {}
|
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:
|
if manifest is None:
|
||||||
return {}
|
return {}
|
||||||
payload: dict[str, object] = {}
|
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:
|
if manifest.provides_interfaces:
|
||||||
payload["provides_interfaces"] = [
|
payload["provides_interfaces"] = [
|
||||||
{"name": item.name, "version": item.version}
|
{"name": item.name, "version": item.version}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ fail() {
|
|||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
WEBUI="$ROOT/webui"
|
WEBUI="$ROOT/webui"
|
||||||
NPM_BIN="${NPM:-}"
|
NPM_BIN="${NPM:-}"
|
||||||
|
NODE_BIN="${NODE:-}"
|
||||||
|
|
||||||
if [[ -z "$NPM_BIN" ]]; then
|
if [[ -z "$NPM_BIN" ]]; then
|
||||||
if [[ -x "/home/zemion/.nvm/versions/node/v22.22.3/bin/npm" ]]; 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"
|
NPM_BIN="npm"
|
||||||
fi
|
fi
|
||||||
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
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -58,6 +66,7 @@ done
|
|||||||
|
|
||||||
[[ -f "$WEBUI/package.release.json" ]] || fail "missing $WEBUI/package.release.json"
|
[[ -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 "$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")"
|
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/govoplan-release-lock.XXXXXXXX")"
|
||||||
cleanup() {
|
cleanup() {
|
||||||
@@ -75,6 +84,23 @@ echo "Temporary workspace: $TMP_DIR"
|
|||||||
(
|
(
|
||||||
cd "$TMP_DIR"
|
cd "$TMP_DIR"
|
||||||
PATH="$(dirname "$NPM_BIN"):$PATH" "$NPM_BIN" install --package-lock-only --ignore-scripts
|
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"
|
cp "$TMP_DIR/package-lock.json" "$WEBUI/package-lock.release.json"
|
||||||
|
|||||||
74
scripts/install-webui-release-dependencies.sh
Normal file
74
scripts/install-webui-release-dependencies.sh
Normal 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
|
||||||
@@ -92,19 +92,39 @@ PARENT="$(dirname "$ROOT")"
|
|||||||
PACKAGE_MODULE_REPOS=(
|
PACKAGE_MODULE_REPOS=(
|
||||||
"$PARENT/govoplan-access"
|
"$PARENT/govoplan-access"
|
||||||
"$PARENT/govoplan-admin"
|
"$PARENT/govoplan-admin"
|
||||||
"$PARENT/govoplan-tenancy"
|
"$PARENT/govoplan-approvals"
|
||||||
"$PARENT/govoplan-organizations"
|
"$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-identity"
|
||||||
"$PARENT/govoplan-idm"
|
"$PARENT/govoplan-idm"
|
||||||
"$PARENT/govoplan-policy"
|
"$PARENT/govoplan-inspections"
|
||||||
"$PARENT/govoplan-audit"
|
"$PARENT/govoplan-issue-reporting"
|
||||||
"$PARENT/govoplan-dashboard"
|
"$PARENT/govoplan-learning"
|
||||||
"$PARENT/govoplan-files"
|
|
||||||
"$PARENT/govoplan-mail"
|
"$PARENT/govoplan-mail"
|
||||||
"$PARENT/govoplan-campaign"
|
|
||||||
"$PARENT/govoplan-calendar"
|
|
||||||
"$PARENT/govoplan-docs"
|
|
||||||
"$PARENT/govoplan-ops"
|
"$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=(
|
TAG_ONLY_MODULE_REPOS=(
|
||||||
"$PARENT/govoplan-addresses"
|
"$PARENT/govoplan-addresses"
|
||||||
@@ -120,6 +140,7 @@ TAG_ONLY_MODULE_REPOS=(
|
|||||||
"$PARENT/govoplan-notifications"
|
"$PARENT/govoplan-notifications"
|
||||||
"$PARENT/govoplan-payments"
|
"$PARENT/govoplan-payments"
|
||||||
"$PARENT/govoplan-portal"
|
"$PARENT/govoplan-portal"
|
||||||
|
"$PARENT/govoplan-postbox"
|
||||||
"$PARENT/govoplan-reporting"
|
"$PARENT/govoplan-reporting"
|
||||||
"$PARENT/govoplan-scheduling"
|
"$PARENT/govoplan-scheduling"
|
||||||
"$PARENT/govoplan-search"
|
"$PARENT/govoplan-search"
|
||||||
@@ -173,6 +194,9 @@ if [[ -z "$NPM_BIN" ]]; then
|
|||||||
fi
|
fi
|
||||||
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() {
|
normalize_bump() {
|
||||||
local value="${1,,}"
|
local value="${1,,}"
|
||||||
case "$value" in
|
case "$value" in
|
||||||
@@ -282,56 +306,20 @@ update_manifest_version() {
|
|||||||
local repo="$1"
|
local repo="$1"
|
||||||
local project_name="$2"
|
local project_name="$2"
|
||||||
local version="$3"
|
local version="$3"
|
||||||
local manifest_path=""
|
local manifest_paths=()
|
||||||
|
|
||||||
case "$project_name" in
|
if [[ "$project_name" == "govoplan-core" ]]; then
|
||||||
govoplan-core)
|
return 0
|
||||||
return 0
|
fi
|
||||||
;;
|
|
||||||
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
|
|
||||||
|
|
||||||
[[ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import pathlib
|
import pathlib
|
||||||
@@ -347,10 +335,18 @@ text, count = re.subn(
|
|||||||
text,
|
text,
|
||||||
count=1,
|
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:
|
if count != 1:
|
||||||
raise SystemExit(f"could not update ModuleManifest.version in {path}")
|
raise SystemExit(f"could not update ModuleManifest.version in {path}")
|
||||||
path.write_text(text)
|
path.write_text(text)
|
||||||
PYCODE
|
PYCODE
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
update_webui_package() {
|
update_webui_package() {
|
||||||
@@ -729,7 +725,7 @@ for repo in "${REPOS[@]}"; do
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
set +e
|
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=$?
|
ls_remote_status=$?
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -790,9 +786,14 @@ for repo in "${MODULE_REPOS[@]}"; do
|
|||||||
if [[ "$DRY_RUN" -eq 0 ]]; then
|
if [[ "$DRY_RUN" -eq 0 ]]; then
|
||||||
if git -C "$repo" diff --cached --quiet; then
|
if git -C "$repo" diff --cached --quiet; then
|
||||||
if [[ "${REPO_KINDS[$repo]}" == "package" ]]; 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
|
fi
|
||||||
echo "No staged changes for ${PROJECT_NAMES[$repo]}; tagging current HEAD."
|
|
||||||
else
|
else
|
||||||
run git -C "$repo" commit -m "$COMMIT_MESSAGE"
|
run git -C "$repo" commit -m "$COMMIT_MESSAGE"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from govoplan_core.db.bootstrap import bootstrap_dev_data
|
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.db.session import configure_database, get_database
|
||||||
from govoplan_core.settings import settings
|
from govoplan_core.settings import settings
|
||||||
|
|
||||||
@@ -11,12 +19,43 @@ from govoplan_core.settings import settings
|
|||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(description="Initialize the GovOPlaN database")
|
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("--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("--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")
|
parser.add_argument("--dev-api-key", default=settings.dev_bootstrap_api_key, help="Development API key secret to create")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
configure_database(args.database_url)
|
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:
|
if migration.reconciled_revision:
|
||||||
print(f"Reconciled legacy database marker to {migration.reconciled_revision}.")
|
print(f"Reconciled legacy database marker to {migration.reconciled_revision}.")
|
||||||
print(f"Database schema upgraded to {migration.current_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.")
|
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__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ AccessProvenanceKind = Literal[
|
|||||||
"identity",
|
"identity",
|
||||||
"account",
|
"account",
|
||||||
"tenant_membership",
|
"tenant_membership",
|
||||||
|
"resource",
|
||||||
|
"owner",
|
||||||
|
"share",
|
||||||
"organization_unit",
|
"organization_unit",
|
||||||
"function",
|
"function",
|
||||||
"group",
|
"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
|
@runtime_checkable
|
||||||
class AccessExplanationService(Protocol):
|
class AccessExplanationService(Protocol):
|
||||||
def explain_scope_provenance(
|
def explain_scope_provenance(
|
||||||
|
|||||||
13
src/govoplan_core/core/files.py
Normal file
13
src/govoplan_core/core/files.py
Normal 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."""
|
||||||
@@ -12,6 +12,29 @@ from govoplan_core.core.registry import PlatformRegistry
|
|||||||
class MigrationMetadataPlan:
|
class MigrationMetadataPlan:
|
||||||
metadata: tuple[MetaData, ...]
|
metadata: tuple[MetaData, ...]
|
||||||
script_locations: tuple[str, ...]
|
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:
|
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)
|
metadata.append(spec.metadata)
|
||||||
if spec.script_location:
|
if spec.script_location:
|
||||||
script_locations.append(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
@@ -19,7 +19,7 @@ MODULE_SETTINGS_KEY = "module_management"
|
|||||||
INSTALL_PLAN_KEY = "install_plan"
|
INSTALL_PLAN_KEY = "install_plan"
|
||||||
REQUIRED_PLATFORM_MODULES = ("access",)
|
REQUIRED_PLATFORM_MODULES = ("access",)
|
||||||
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin")
|
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin")
|
||||||
INSTALL_PLAN_ACTIONS = ("install", "uninstall")
|
INSTALL_PLAN_ACTIONS = ("install", "update", "uninstall")
|
||||||
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
|
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
|
||||||
INSTALL_PLAN_SOURCES = ("manual", "catalog")
|
INSTALL_PLAN_SOURCES = ("manual", "catalog")
|
||||||
LOCAL_DEPENDENCY_REF_PREFIXES = ("file:", "path:", "workspace:", "link:")
|
LOCAL_DEPENDENCY_REF_PREFIXES = ("file:", "path:", "workspace:", "link:")
|
||||||
@@ -46,6 +46,7 @@ class ModuleInstallPlanItem:
|
|||||||
webui_package: str | None = None
|
webui_package: str | None = None
|
||||||
webui_ref: str | None = None
|
webui_ref: str | None = None
|
||||||
artifact_integrity: Mapping[str, object] | None = None
|
artifact_integrity: Mapping[str, object] | None = None
|
||||||
|
data_safety_acknowledged: bool = False
|
||||||
destroy_data: bool = False
|
destroy_data: bool = False
|
||||||
status: str = "planned"
|
status: str = "planned"
|
||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
@@ -59,6 +60,8 @@ class ModuleInstallPlanItem:
|
|||||||
}
|
}
|
||||||
if self.destroy_data:
|
if self.destroy_data:
|
||||||
payload["destroy_data"] = True
|
payload["destroy_data"] = True
|
||||||
|
if self.data_safety_acknowledged:
|
||||||
|
payload["data_safety_acknowledged"] = True
|
||||||
if self.catalog:
|
if self.catalog:
|
||||||
payload["catalog"] = dict(self.catalog)
|
payload["catalog"] = dict(self.catalog)
|
||||||
for key in ("python_package", "python_ref", "webui_package", "webui_ref", "notes"):
|
for key in ("python_package", "python_ref", "webui_package", "webui_ref", "notes"):
|
||||||
@@ -206,7 +209,7 @@ def module_install_plan_commands(
|
|||||||
for item in items:
|
for item in items:
|
||||||
if item.status not in included_statuses:
|
if item.status not in included_statuses:
|
||||||
continue
|
continue
|
||||||
if item.action == "install":
|
if item.action in {"install", "update"}:
|
||||||
if item.python_ref:
|
if item.python_ref:
|
||||||
commands.append(f"python -m pip install {shlex.quote(item.python_ref)}")
|
commands.append(f"python -m pip install {shlex.quote(item.python_ref)}")
|
||||||
if item.webui_package and item.webui_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"}
|
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]
|
desired = [module_id for module_id in desired if module_id not in removed]
|
||||||
if activate_installed_modules:
|
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))
|
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_package = _clean_optional_string(raw.get("webui_package"))
|
||||||
webui_ref = _clean_optional_string(raw.get("webui_ref"))
|
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)
|
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"))
|
destroy_data = _clean_bool(raw.get("destroy_data"))
|
||||||
notes = _clean_optional_string(raw.get("notes"))
|
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}.")
|
raise ModuleManagementError(f"Unsupported install plan status for {module_id!r}: {status!r}.")
|
||||||
if source not in INSTALL_PLAN_SOURCES:
|
if source not in INSTALL_PLAN_SOURCES:
|
||||||
raise ModuleManagementError(f"Unsupported install plan source for {module_id!r}: {source!r}.")
|
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.")
|
raise ModuleManagementError(f"Install plan item {module_id!r} needs a Python package reference.")
|
||||||
if action == "uninstall" and not python_package:
|
if action == "uninstall" and not python_package:
|
||||||
raise ModuleManagementError(f"Uninstall plan item {module_id!r} needs a Python package name.")
|
raise ModuleManagementError(f"Uninstall plan item {module_id!r} needs a Python package name.")
|
||||||
if action != "uninstall" and destroy_data:
|
if action != "uninstall" and destroy_data:
|
||||||
raise ModuleManagementError(f"Install plan item {module_id!r} can only destroy data during uninstall.")
|
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.")
|
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:
|
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.")
|
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_package=webui_package,
|
||||||
webui_ref=webui_ref,
|
webui_ref=webui_ref,
|
||||||
artifact_integrity=artifact_integrity,
|
artifact_integrity=artifact_integrity,
|
||||||
|
data_safety_acknowledged=data_safety_acknowledged,
|
||||||
destroy_data=destroy_data,
|
destroy_data=destroy_data,
|
||||||
status=status,
|
status=status,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
|
|||||||
@@ -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
|
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_]*)+$")
|
_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(
|
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.")
|
raise ValueError("Module package catalog entries must be objects.")
|
||||||
module_id = _required_str(value, "module_id")
|
module_id = _required_str(value, "module_id")
|
||||||
action = str(value.get("action") or "install")
|
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}")
|
raise ValueError(f"Unsupported catalog action for {module_id!r}: {action!r}")
|
||||||
item = {
|
item = {
|
||||||
"module_id": module_id,
|
"module_id": module_id,
|
||||||
@@ -606,6 +613,21 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
|
|||||||
"description": _optional_str(value, "description"),
|
"description": _optional_str(value, "description"),
|
||||||
"version": _optional_str(value, "version"),
|
"version": _optional_str(value, "version"),
|
||||||
"action": action,
|
"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_package": _optional_str(value, "python_package"),
|
||||||
"python_ref": _optional_str(value, "python_ref"),
|
"python_ref": _optional_str(value, "python_ref"),
|
||||||
"webui_package": _optional_str(value, "webui_package"),
|
"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"),
|
"notes": _optional_str(value, "notes"),
|
||||||
"tags": _string_list(value.get("tags")),
|
"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"))
|
artifact_integrity = _normalize_artifact_integrity(value.get("artifact_integrity"))
|
||||||
if artifact_integrity:
|
if artifact_integrity:
|
||||||
item["artifact_integrity"] = artifact_integrity
|
item["artifact_integrity"] = artifact_integrity
|
||||||
return item
|
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:
|
def _required_str(value: dict[str, Any], key: str) -> str:
|
||||||
item = _optional_str(value, key)
|
item = _optional_str(value, key)
|
||||||
if not item:
|
if not item:
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION = "1"
|
|||||||
|
|
||||||
PermissionLevel = Literal["system", "tenant"]
|
PermissionLevel = Literal["system", "tenant"]
|
||||||
SubjectType = Literal["account", "membership", "group", "service_account", "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)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -91,11 +99,49 @@ MigrationRetirementExecutor = Callable[[object, str], None]
|
|||||||
MigrationRetirementProvider = Callable[[object | None, str], MigrationRetirementPlan]
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class MigrationSpec:
|
class MigrationSpec:
|
||||||
module_id: str
|
module_id: str
|
||||||
metadata: object | None = None
|
metadata: object | None = None
|
||||||
script_location: str | None = None
|
script_location: str | None = None
|
||||||
|
migration_after: tuple[str, ...] = ()
|
||||||
|
migration_before: tuple[str, ...] = ()
|
||||||
|
migration_tasks: tuple[ModuleMigrationTask, ...] = ()
|
||||||
retirement_supported: bool = False
|
retirement_supported: bool = False
|
||||||
retirement_provider: MigrationRetirementProvider | None = None
|
retirement_provider: MigrationRetirementProvider | None = None
|
||||||
retirement_notes: str | None = None
|
retirement_notes: str | None = None
|
||||||
@@ -215,7 +261,28 @@ class ResourceAclProvider(Protocol):
|
|||||||
|
|
||||||
|
|
||||||
TenantSummaryProvider = Callable[[object, str], Mapping[str, int]]
|
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"]
|
RouteFactory = Callable[[ModuleContext], "APIRouter"]
|
||||||
CapabilityFactory = Callable[[ModuleContext], object]
|
CapabilityFactory = Callable[[ModuleContext], object]
|
||||||
DocumentationProvider = Callable[[DocumentationContext], Iterable[DocumentationTopic]]
|
DocumentationProvider = Callable[[DocumentationContext], Iterable[DocumentationTopic]]
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
from collections import defaultdict, deque
|
from collections import defaultdict, deque
|
||||||
from collections.abc import Iterable, Mapping
|
from collections.abc import Iterable, Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, replace
|
||||||
|
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
CapabilityFactory,
|
CapabilityFactory,
|
||||||
|
DeleteVetoIssue,
|
||||||
DeleteVetoProvider,
|
DeleteVetoProvider,
|
||||||
|
DeleteVetoProviderRegistration,
|
||||||
ModuleInterfaceProvider,
|
ModuleInterfaceProvider,
|
||||||
ModuleInterfaceRequirement,
|
ModuleInterfaceRequirement,
|
||||||
ModuleContext,
|
ModuleContext,
|
||||||
@@ -45,7 +47,7 @@ class PlatformRegistry:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._manifests: dict[str, ModuleManifest] = {}
|
self._manifests: dict[str, ModuleManifest] = {}
|
||||||
self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {}
|
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._capability_factories: dict[str, CapabilityFactory] = {}
|
||||||
self._capabilities: dict[str, object] = {}
|
self._capabilities: dict[str, object] = {}
|
||||||
self._capability_context: ModuleContext | None = None
|
self._capability_context: ModuleContext | None = None
|
||||||
@@ -58,7 +60,7 @@ class PlatformRegistry:
|
|||||||
self.register_tenant_summary_provider(manifest.id, provider)
|
self.register_tenant_summary_provider(manifest.id, provider)
|
||||||
for resource_type, providers in manifest.delete_veto_providers.items():
|
for resource_type, providers in manifest.delete_veto_providers.items():
|
||||||
for provider in providers:
|
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():
|
for name, factory in manifest.capability_factories.items():
|
||||||
self.register_capability_factory(manifest.id, name, factory)
|
self.register_capability_factory(manifest.id, name, factory)
|
||||||
return manifest
|
return manifest
|
||||||
@@ -148,12 +150,39 @@ class PlatformRegistry:
|
|||||||
def tenant_summary_providers(self) -> Mapping[str, TenantSummaryProvider]:
|
def tenant_summary_providers(self) -> Mapping[str, TenantSummaryProvider]:
|
||||||
return dict(self._tenant_summary_providers)
|
return dict(self._tenant_summary_providers)
|
||||||
|
|
||||||
def register_delete_veto(self, resource_type: str, provider: DeleteVetoProvider) -> None:
|
def register_delete_veto(self, module_id: str, resource_type: str, provider: DeleteVetoProvider) -> None:
|
||||||
self._delete_veto_providers[resource_type].append(provider)
|
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, ...]:
|
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, ()))
|
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:
|
def validate(self) -> RegistrySnapshot:
|
||||||
ordered = tuple(self._topologically_sorted())
|
ordered = tuple(self._topologically_sorted())
|
||||||
available_capabilities = set(self._capability_factories)
|
available_capabilities = set(self._capability_factories)
|
||||||
@@ -224,6 +253,52 @@ class PlatformRegistry:
|
|||||||
return (self._manifests[module_id] for module_id in ordered)
|
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:
|
def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
||||||
if not _MODULE_ID_RE.match(manifest.id):
|
if not _MODULE_ID_RE.match(manifest.id):
|
||||||
raise RegistryError(f"Module manifest id must match {_MODULE_ID_RE.pattern}: {manifest.id!r}")
|
raise RegistryError(f"Module manifest id must match {_MODULE_ID_RE.pattern}: {manifest.id!r}")
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from alembic import command
|
from alembic import command
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
from alembic.runtime.migration import MigrationContext
|
from alembic.runtime.migration import MigrationContext
|
||||||
|
from alembic.script import ScriptDirectory
|
||||||
from sqlalchemy import create_engine, inspect, text
|
from sqlalchemy import create_engine, inspect, text
|
||||||
|
|
||||||
from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan
|
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 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.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
|
||||||
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
||||||
from govoplan_core.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.default_config import get_server_config
|
||||||
from govoplan_core.server.config import ManifestFactory
|
from govoplan_core.server.config import ManifestFactory
|
||||||
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
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"
|
REVISION_HIERARCHICAL_SETTINGS = "f5a6b7c8d9e0"
|
||||||
LEGACY_SCOPE_TABLE = "tenancy_tenants"
|
LEGACY_SCOPE_TABLE = "tenancy_tenants"
|
||||||
CORE_SCOPE_TABLE = "core_scopes"
|
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 = (
|
_NAMESPACE_TABLE_RENAMES = (
|
||||||
("tenants", "tenancy_tenants"),
|
("tenants", "tenancy_tenants"),
|
||||||
@@ -160,12 +168,31 @@ class MigrationResult:
|
|||||||
current_revision: str | None
|
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(
|
def registered_module_migration_plan(
|
||||||
database_url: str | None = None,
|
database_url: str | None = None,
|
||||||
*,
|
*,
|
||||||
enabled_modules: tuple[str, ...] | list[str] | None = None,
|
enabled_modules: tuple[str, ...] | list[str] | None = None,
|
||||||
manifest_factories: tuple[ManifestFactory, ...] = (),
|
manifest_factories: tuple[ManifestFactory, ...] = (),
|
||||||
) -> MigrationMetadataPlan:
|
) -> 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()
|
server_config = get_server_config()
|
||||||
active_database_url = database_url or settings.database_url
|
active_database_url = database_url or settings.database_url
|
||||||
if active_database_url:
|
if active_database_url:
|
||||||
@@ -187,11 +214,147 @@ def registered_module_migration_plan(
|
|||||||
active_enabled_modules,
|
active_enabled_modules,
|
||||||
manifest_factories=active_manifest_factories,
|
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:
|
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(
|
def alembic_config(
|
||||||
@@ -505,6 +668,7 @@ def migrate_database(
|
|||||||
database_url: str | None = None,
|
database_url: str | None = None,
|
||||||
reconcile_legacy_schema: bool = True,
|
reconcile_legacy_schema: bool = True,
|
||||||
enabled_modules: tuple[str, ...] | list[str] | None = None,
|
enabled_modules: tuple[str, ...] | list[str] | None = None,
|
||||||
|
migration_module_order: tuple[str, ...] | list[str] | None = None,
|
||||||
manifest_factories: tuple[ManifestFactory, ...] = (),
|
manifest_factories: tuple[ManifestFactory, ...] = (),
|
||||||
) -> MigrationResult:
|
) -> MigrationResult:
|
||||||
url = database_url or settings.database_url
|
url = database_url or settings.database_url
|
||||||
@@ -513,14 +677,19 @@ def migrate_database(
|
|||||||
reconcile_change_sequence_retention_floor_drift(url)
|
reconcile_change_sequence_retention_floor_drift(url)
|
||||||
previous = database_revision(url)
|
previous = database_revision(url)
|
||||||
reconciled = reconcile_legacy_create_all_schema(url) if reconcile_legacy_schema else None
|
reconciled = reconcile_legacy_create_all_schema(url) if reconcile_legacy_schema else None
|
||||||
command.upgrade(
|
config = alembic_config(
|
||||||
alembic_config(
|
database_url=url,
|
||||||
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,
|
enabled_modules=enabled_modules,
|
||||||
manifest_factories=manifest_factories,
|
manifest_factories=manifest_factories,
|
||||||
),
|
)
|
||||||
"heads",
|
_upgrade_ordered_module_heads(config, plan, tuple(migration_module_order))
|
||||||
)
|
command.upgrade(config, "heads")
|
||||||
if reconcile_legacy_schema:
|
if reconcile_legacy_schema:
|
||||||
reconcile_scope_table_retirement_drift(url)
|
reconcile_scope_table_retirement_drift(url)
|
||||||
current = database_revision(url)
|
current = database_revision(url)
|
||||||
@@ -529,3 +698,43 @@ def migrate_database(
|
|||||||
reconciled_revision=reconciled,
|
reconciled_revision=reconciled,
|
||||||
current_revision=current,
|
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}
|
||||||
|
|||||||
@@ -6,6 +6,42 @@ from govoplan_core.security.permissions import scopes_grant
|
|||||||
|
|
||||||
|
|
||||||
LEGACY_TO_MODULE_SCOPES: dict[str, str] = {
|
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:read": "campaigns:campaign:read",
|
||||||
"campaign:create": "campaigns:campaign:create",
|
"campaign:create": "campaigns:campaign:create",
|
||||||
"campaign:update": "campaigns:campaign:update",
|
"campaign:update": "campaigns:campaign:update",
|
||||||
@@ -44,14 +80,28 @@ LEGACY_TO_MODULE_SCOPES: dict[str, str] = {
|
|||||||
"mail_servers:manage_credentials": "mail:secret:manage",
|
"mail_servers:manage_credentials": "mail:secret:manage",
|
||||||
}
|
}
|
||||||
|
|
||||||
MODULE_TO_LEGACY_SCOPES = {module: legacy for legacy, module in LEGACY_TO_MODULE_SCOPES.items()}
|
MODULE_TO_LEGACY_SCOPE_ALIASES: dict[str, tuple[str, ...]] = {}
|
||||||
MODULE_TENANT_SCOPES = frozenset(LEGACY_TO_MODULE_SCOPES.values())
|
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, ...]:
|
def compatible_required_scopes(required: str) -> tuple[str, ...]:
|
||||||
legacy = MODULE_TO_LEGACY_SCOPES.get(required)
|
legacy_aliases = MODULE_TO_LEGACY_SCOPE_ALIASES.get(required)
|
||||||
if legacy:
|
if legacy_aliases:
|
||||||
return (required, legacy)
|
return (required, *legacy_aliases)
|
||||||
module = LEGACY_TO_MODULE_SCOPES.get(required)
|
module = LEGACY_TO_MODULE_SCOPES.get(required)
|
||||||
if module:
|
if module:
|
||||||
return (required, 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:
|
def scopes_grant_compatible(scopes: Iterable[str], required: str) -> bool:
|
||||||
granted = list(scopes)
|
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):
|
if scopes_grant(granted, required):
|
||||||
return True
|
return True
|
||||||
if "tenant:*" in granted or "*" in granted:
|
if "tenant:*" in granted or "*" in granted:
|
||||||
if required in MODULE_TENANT_SCOPES:
|
if required in MODULE_TENANT_SCOPES:
|
||||||
return True
|
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)
|
return any(scopes_grant(granted, alias) for alias in compatible_required_scopes(required) if alias != required)
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from govoplan_core.core.access import (
|
|||||||
AccessDecisionProvenance,
|
AccessDecisionProvenance,
|
||||||
AccessExplanationService,
|
AccessExplanationService,
|
||||||
AccessGovernanceMaterializer,
|
AccessGovernanceMaterializer,
|
||||||
|
ResourceAccessExplanationProvider,
|
||||||
AccessSemanticDirectory,
|
AccessSemanticDirectory,
|
||||||
AccessSubjectRef,
|
AccessSubjectRef,
|
||||||
AccountRef,
|
AccountRef,
|
||||||
@@ -75,8 +76,10 @@ from govoplan_core.core.campaigns import (
|
|||||||
CampaignPolicyContextProvider,
|
CampaignPolicyContextProvider,
|
||||||
CampaignRetentionProvider,
|
CampaignRetentionProvider,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.files import CAPABILITY_FILES_ACCESS, FileAccessProvider
|
||||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||||
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory as CoreOrganizationDirectory, OrganizationFunctionRef as CoreOrganizationFunctionRef
|
from govoplan_core.core.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.registry import PlatformRegistry
|
||||||
from govoplan_core.core.runtime import clear_runtime, configure_runtime
|
from govoplan_core.core.runtime import clear_runtime, configure_runtime
|
||||||
from govoplan_core.db.base import Base
|
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_core.tenancy.scope import create_scope_tables
|
||||||
from govoplan_access.backend.db.models import (
|
from govoplan_access.backend.db.models import (
|
||||||
Account,
|
Account,
|
||||||
|
ExternalFunctionRoleAssignment,
|
||||||
Function,
|
Function,
|
||||||
FunctionAssignment,
|
FunctionAssignment,
|
||||||
FunctionRoleAssignment,
|
FunctionRoleAssignment,
|
||||||
@@ -237,6 +241,24 @@ class _FakeResourceAccessService:
|
|||||||
return AccessDecision(allowed=True)
|
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:
|
class _FakeAccessExplanationService:
|
||||||
provenance = (
|
provenance = (
|
||||||
AccessDecisionProvenance(kind="identity", id="identity-1", label="Demo Person"),
|
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.administration", CAPABILITY_ACCESS_ADMINISTRATION)
|
||||||
self.assertEqual("access.governanceMaterializer", CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER)
|
self.assertEqual("access.governanceMaterializer", CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER)
|
||||||
self.assertEqual("campaigns.access", CAPABILITY_CAMPAIGNS_ACCESS)
|
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.deliveryTasks", CAPABILITY_CAMPAIGNS_DELIVERY_TASKS)
|
||||||
self.assertEqual("campaigns.mailPolicyContext", CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT)
|
self.assertEqual("campaigns.mailPolicyContext", CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT)
|
||||||
self.assertEqual("campaigns.policyContext", CAPABILITY_CAMPAIGNS_POLICY_CONTEXT)
|
self.assertEqual("campaigns.policyContext", CAPABILITY_CAMPAIGNS_POLICY_CONTEXT)
|
||||||
@@ -582,6 +605,8 @@ class AccessContractTests(unittest.TestCase):
|
|||||||
self.assertIsInstance(_FakeAccessSemanticDirectory(), AccessSemanticDirectory)
|
self.assertIsInstance(_FakeAccessSemanticDirectory(), AccessSemanticDirectory)
|
||||||
self.assertIsInstance(_FakePermissionEvaluator(), PermissionEvaluator)
|
self.assertIsInstance(_FakePermissionEvaluator(), PermissionEvaluator)
|
||||||
self.assertIsInstance(_FakeResourceAccessService(), ResourceAccessService)
|
self.assertIsInstance(_FakeResourceAccessService(), ResourceAccessService)
|
||||||
|
self.assertIsInstance(_FakeResourceAccessExplanationProvider(), ResourceAccessExplanationProvider)
|
||||||
|
self.assertIsInstance(_FakeFileAccessProvider(), FileAccessProvider)
|
||||||
self.assertIsInstance(_FakeAccessExplanationService(), AccessExplanationService)
|
self.assertIsInstance(_FakeAccessExplanationService(), AccessExplanationService)
|
||||||
self.assertIsInstance(_FakeTenantAccessProvisioner(), TenantAccessProvisioner)
|
self.assertIsInstance(_FakeTenantAccessProvisioner(), TenantAccessProvisioner)
|
||||||
self.assertIsInstance(_FakeAccessAdministration(), AccessAdministration)
|
self.assertIsInstance(_FakeAccessAdministration(), AccessAdministration)
|
||||||
@@ -1031,6 +1056,268 @@ class AccessContractTests(unittest.TestCase):
|
|||||||
clear_runtime()
|
clear_runtime()
|
||||||
shutil.rmtree(root, ignore_errors=True)
|
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:
|
def test_api_principal_dependency_uses_access_resolver_capability(self) -> None:
|
||||||
root = Path(tempfile.mkdtemp(prefix="govoplan-access-contract-"))
|
root = Path(tempfile.mkdtemp(prefix="govoplan-access-contract-"))
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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.db.session import configure_database, set_database
|
||||||
from govoplan_core.core.change_sequence import decode_sequence_watermark, prune_sequence_entries
|
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.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"])
|
_database = configure_database(os.environ["DATABASE_URL"])
|
||||||
engine = _database.engine
|
engine = _database.engine
|
||||||
SessionLocal = _database.SessionLocal
|
SessionLocal = _database.SessionLocal
|
||||||
|
|
||||||
from govoplan_core.server.app import app
|
from govoplan_core.server.app import app
|
||||||
from govoplan_core.security.permissions import SYSTEM_SCOPES
|
|
||||||
|
|
||||||
|
|
||||||
class ApiSmokeTests(unittest.TestCase):
|
class ApiSmokeTests(unittest.TestCase):
|
||||||
@@ -56,7 +57,9 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
set_database(_database)
|
set_database(_database)
|
||||||
self.client.cookies.clear()
|
self.client.cookies.clear()
|
||||||
|
scope_registry.metadata.drop_all(bind=engine)
|
||||||
Base.metadata.drop_all(bind=engine)
|
Base.metadata.drop_all(bind=engine)
|
||||||
|
create_scope_tables(engine)
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
shutil.rmtree(_TEST_ROOT / "files", ignore_errors=True)
|
shutil.rmtree(_TEST_ROOT / "files", ignore_errors=True)
|
||||||
shutil.rmtree(_TEST_ROOT / "mock-mailbox", 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")
|
auditor = next(item for item in roles.json()["roles"] if item["slug"] == "system_auditor")
|
||||||
self.assertTrue(owner["is_builtin"])
|
self.assertTrue(owner["is_builtin"])
|
||||||
self.assertEqual(owner["user_assignments"], 1)
|
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.assertEqual(owner["permissions"], ["system:*"])
|
||||||
self.assertFalse(administrator["is_builtin"])
|
self.assertFalse(administrator["is_builtin"])
|
||||||
self.assertFalse(auditor["is_builtin"])
|
self.assertFalse(auditor["is_builtin"])
|
||||||
|
|||||||
57
tests/test_delete_veto_contract.py
Normal file
57
tests/test_delete_veto_contract.py
Normal 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()
|
||||||
@@ -18,6 +18,7 @@ from govoplan_core.core.idm import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.core.organizations import (
|
from govoplan_core.core.organizations import (
|
||||||
CAPABILITY_ORGANIZATION_DIRECTORY,
|
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||||
|
ORGANIZATIONS_MODULE_ID,
|
||||||
OrganizationDirectory,
|
OrganizationDirectory,
|
||||||
OrganizationFunctionRef,
|
OrganizationFunctionRef,
|
||||||
OrganizationUnitRef,
|
OrganizationUnitRef,
|
||||||
@@ -25,8 +26,8 @@ from govoplan_core.core.organizations import (
|
|||||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||||
from govoplan_core.core.registry import PlatformRegistry
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_access.backend.db.models import ExternalFunctionRoleAssignment, Role, User
|
from govoplan_access.backend.db.models import Account, ExternalFunctionRoleAssignment, Role, User
|
||||||
from govoplan_access.backend.semantic import collect_external_function_roles
|
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_identity.backend.db.models import Identity, IdentityAccountLink
|
||||||
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment
|
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment
|
||||||
from govoplan_organizations.backend.db.models import (
|
from govoplan_organizations.backend.db.models import (
|
||||||
@@ -93,6 +94,17 @@ class _FakeOrganizationDirectory:
|
|||||||
return (self.function,) if organization_unit_id == self.unit.id else ()
|
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:
|
class _FakeIdmDirectory:
|
||||||
assignment = OrganizationFunctionAssignmentRef(
|
assignment = OrganizationFunctionAssignmentRef(
|
||||||
id="assignment-1",
|
id="assignment-1",
|
||||||
@@ -165,6 +177,74 @@ class IdentityOrganizationContractTests(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
shutil.rmtree(root, ignore_errors=True)
|
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:
|
def test_access_maps_idm_function_facts_to_roles_explicitly(self) -> None:
|
||||||
root = Path(tempfile.mkdtemp(prefix="govoplan-access-idm-role-map-"))
|
root = Path(tempfile.mkdtemp(prefix="govoplan-access-idm-role-map-"))
|
||||||
try:
|
try:
|
||||||
@@ -202,8 +282,16 @@ class IdentityOrganizationContractTests(unittest.TestCase):
|
|||||||
session,
|
session,
|
||||||
user,
|
user,
|
||||||
_FakeIdmDirectory().organization_function_assignments_for_account("account-1", tenant_id="tenant-1"),
|
_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])
|
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:
|
finally:
|
||||||
shutil.rmtree(root, ignore_errors=True)
|
shutil.rmtree(root, ignore_errors=True)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
94
webui/package-lock.json
generated
94
webui/package-lock.json
generated
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||||
"@govoplan/admin-webui": "file:../../govoplan-admin/webui",
|
"@govoplan/admin-webui": "file:../../govoplan-admin/webui",
|
||||||
|
"@govoplan/audit-webui": "file:../../govoplan-audit/webui",
|
||||||
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
|
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
|
||||||
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
|
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
|
||||||
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
|
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
|
||||||
@@ -18,7 +19,8 @@
|
|||||||
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
|
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
|
||||||
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
|
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
|
||||||
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
||||||
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui"
|
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
|
||||||
|
"@govoplan/policy-webui": "file:../../govoplan-policy/webui"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.2",
|
"@types/react": "^19.0.2",
|
||||||
@@ -41,12 +43,12 @@
|
|||||||
},
|
},
|
||||||
"../../govoplan-access/webui": {
|
"../../govoplan-access/webui": {
|
||||||
"name": "@govoplan/access-webui",
|
"name": "@govoplan/access-webui",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.6",
|
"@govoplan/core-webui": "^0.1.7",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -60,12 +62,28 @@
|
|||||||
},
|
},
|
||||||
"../../govoplan-admin/webui": {
|
"../../govoplan-admin/webui": {
|
||||||
"name": "@govoplan/admin-webui",
|
"name": "@govoplan/admin-webui",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"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",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -79,9 +97,9 @@
|
|||||||
},
|
},
|
||||||
"../../govoplan-calendar/webui": {
|
"../../govoplan-calendar/webui": {
|
||||||
"name": "@govoplan/calendar-webui",
|
"name": "@govoplan/calendar-webui",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.6",
|
"@govoplan/core-webui": "^0.1.7",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -98,15 +116,15 @@
|
|||||||
},
|
},
|
||||||
"../../govoplan-campaign/webui": {
|
"../../govoplan-campaign/webui": {
|
||||||
"name": "@govoplan/campaign-webui",
|
"name": "@govoplan/campaign-webui",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"read-excel-file": "^9.2.0"
|
"read-excel-file": "9.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.6",
|
"@govoplan/core-webui": "^0.1.7",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -120,9 +138,9 @@
|
|||||||
},
|
},
|
||||||
"../../govoplan-dashboard/webui": {
|
"../../govoplan-dashboard/webui": {
|
||||||
"name": "@govoplan/dashboard-webui",
|
"name": "@govoplan/dashboard-webui",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.6",
|
"@govoplan/core-webui": "^0.1.7",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -136,9 +154,9 @@
|
|||||||
},
|
},
|
||||||
"../../govoplan-docs/webui": {
|
"../../govoplan-docs/webui": {
|
||||||
"name": "@govoplan/docs-webui",
|
"name": "@govoplan/docs-webui",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.6",
|
"@govoplan/core-webui": "^0.1.7",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -155,9 +173,9 @@
|
|||||||
},
|
},
|
||||||
"../../govoplan-files/webui": {
|
"../../govoplan-files/webui": {
|
||||||
"name": "@govoplan/files-webui",
|
"name": "@govoplan/files-webui",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.6",
|
"@govoplan/core-webui": "^0.1.7",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -174,9 +192,9 @@
|
|||||||
},
|
},
|
||||||
"../../govoplan-idm/webui": {
|
"../../govoplan-idm/webui": {
|
||||||
"name": "@govoplan/idm-webui",
|
"name": "@govoplan/idm-webui",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.6",
|
"@govoplan/core-webui": "^0.1.7",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -193,12 +211,12 @@
|
|||||||
},
|
},
|
||||||
"../../govoplan-mail/webui": {
|
"../../govoplan-mail/webui": {
|
||||||
"name": "@govoplan/mail-webui",
|
"name": "@govoplan/mail-webui",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.6",
|
"@govoplan/core-webui": "^0.1.7",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -212,9 +230,9 @@
|
|||||||
},
|
},
|
||||||
"../../govoplan-ops/webui": {
|
"../../govoplan-ops/webui": {
|
||||||
"name": "@govoplan/ops-webui",
|
"name": "@govoplan/ops-webui",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.6",
|
"@govoplan/core-webui": "^0.1.7",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -231,9 +249,9 @@
|
|||||||
},
|
},
|
||||||
"../../govoplan-organizations/webui": {
|
"../../govoplan-organizations/webui": {
|
||||||
"name": "@govoplan/organizations-webui",
|
"name": "@govoplan/organizations-webui",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.6",
|
"@govoplan/core-webui": "^0.1.7",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.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": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
@@ -980,6 +1014,10 @@
|
|||||||
"resolved": "../../govoplan-admin/webui",
|
"resolved": "../../govoplan-admin/webui",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@govoplan/audit-webui": {
|
||||||
|
"resolved": "../../govoplan-audit/webui",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@govoplan/calendar-webui": {
|
"node_modules/@govoplan/calendar-webui": {
|
||||||
"resolved": "../../govoplan-calendar/webui",
|
"resolved": "../../govoplan-calendar/webui",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -1016,6 +1054,10 @@
|
|||||||
"resolved": "../../govoplan-organizations/webui",
|
"resolved": "../../govoplan-organizations/webui",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@govoplan/policy-webui": {
|
||||||
|
"resolved": "../../govoplan-policy/webui",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
|||||||
@@ -1,25 +1,32 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.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.6",
|
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.7",
|
||||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.6",
|
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.7",
|
||||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.6",
|
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.7",
|
||||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.6",
|
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.7",
|
||||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.6"
|
"@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": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.2",
|
"@types/react": "^19.0.2",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
@@ -27,7 +34,7 @@
|
|||||||
"vite": "^6.0.6"
|
"vite": "^6.0.6"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router-dom": "^7.1.1"
|
||||||
@@ -713,11 +720,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@govoplan/access-webui": {
|
"node_modules/@govoplan/access-webui": {
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#37828fe34099c17267e63cfbc3a78ae1075b6b52",
|
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#d08a41fb56053331cc6f6ffe2757b6f2e241f33a",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.6",
|
"@govoplan/core-webui": "^0.1.7",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router-dom": "^7.1.1"
|
||||||
@@ -729,11 +736,27 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@govoplan/admin-webui": {
|
"node_modules/@govoplan/admin-webui": {
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#0f2a9beca76c695a70a709bce2d843e6b1349277",
|
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#24edb7eb8a4f7b63297718319a3bbdf74b3a9aeb",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.6",
|
"@govoplan/core-webui": "^0.1.7",
|
||||||
"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"
|
||||||
|
},
|
||||||
|
"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": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router-dom": "^7.1.1"
|
||||||
@@ -745,12 +768,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@govoplan/calendar-webui": {
|
"node_modules/@govoplan/calendar-webui": {
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#c3c867391c39813593930f0272a1a0142c70872b",
|
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#39d2c28ab12f59d549f5e6d11584e8dbba1e3f52",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.6",
|
"@govoplan/core-webui": "^0.1.7",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
@@ -764,14 +787,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@govoplan/campaign-webui": {
|
"node_modules/@govoplan/campaign-webui": {
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#57f6066bf72ba817ce5a1973405d2422ae59678d",
|
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#83b2082c74ae9d09c1fec7eb709368ffc5116ecb",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"read-excel-file": "^9.2.0"
|
"read-excel-file": "9.2.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.6",
|
"@govoplan/core-webui": "^0.1.7",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router-dom": "^7.1.1"
|
||||||
@@ -782,13 +805,67 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@govoplan/files-webui": {
|
"node_modules/@govoplan/dashboard-webui": {
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#18e6c3eb9b776bd58c18a750725d6cd8a403bc5c",
|
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#a8cf9949c64be366ce1d45ec4495a99b53aa0ee3",
|
||||||
"peerDependencies": {
|
"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",
|
"@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": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
@@ -802,11 +879,65 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@govoplan/mail-webui": {
|
"node_modules/@govoplan/mail-webui": {
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#b4b0c76455e5fddadd03af3b023c82678dedf36f",
|
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#286b6cafd977a223418b5cc9446fd1a95e29428c",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.6",
|
"@govoplan/core-webui": "^0.1.7",
|
||||||
"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"
|
||||||
|
},
|
||||||
|
"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": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router-dom": "^7.1.1"
|
||||||
@@ -1584,9 +1715,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lucide-react": {
|
"node_modules/lucide-react": {
|
||||||
"version": "0.555.0",
|
"version": "1.24.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz",
|
||||||
"integrity": "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==",
|
"integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||||
"@govoplan/admin-webui": "file:../../govoplan-admin/webui",
|
"@govoplan/admin-webui": "file:../../govoplan-admin/webui",
|
||||||
|
"@govoplan/audit-webui": "file:../../govoplan-audit/webui",
|
||||||
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
|
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
|
||||||
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
|
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
|
||||||
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
|
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
|
||||||
@@ -36,7 +37,8 @@
|
|||||||
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
|
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
|
||||||
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
|
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
|
||||||
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
|
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
|
||||||
"@govoplan/ops-webui": "file:../../govoplan-ops/webui"
|
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
||||||
|
"@govoplan/policy-webui": "file:../../govoplan-policy/webui"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.2",
|
"@types/react": "^19.0.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -22,17 +22,19 @@
|
|||||||
"preview": "vite preview --host 127.0.0.1 --port 4173"
|
"preview": "vite preview --host 127.0.0.1 --port 4173"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.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.6",
|
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.7",
|
||||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.6",
|
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.7",
|
||||||
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.6",
|
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.7",
|
||||||
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#v0.1.6",
|
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.7",
|
||||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.6",
|
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#v0.1.7",
|
||||||
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.6",
|
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.7",
|
||||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.6",
|
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.7",
|
||||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.6",
|
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.7",
|
||||||
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.6",
|
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.7",
|
||||||
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.6"
|
"@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": {
|
"devDependencies": {
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process";
|
|||||||
const packageByModule = {
|
const packageByModule = {
|
||||||
access: "@govoplan/access-webui",
|
access: "@govoplan/access-webui",
|
||||||
admin: "@govoplan/admin-webui",
|
admin: "@govoplan/admin-webui",
|
||||||
|
audit: "@govoplan/audit-webui",
|
||||||
campaigns: "@govoplan/campaign-webui",
|
campaigns: "@govoplan/campaign-webui",
|
||||||
dashboard: "@govoplan/dashboard-webui",
|
dashboard: "@govoplan/dashboard-webui",
|
||||||
docs: "@govoplan/docs-webui",
|
docs: "@govoplan/docs-webui",
|
||||||
@@ -10,7 +11,8 @@ const packageByModule = {
|
|||||||
idm: "@govoplan/idm-webui",
|
idm: "@govoplan/idm-webui",
|
||||||
mail: "@govoplan/mail-webui",
|
mail: "@govoplan/mail-webui",
|
||||||
organizations: "@govoplan/organizations-webui",
|
organizations: "@govoplan/organizations-webui",
|
||||||
ops: "@govoplan/ops-webui"
|
ops: "@govoplan/ops-webui",
|
||||||
|
policy: "@govoplan/policy-webui"
|
||||||
};
|
};
|
||||||
|
|
||||||
const cases = [
|
const cases = [
|
||||||
@@ -18,6 +20,7 @@ const cases = [
|
|||||||
{ name: "access-only", modules: ["access"] },
|
{ name: "access-only", modules: ["access"] },
|
||||||
{ name: "admin-only", modules: ["admin"] },
|
{ name: "admin-only", modules: ["admin"] },
|
||||||
{ name: "access-with-admin", modules: ["access", "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: "dashboard-only", modules: ["dashboard"] },
|
||||||
{ name: "files-only", modules: ["files"] },
|
{ name: "files-only", modules: ["files"] },
|
||||||
{ name: "mail-only", modules: ["mail"] },
|
{ name: "mail-only", modules: ["mail"] },
|
||||||
@@ -27,7 +30,7 @@ const cases = [
|
|||||||
{ name: "campaign-with-files-no-mail", modules: ["campaigns", "files"] },
|
{ name: "campaign-with-files-no-mail", modules: ["campaigns", "files"] },
|
||||||
{ name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] },
|
{ name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] },
|
||||||
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
|
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
|
||||||
{ name: "full-product", modules: ["access", "admin", "dashboard", "organizations", "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;
|
const npmExec = process.env.npm_execpath;
|
||||||
|
|||||||
@@ -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" });
|
|
||||||
}
|
|
||||||
129
webui/src/api/privacyRetention.ts
Normal file
129
webui/src/api/privacyRetention.ts
Normal 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 })
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
type PrivacyRetentionPolicyFieldKey,
|
type PrivacyRetentionPolicyFieldKey,
|
||||||
type PrivacyRetentionPolicyPatch,
|
type PrivacyRetentionPolicyPatch,
|
||||||
type PrivacyRetentionPolicyScope } from
|
type PrivacyRetentionPolicyScope } from
|
||||||
"../../api/admin";
|
"../../api/privacyRetention";
|
||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export * from "./types";
|
|||||||
export * from "./api/client";
|
export * from "./api/client";
|
||||||
export * from "./api/auth";
|
export * from "./api/auth";
|
||||||
export * from "./api/platform";
|
export * from "./api/platform";
|
||||||
|
export * from "./api/privacyRetention";
|
||||||
export * from "./platform/modules";
|
export * from "./platform/modules";
|
||||||
export * from "./platform/ModuleContext";
|
export * from "./platform/ModuleContext";
|
||||||
export * from "./platform/moduleEvents";
|
export * from "./platform/moduleEvents";
|
||||||
|
|||||||
@@ -1,6 +1,42 @@
|
|||||||
import type { AuthInfo } from "../types";
|
import type { AuthInfo } from "../types";
|
||||||
|
|
||||||
const legacyToModuleScopes: Record<string, string> = {
|
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:read": "campaigns:campaign:read",
|
||||||
"campaign:create": "campaigns:campaign:create",
|
"campaign:create": "campaigns:campaign:create",
|
||||||
"campaign:update": "campaigns:campaign:update",
|
"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 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[]> = {
|
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"],
|
"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 (legacyAliases[granted]?.some((alias) => primitiveScopeGrants(alias, required))) return true;
|
||||||
if (granted === "*") return true;
|
if (granted === "*") return true;
|
||||||
if (granted === "tenant:*") {
|
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;
|
if (granted.endsWith(":*") && required.startsWith(granted.slice(0, -1))) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -111,5 +158,8 @@ export const adminReadScopes = [
|
|||||||
"system:governance:read",
|
"system:governance:read",
|
||||||
"access:tenant:read",
|
"access:tenant:read",
|
||||||
"access:account:read",
|
"access:account:read",
|
||||||
|
"access:system_role:read",
|
||||||
|
"access:audit:read",
|
||||||
|
"access:system_setting:read",
|
||||||
"access:governance:read"
|
"access:governance:read"
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const resolvedInstalledModulesVirtualId = `\0${installedModulesVirtualId}`;
|
|||||||
const defaultWebModulePackages = [
|
const defaultWebModulePackages = [
|
||||||
"@govoplan/access-webui",
|
"@govoplan/access-webui",
|
||||||
"@govoplan/admin-webui",
|
"@govoplan/admin-webui",
|
||||||
|
"@govoplan/audit-webui",
|
||||||
"@govoplan/calendar-webui",
|
"@govoplan/calendar-webui",
|
||||||
"@govoplan/campaign-webui",
|
"@govoplan/campaign-webui",
|
||||||
"@govoplan/dashboard-webui",
|
"@govoplan/dashboard-webui",
|
||||||
@@ -21,7 +22,8 @@ const defaultWebModulePackages = [
|
|||||||
"@govoplan/idm-webui",
|
"@govoplan/idm-webui",
|
||||||
"@govoplan/mail-webui",
|
"@govoplan/mail-webui",
|
||||||
"@govoplan/organizations-webui",
|
"@govoplan/organizations-webui",
|
||||||
"@govoplan/ops-webui"
|
"@govoplan/ops-webui",
|
||||||
|
"@govoplan/policy-webui"
|
||||||
];
|
];
|
||||||
|
|
||||||
function configuredWebModulePackages(): string[] {
|
function configuredWebModulePackages(): string[] {
|
||||||
@@ -92,6 +94,7 @@ export default defineConfig({
|
|||||||
fileURLToPath(new URL('.', import.meta.url)),
|
fileURLToPath(new URL('.', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-access/webui', 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-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-calendar/webui', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-dashboard/webui', import.meta.url)),
|
fileURLToPath(new URL('../../govoplan-dashboard/webui', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-docs/webui', import.meta.url)),
|
fileURLToPath(new URL('../../govoplan-docs/webui', import.meta.url)),
|
||||||
@@ -100,7 +103,8 @@ export default defineConfig({
|
|||||||
fileURLToPath(new URL('../../govoplan-mail/webui', import.meta.url)),
|
fileURLToPath(new URL('../../govoplan-mail/webui', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-organizations/webui', import.meta.url)),
|
fileURLToPath(new URL('../../govoplan-organizations/webui', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-campaign/webui', import.meta.url)),
|
fileURLToPath(new URL('../../govoplan-campaign/webui', import.meta.url)),
|
||||||
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: {
|
proxy: {
|
||||||
|
|||||||
Reference in New Issue
Block a user