85 Commits
Author SHA1 Message Date
zemion 4cb334c912 Add distribution audience contracts and module wiring 2026-07-31 20:59:49 +02:00
zemion 6ebb299d6c Add workflow baseline orchestration contracts 2026-07-31 19:39:59 +02:00
zemion 42b5019464 Extend Postbox message authoring contract 2026-07-31 18:40:30 +02:00
zemion 4c0e6435ab Fix IDM lifecycle capability identifier 2026-07-31 18:40:30 +02:00
zemion e37d8fee94 Extend Postbox access classification contract 2026-07-31 18:21:36 +02:00
zemion 50a8d459e7 Add IDM assignment lifecycle worker contract 2026-07-31 18:07:36 +02:00
zemion 5ee85d07d6 Define governed View projection contract 2026-07-31 17:52:57 +02:00
zemion cf01545806 Allow session-aware definition policy resolution 2026-07-31 17:34:16 +02:00
zemion 1884274f8d feat: add workflow engine contribution contracts 2026-07-31 16:58:57 +02:00
zemion 5211e07d0b fix: enforce DataGrid cover boundary 2026-07-31 04:44:24 +02:00
zemion 7b8072d049 feat: complete shared platform UI contracts 2026-07-31 04:21:34 +02:00
zemion 5b55f59a92 Add shared automation and WebUI editing primitives 2026-07-31 02:48:56 +02:00
zemion f0898fcdee feat: add governed ownership workflows and admin tree navigation 2026-07-30 17:42:05 +02:00
zemion 9b88ae388b feat: harden shared platform contracts 2026-07-30 14:26:36 +02:00
zemion 6970bf7457 Support observable grid queries and file filters 2026-07-30 05:22:09 +02:00
zemion 47e106684d perf(webui): lazily load module descriptors 2026-07-30 04:35:57 +02:00
zemion 9e219bc4d3 feat(core): dispatch bounded postbox routes 2026-07-30 03:59:38 +02:00
zemion ea436a513f feat(core): define organization hierarchy contracts 2026-07-30 03:26:19 +02:00
zemion e7c84e3227 feat(core): reconcile durable workflow instances 2026-07-30 03:09:58 +02:00
zemion cf7afe9dda feat(core): dispatch durable dataflow runs 2026-07-30 02:32:49 +02:00
zemion ca8a8c5111 feat(core): define sanctions screening gate contract 2026-07-30 01:54:58 +02:00
zemion f3b388fe7e perf(core): define bounded reference search 2026-07-30 01:29:45 +02:00
zemion af3e0a055d perf(core): batch tenant summary providers 2026-07-30 01:15:04 +02:00
zemion 51d4032b86 docs(core): define compatibility retention policy 2026-07-30 01:03:39 +02:00
zemion 0beb9ffea9 build: reject duplicate generated translations 2026-07-30 00:40:25 +02:00
zemion 9e6a6b5fdc feat(webui): center optional global search 2026-07-29 22:00:45 +02:00
zemion 48fb953b93 feat: add durable auth principal cache revisions 2026-07-29 19:23:52 +02:00
zemion 4bde0495f7 feat: expose workflow view resolution input 2026-07-29 19:09:03 +02:00
zemion a80caf7933 feat: support focused dev reload scopes 2026-07-29 18:52:55 +02:00
zemion 790790ab37 feat: expose sanctions screening integration 2026-07-29 18:46:53 +02:00
zemion 920e3c9834 feat: version search source contracts 2026-07-29 18:08:52 +02:00
zemion 13893c80cd feat: version automation principal subjects 2026-07-29 17:48:36 +02:00
zemion a192a2215f Add durable platform event delivery contract 2026-07-29 17:34:52 +02:00
zemion e8fed6d25a fix: resolve postbox inbox icon 2026-07-29 16:01:45 +02:00
zemion d9b5708df0 feat: add search and external integration contracts 2026-07-29 15:50:08 +02:00
zemion 68328f3d8e feat: strengthen module contracts and shared WebUI runtime 2026-07-29 14:16:28 +02:00
zemion 53e947935a fix: standardize direct page scroll viewports 2026-07-28 22:50:11 +02:00
zemion 324c26da78 fix: allow fallback dashboard scrolling 2026-07-28 22:13:22 +02:00
zemion 389f98e349 Keep normalized View roots acyclic 2026-07-28 21:32:20 +02:00
zemion ce9ef8d88f Add governed View surface runtime 2026-07-28 21:04:54 +02:00
zemion 13bc3d3b4e Add shared credential envelope infrastructure 2026-07-28 19:32:41 +02:00
zemion 3f5870281a Color all shared alert tones 2026-07-28 18:35:53 +02:00
zemion a46df85479 Resolve XyFlow styles for linked modules 2026-07-28 15:47:15 +02:00
zemion c31581b1b9 Prebundle XyFlow for linked module development 2026-07-28 15:39:00 +02:00
zemion 26ae034153 Add governed automation contracts 2026-07-28 15:02:42 +02:00
zemion baa2143a26 feat: add dataflow publication contracts and workflow webui 2026-07-28 13:47:50 +02:00
zemion 8b1910b5b7 feat: add datasource and definition graph contracts 2026-07-28 12:42:49 +02:00
zemion d36bb94335 Add provider-neutral tabular source contracts 2026-07-28 11:12:24 +02:00
zemion 74034947c6 Update vulnerable PostCSS dependency 2026-07-28 01:36:39 +02:00
zemion c7183fe7f1 Integrate Dataflow module into core 2026-07-28 01:33:37 +02:00
zemion 139a352c80 chore: update GovOPlaN repository references 2026-07-27 15:46:51 +02:00
zemion 336c94137f chore(release): align Mail bundle with Campaign contract 2026-07-23 00:47:34 +02:00
zemion 93225b6487 docs: move system status badges to meta repository 2026-07-22 23:45:42 +02:00
zemion e11ea81008 chore(release): prepare Core 0.1.14 2026-07-22 20:31:49 +02:00
zemion bc8afeb139 test(campaign): align synchronous send security contract 2026-07-22 20:30:00 +02:00
zemion f876345656 test(db): prove PostgreSQL retirement atomicity 2026-07-22 15:28:00 +02:00
zemion d487726f4d chore(release): bump Core to 0.1.13 2026-07-22 10:40:27 +02:00
zemion e6fc07da37 chore(release): bundle Campaign 0.1.10 2026-07-22 10:38:37 +02:00
zemion e6d589eb07 fix(release): package Core migration runtime 2026-07-22 10:34:34 +02:00
zemion 59610e21d2 chore(release): record reviewed 0.1.12 migration heads 2026-07-22 09:06:56 +02:00
zemion cece71d945 feat(webui): synchronize external DataGrid queries 2026-07-22 09:03:11 +02:00
zemion 22e8183846 fix(webui): translate MetricCard content 2026-07-22 08:48:42 +02:00
zemion aa111a5fe1 chore(release): bump Core to 0.1.12 2026-07-22 08:41:54 +02:00
zemion e6062fe9e4 fix(webui): enforce full-result DataGrid queries 2026-07-22 08:05:11 +02:00
zemion 987ca894ed chore(release): record reviewed 0.1.11 migration heads 2026-07-22 04:42:30 +02:00
zemion 4caa326878 chore(core): bump version to 0.1.11 2026-07-22 03:41:24 +02:00
zemion 8c4c4456c6 feat(core): define auditable poll response retirement 2026-07-22 03:31:05 +02:00
zemion 6abe292ac8 feat(core): define governed poll participation contract 2026-07-22 03:21:03 +02:00
zemion fea2807754 feat(core): define atomic poll option ordering 2026-07-22 03:20:28 +02:00
zemion 22646c614c feat(core): add bounded people picker foundation 2026-07-22 03:01:56 +02:00
zemion 17376332a2 feat(core): configure bounded scheduling cancellation notices 2026-07-22 02:58:09 +02:00
zemion 0946bc84a9 feat(core): support explicit public module routes 2026-07-22 02:58:03 +02:00
zemion a18499cbb5 feat(core): require scoped user workflows 2026-07-22 01:55:24 +02:00
zemion 36d7b73bb5 fix(webui): allow card content to overflow 2026-07-22 01:49:24 +02:00
zemion b89a2d15f1 chore(core): bump version to 0.1.10 2026-07-21 20:47:54 +02:00
zemion 7f923afdad docs(core): refine function-bound postbox encryption 2026-07-21 20:47:54 +02:00
zemion a7683c5d4a feat(core): add resilient fixed-window throttling 2026-07-21 20:47:54 +02:00
zemion 41ad057f7e feat(webui): standardize discard and table actions 2026-07-21 20:47:54 +02:00
zemion bf0729eb59 feat(core): add authenticated baseline role templates 2026-07-21 20:47:54 +02:00
zemion c4b90181e0 fix(webui): localize contextual Mail help 2026-07-21 19:15:56 +02:00
zemion 55ed194a99 fix(webui): respect configured documentation access 2026-07-21 19:01:00 +02:00
zemion b3b0cf0fca feat(webui): open contextual configured handbooks 2026-07-21 18:42:31 +02:00
zemion fa9119bea7 security(webui): block remote mail preview content 2026-07-21 17:52:02 +02:00
zemion 70ca772138 test: gate Mail on Campaign access interface 2026-07-21 17:51:35 +02:00
zemion 2eae5c4df6 test: align module contracts with Mail-owned delivery 2026-07-21 17:15:19 +02:00
215 changed files with 31152 additions and 1559 deletions
+23 -8
View File
@@ -4,13 +4,6 @@
**Repository type:** system (kernel).
<!-- govoplan-repository-type:end -->
[![Module Matrix](https://git.add-ideas.de/add-ideas/govoplan/actions/workflows/module-matrix.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=module-matrix.yml&actor=0&status=0)
[![Release Integration](https://git.add-ideas.de/add-ideas/govoplan/actions/workflows/release-integration.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=release-integration.yml&actor=0&status=0)
[![Dependency Audit](https://git.add-ideas.de/add-ideas/govoplan/actions/workflows/dependency-audit.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=dependency-audit.yml&actor=0&status=0)
[![Security Audit](https://git.add-ideas.de/add-ideas/govoplan/actions/workflows/security-audit.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=security-audit.yml&actor=0&status=0)
# govoplan-core
GovOPlaN core is the platform runner and shared foundation. It owns the server entry point, database/session primitives, module discovery, migration orchestration, capability contracts, install/uninstall orchestration, and the shared WebUI shell. Platform and feature behavior is supplied by installed modules.
## Repository ownership
@@ -23,6 +16,9 @@ Core owns:
- kernel APIs for platform metadata, module lifecycle, health, and development diagnostics
- `@govoplan/core-webui`, including login, CSRF/API helpers, shell layout, generic UI components, IconRail, DataGrid, access boundaries, and module route/nav contracts
The shared DataGrid sizing and resize invariants are specified in
[`docs/DATAGRID_SIZING_CONTRACT.md`](docs/DATAGRID_SIZING_CONTRACT.md).
Platform and feature modules own their backend routers, models, migrations,
permissions, frontend packages, nav items, and route contributions. Access,
tenancy, policy, audit, and admin behavior live in their owning platform
@@ -54,7 +50,7 @@ python3 -m venv .venv
./.venv/bin/python -m pip install -r requirements-dev.txt
```
Run the platform server from core through the module-aware development runner. The default config reads `ENABLED_MODULES` and discovers installed module entry points. Local development defaults to `tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops`; set `ENABLED_MODULES` explicitly when testing a smaller module permutation.
Run the platform server from core through the module-aware development runner. The default config reads `ENABLED_MODULES` and discovers installed module entry points. Local development defaults to the modules listed by `govoplan_core.settings.Settings.enabled_modules`, including Views when its package is installed; set `ENABLED_MODULES` explicitly when testing a smaller module permutation.
```bash
cd /mnt/DATA/git/govoplan-core
@@ -74,6 +70,21 @@ ENABLED_MODULES=access,campaigns /mnt/DATA/git/govoplan/.venv/bin/python -m govo
The runner loads the same `GovoplanServerConfig` as `govoplan_core.server.app:app`, builds the platform registry, and passes core plus enabled module source roots to uvicorn as reload directories. After reinstalling the editable package, the same command is also available as `govoplan-devserver`.
For focused backend work, keep the complete module graph active while watching
only the module being edited. Core/config sources and explicit `--reload-dir`
paths remain watched:
```bash
/mnt/DATA/git/govoplan/.venv/bin/python -m govoplan_core.devserver \
--reload-module calendar \
--reload-module campaign
```
Use `--reload-core-only` when no optional module source tree should trigger a
restart. Omitting both options preserves the broad default and watches every
enabled module. Startup, migration, and compatibility checks still run against
the complete enabled graph whenever the backend restarts.
The default development database is PostgreSQL at `postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev`. Store the password in `~/.pgpass`. To force the disposable SQLite fallback, run with `GOVOPLAN_DEV_DATABASE_BACKEND=sqlite`; that database lives below `runtime/`.
Local devserver runs do not require Redis. `CELERY_ENABLED` defaults to `false`, so campaign queue actions update database state without publishing Celery tasks. Use the synchronous send flow for local send tests, or set `CELERY_ENABLED=true` only when a Redis broker and worker are running.
@@ -143,6 +154,10 @@ PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versio
The local host links sibling module WebUI packages through local file dependencies and Vite filesystem allowances. Release builds should use `webui/package.release.json`, which points WebUI module packages at tagged git refs. See [RELEASE_DEPENDENCIES.md](docs/RELEASE_DEPENDENCIES.md).
Production builds lazy-load enabled module descriptors and enforce initial and
asynchronous JavaScript budgets. See
[WEBUI_BUNDLE_BUDGETS.md](docs/WEBUI_BUNDLE_BUDGETS.md).
## Module contract
Backend modules register through the `govoplan.modules` entry point and return a `ModuleManifest`. A manifest can contribute:
@@ -0,0 +1,87 @@
"""add reusable core credential envelopes
Revision ID: c91f0a72be34
Revises: 0f1e2d3c4b5a
Create Date: 2026-07-23 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c91f0a72be34"
down_revision = "0f1e2d3c4b5a"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "core_credential_envelopes" in inspector.get_table_names():
return
op.create_table(
"core_credential_envelopes",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("scope_type", sa.String(length=20), nullable=False),
sa.Column("scope_id", sa.String(length=255), nullable=True),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("credential_kind", sa.String(length=40), nullable=False),
sa.Column("public_data", sa.JSON(), nullable=False),
sa.Column("secret_data_encrypted", sa.Text(), nullable=True),
sa.Column("secret_keys", sa.JSON(), nullable=False),
sa.Column("allowed_modules", sa.JSON(), nullable=False),
sa.Column("allowed_server_refs", sa.JSON(), nullable=False),
sa.Column("inherit_to_lower_scopes", sa.Boolean(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("revision", sa.String(length=36), nullable=False),
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
sa.Column("updated_by_user_id", sa.String(length=36), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["tenant_id"],
["core_scopes.id"],
name=op.f("fk_core_credential_envelopes_tenant_id_core_scopes"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_credential_envelopes")),
)
op.create_index(
"ix_core_credential_envelopes_scope",
"core_credential_envelopes",
["tenant_id", "scope_type", "scope_id"],
unique=False,
)
op.create_index(
"ix_core_credential_envelopes_active",
"core_credential_envelopes",
["tenant_id", "is_active", "deleted_at"],
unique=False,
)
for column in (
"tenant_id",
"scope_type",
"scope_id",
"credential_kind",
"is_active",
"created_by_user_id",
"updated_by_user_id",
"deleted_at",
):
op.create_index(
op.f(f"ix_core_credential_envelopes_{column}"),
"core_credential_envelopes",
[column],
unique=False,
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "core_credential_envelopes" in inspector.get_table_names():
op.drop_table("core_credential_envelopes")
@@ -0,0 +1,24 @@
"""development-track wrapper for generic ownership transfers."""
from __future__ import annotations
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
_path = (
Path(__file__).resolve().parents[1]
/ "versions"
/ "d03a7b9c1e5f_core_ownership_transfers.py"
)
_spec = spec_from_file_location("govoplan_core_ownership_transfer_migration", _path)
if _spec is None or _spec.loader is None:
raise RuntimeError(f"Unable to load ownership migration from {_path}")
_migration = module_from_spec(_spec)
_spec.loader.exec_module(_migration)
revision = _migration.revision
down_revision = _migration.down_revision
branch_labels = _migration.branch_labels
depends_on = _migration.depends_on
upgrade = _migration.upgrade
downgrade = _migration.downgrade
+7 -1
View File
@@ -5,9 +5,15 @@ from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
try:
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate optional access metadata
except ModuleNotFoundError as exc:
if exc.name != "govoplan_access":
raise
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
from govoplan_core.core import ownership as core_ownership_models # noqa: F401 - populate core metadata
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
from govoplan_core.core.migrations import migration_metadata_plan
from govoplan_core.db.base import Base
from govoplan_core.server.default_config import get_server_config
@@ -0,0 +1,119 @@
"""add reusable core credential envelopes
Revision ID: c91f0a72be34
Revises: 4f2a9c8e7b6d
Create Date: 2026-07-23 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c91f0a72be34"
down_revision = "4f2a9c8e7b6d"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "core_credential_envelopes" in inspector.get_table_names():
return
op.create_table(
"core_credential_envelopes",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("scope_type", sa.String(length=20), nullable=False),
sa.Column("scope_id", sa.String(length=255), nullable=True),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("credential_kind", sa.String(length=40), nullable=False),
sa.Column("public_data", sa.JSON(), nullable=False),
sa.Column("secret_data_encrypted", sa.Text(), nullable=True),
sa.Column("secret_keys", sa.JSON(), nullable=False),
sa.Column("allowed_modules", sa.JSON(), nullable=False),
sa.Column("allowed_server_refs", sa.JSON(), nullable=False),
sa.Column("inherit_to_lower_scopes", sa.Boolean(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("revision", sa.String(length=36), nullable=False),
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
sa.Column("updated_by_user_id", sa.String(length=36), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["tenant_id"],
["core_scopes.id"],
name=op.f("fk_core_credential_envelopes_tenant_id_core_scopes"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_credential_envelopes")),
)
op.create_index(
"ix_core_credential_envelopes_scope",
"core_credential_envelopes",
["tenant_id", "scope_type", "scope_id"],
unique=False,
)
op.create_index(
"ix_core_credential_envelopes_active",
"core_credential_envelopes",
["tenant_id", "is_active", "deleted_at"],
unique=False,
)
op.create_index(
op.f("ix_core_credential_envelopes_tenant_id"),
"core_credential_envelopes",
["tenant_id"],
unique=False,
)
op.create_index(
op.f("ix_core_credential_envelopes_scope_type"),
"core_credential_envelopes",
["scope_type"],
unique=False,
)
op.create_index(
op.f("ix_core_credential_envelopes_scope_id"),
"core_credential_envelopes",
["scope_id"],
unique=False,
)
op.create_index(
op.f("ix_core_credential_envelopes_credential_kind"),
"core_credential_envelopes",
["credential_kind"],
unique=False,
)
op.create_index(
op.f("ix_core_credential_envelopes_is_active"),
"core_credential_envelopes",
["is_active"],
unique=False,
)
op.create_index(
op.f("ix_core_credential_envelopes_created_by_user_id"),
"core_credential_envelopes",
["created_by_user_id"],
unique=False,
)
op.create_index(
op.f("ix_core_credential_envelopes_updated_by_user_id"),
"core_credential_envelopes",
["updated_by_user_id"],
unique=False,
)
op.create_index(
op.f("ix_core_credential_envelopes_deleted_at"),
"core_credential_envelopes",
["deleted_at"],
unique=False,
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "core_credential_envelopes" in inspector.get_table_names():
op.drop_table("core_credential_envelopes")
@@ -0,0 +1,129 @@
"""add generic resource ownership transfer state
Revision ID: d03a7b9c1e5f
Revises: c91f0a72be34
Create Date: 2026-07-30 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "d03a7b9c1e5f"
down_revision = "c91f0a72be34"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "core_ownership_transfers" in inspector.get_table_names():
return
op.create_table(
"core_ownership_transfers",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("resource_module", sa.String(length=100), nullable=False),
sa.Column("resource_type", sa.String(length=100), nullable=False),
sa.Column("resource_id", sa.String(length=255), nullable=False),
sa.Column("kind", sa.String(length=40), nullable=False),
sa.Column("status", sa.String(length=50), nullable=False),
sa.Column("current_owner_type", sa.String(length=40), nullable=False),
sa.Column("current_owner_id", sa.String(length=255), nullable=False),
sa.Column("target_owner_type", sa.String(length=40), nullable=False),
sa.Column("target_owner_id", sa.String(length=255), nullable=False),
sa.Column("initiated_by_type", sa.String(length=40), nullable=False),
sa.Column("initiated_by_id", sa.String(length=255), nullable=False),
sa.Column("owner_approved_by_type", sa.String(length=40), nullable=True),
sa.Column("owner_approved_by_id", sa.String(length=255), nullable=True),
sa.Column("target_accepted_by_type", sa.String(length=40), nullable=True),
sa.Column("target_accepted_by_id", sa.String(length=255), nullable=True),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("assurance_profile", sa.String(length=80), nullable=True),
sa.Column("required_approvals", sa.Integer(), nullable=False),
sa.Column("approvals", sa.JSON(), nullable=False),
sa.Column("decisions", sa.JSON(), nullable=False),
sa.Column("idempotency_key", sa.String(length=200), nullable=False),
sa.Column("canonical_request_hash", sa.String(length=64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("execute_after", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("declined_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("expired_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_ownership_transfers")),
sa.UniqueConstraint(
"tenant_id",
"resource_module",
"idempotency_key",
name="uq_core_ownership_transfer_idempotency",
),
)
op.create_index(
op.f("ix_core_ownership_transfers_tenant_id"),
"core_ownership_transfers",
["tenant_id"],
unique=False,
)
op.create_index(
op.f("ix_core_ownership_transfers_kind"),
"core_ownership_transfers",
["kind"],
unique=False,
)
op.create_index(
op.f("ix_core_ownership_transfers_status"),
"core_ownership_transfers",
["status"],
unique=False,
)
op.create_index(
"ix_core_ownership_transfer_resource",
"core_ownership_transfers",
[
"tenant_id",
"resource_module",
"resource_type",
"resource_id",
"status",
],
unique=False,
)
op.create_index(
"ix_core_ownership_transfer_expiry",
"core_ownership_transfers",
["status", "expires_at"],
unique=False,
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "core_ownership_transfers" not in inspector.get_table_names():
return
op.drop_index(
"ix_core_ownership_transfer_expiry",
table_name="core_ownership_transfers",
)
op.drop_index(
"ix_core_ownership_transfer_resource",
table_name="core_ownership_transfers",
)
op.drop_index(
op.f("ix_core_ownership_transfers_status"),
table_name="core_ownership_transfers",
)
op.drop_index(
op.f("ix_core_ownership_transfers_kind"),
table_name="core_ownership_transfers",
)
op.drop_index(
op.f("ix_core_ownership_transfers_tenant_id"),
table_name="core_ownership_transfers",
)
op.drop_table("core_ownership_transfers")
+11 -2
View File
@@ -5,7 +5,7 @@ only linear screen flows. Workflows, schedules, imports, connectors, policies,
and external events all need to request governed actions without bypassing the
same safety rules that apply to human users.
The first implementation should live in `govoplan-workflow` and core contracts.
The first implementation lives in `govoplan-workflow-engine` and Core contracts.
Create a separate `govoplan-automation` module only if action planning,
schedulers, rule execution, or cross-module automation become too broad for
workflow ownership.
@@ -29,6 +29,10 @@ of module capabilities.
## Action Definition
An `ActionDefinition` describes something a human or system actor can request.
The versioned runtime DTOs and provider protocol live in
`govoplan_core.core.automation`; domain modules implement the protocol and
Workflow resolves providers through module capabilities rather than importing
their implementations.
Recommended fields:
@@ -110,10 +114,15 @@ Automation should use explicit failure states:
These states should be visible in workflow, task, and admin diagnostics.
The contract names these states explicitly as `ActionExecutionState`, alongside
`pending`, `running`, and `completed`. A provider returns observed effects even
for partial failures; the runner, not the provider, owns durable attempts,
recovery decisions, and workflow advancement.
## Boundary
Core may own stable DTOs, registry contracts, and generic audit/event hooks.
`govoplan-workflow` should own the first runner because workflow is the first
`govoplan-workflow-engine` owns the first runner because workflow is the first
module that coordinates cross-module process actions.
Domain modules own their own action providers. For example, templates own
+59
View File
@@ -0,0 +1,59 @@
# Automation Contracts
Core defines provider-neutral automation contracts. It does not own domain
schedules, Workflow graphs, or Dataflow execution.
## Invocation Envelope
`AutomationInvocation` classifies a start as `manual`, `api`, `schedule`,
`event`, `workflow`, `dependency`, `retry`, or `backfill`. It carries opaque
trigger and delivery references, event identity, correlation and causation
IDs, scheduled time, requesting actor, and bounded metadata. Domain runs store
this envelope with their immutable definition revision.
## Current Authorization
An automated trigger must not persist a user session, bearer token, API key,
or a snapshot of all current permissions. It stores:
- tenant, account, and membership IDs;
- an opaque authorization reference;
- the minimum scopes required by the pinned definition and output target.
At delivery time the optional
`auth.automationPrincipalProvider` capability resolves current account,
membership, role, group, function, and delegation state. It intersects current
authorization with the stored grant. Missing, inactive, or reduced
authorization blocks the delivery before effects occur.
## Definition Governance
The optional `policy.definitionGovernance` capability evaluates `view`,
`edit`, `run`, `reuse`, `derive`, and `automate` for system, tenant, group, and
user definitions. A decision contains an ordered source path and effective
limits. Derived definitions pin their source revision and hash and retain
ancestor ceilings. Templates are reusable definitions and cannot run on their
own.
Without Policy, domain modules use a conservative tenant-local fallback:
local definitions remain viewable/editable and active complete flows may run;
inheritance, reuse, derivation, and automation are unavailable.
## Delivery Durability
Domain trigger implementations persist idempotent deliveries before running.
`emit_platform_event` binds event delivery to the producer's SQLAlchemy
transaction. When an enabled module provides `platform.eventOutbox`, the event
is stored in that transaction and a dispatcher may retry it across restarts and
workers. The Audit module provides the current SQL outbox implementation; the
Celery `govoplan.events.dispatch_outbox` task drains it through the Dataflow
event-ingestion capability and the local event bus.
The outbox capability remains optional so reduced module combinations can
start. Without it, Core queues events on the SQLAlchemy transaction and
publishes them to the process-local bus only after the outer commit. A rollback,
including a nested savepoint rollback, discards the corresponding events. This
fallback is suitable for local or non-critical reactions, but it is not a
durable multi-worker automation source. Deployments that rely on event-triggered
work must enable the outbox provider and run the `events` worker queue and
periodic dispatcher.
+43
View File
@@ -0,0 +1,43 @@
# GovOPlaN Compatibility Inventory
This inventory classifies compatibility paths covered by
`COMPATIBILITY_POLICY.md`. It is intentionally limited to behavior that changes
accepted data, imports, permissions, or migration state. Operational fallbacks
such as Redis degradation and language fallback are not compatibility paths.
## Database Bridges
| Path | Purpose | Retention | Removal |
| --- | --- | --- | --- |
| `govoplan_core.db.migrations.reconcile_legacy_create_all_schema` | Reconciles databases created before Alembic ownership was recorded. | At least one major release after runtime aliases are removed. | Review after `1.0`; keep release-baseline tests. |
| Migration table/column aliases in `govoplan_core.db.migrations` | Detect and reconcile pre-split table ownership and migration tracks. | All tagged `0.1.x` upgrade origins plus one major release cycle. | Remove only after the corresponding baseline leaves support. |
| Access and module migration backfills for legacy permission names | Converts persisted role assignments without dropping authority. | Same as the database upgrade origin that contains the old role. | Keep migrations immutable; remove only runtime expansion at `0.2`. |
## Portable-Schema Readers
| Path | Purpose | Retention | Removal |
| --- | --- | --- | --- |
| `govoplan_core.mail.config.normalize_split_transport_credentials` | Reads pre-split SMTP/IMAP credentials and emits the split representation. | Current and previous two configuration schema versions. | Version-gate once Mail writes an explicit current schema version; reject inputs older than the two-version window. |
| `ImapServerConfig.discard_legacy_enabled` | Reads the former nested IMAP `enabled` field without writing it. | Current and previous two configuration schema versions. | Remove with the oldest accepted Mail configuration schema. |
| `govoplan_core.core.configuration_packages` readers | Reads explicitly versioned configuration-package manifests. | Current and previous two schema versions. | Retire individual readers as their version leaves the window. |
## Runtime And API Aliases
| Path | Purpose | Retention | Removal |
| --- | --- | --- | --- |
| `govoplan_core.security.scope_aliases.LEGACY_SCOPE_ALIASES` | Expands pre-granular permission names. | Tagged `0.1.x` runtime/API window. | Remove at `0.2` after role backfills and migration notes are verified. |
| `govoplan_core.security.module_permissions.LEGACY_TO_MODULE_SCOPES` | Maps pre-module-split scopes to canonical owning-module scopes. | Tagged `0.1.x` runtime/API window. | Remove at `0.2`; keep database migration evidence for one major cycle. |
| `govoplan_core.privacy.retention` | Stable import facade delegating policy-owned behavior through a capability. | Tagged `0.1.x` import window. | Remove at `0.2` after all in-tree callers use the policy contract and release notes name the replacement. |
| Legacy tenant aliases in API response schemas | Preserves active-tenant response fields used by `0.1.x` clients. | Tagged `0.1.x` API window. | Remove at `0.2` with response-schema migration notes. |
| Optional fields in Poll response references | Accepts providers built against the earlier Poll contract. | Tagged `0.1.x` runtime contract window. | Remove or require a new interface version at `0.2`. |
| Legacy single-tenant summary providers | Allows modules without the batch provider introduced in `0.1.x`. | Tagged `0.1.x` module contract window. | Remove at `0.2` after manifests advertise the batch provider contract. |
## Removed Paths
| Path | Reason | Removed |
| --- | --- | --- |
| `govoplan_core.core.module_installer._run_restart_command_legacy` | Private wrapper had no callers and never represented a persisted or published contract. | Current development line |
| Retired `govoplan_core.api.admin` and pre-split core model imports | In-tree callers and module packages use their owning modules; regression tests prohibit reintroduction. | Before `0.1.10` |
Every new compatibility path must be added here with its classification,
diagnostic, test owner, and planned removal release.
+69
View File
@@ -0,0 +1,69 @@
# GovOPlaN Compatibility Policy
This document defines the compatibility window that release tooling, module
owners, migration authors, import/export providers, and API maintainers must
preserve. It is the source of truth for deciding whether compatibility code can
be removed.
## Database Upgrades
- A released installation from every tagged `0.1.x` version is a supported
database upgrade origin.
- The recorded public release-baseline ledger starts at `v0.1.7`; earlier
`0.1.x` tags predate production installations. If an earlier tagged database
is encountered, the release must provide or document a compatibility bridge
instead of silently treating the database as a fresh installation.
- Released migration revision IDs and recorded release heads are immutable.
- Each release must prove an upgrade from every still-supported recorded
baseline, as well as a fresh installation, before its tag is published.
- Migration-only reconciliation needed by an old database remains available for
at least one subsequent major release cycle after the corresponding runtime
compatibility path is removed.
The release-baseline format and commands are documented in
`RELEASE_DEPENDENCIES.md`.
## Configuration And Export Schemas
- Writers emit only the current schema version.
- Readers accept the current schema version and the previous two schema
versions.
- Older input is rejected with a diagnostic that identifies its version and the
required staged upgrade or conversion path.
- A module-owned configuration provider must version its input and output
schema explicitly. It must not infer an old schema from missing fields once a
versioned schema has shipped.
- Round-trip and upgrade tests must cover all three readable versions before a
schema change is released.
This window applies to configuration packages, module-owned exports, and other
portable GovOPlaN configuration artifacts. Domain interchange standards with
their own compatibility rules remain governed by the owning module.
## Runtime And API Aliases
- Compatibility aliases must emit an explicit deprecation diagnostic and point
to the supported replacement.
- New callers must use the canonical contract. In-tree callers may not add new
uses of a deprecated alias.
- Runtime imports, request fields, response fields, routes, and scope aliases
carried for the `0.1.x` split line are retired at `0.2`, with migration notes.
- An alias may be removed earlier only when it never shipped in a tag or when a
security fix requires removal. The release notes must state the exception.
- Database reconciliation code is not a runtime/API alias and follows the
longer database window above.
## Removal Checklist
Compatibility code can be removed only when all of the following are true:
1. The path is inventoried as a database bridge, portable-schema reader, or
runtime/API alias.
2. Its minimum retention window has elapsed.
3. In-tree callers and published module manifests use the replacement.
4. Upgrade, import, or API regression tests cover the retained window.
5. Diagnostics and migration notes identify any staged action operators must
take.
If one condition is not met, version-gate the compatibility path and record its
planned removal release instead of deleting it.
+5
View File
@@ -69,6 +69,11 @@ Required package metadata:
- migration or transformation rules for older package versions
- provenance, export source metadata, and signature metadata
Portable configuration schemas follow `COMPATIBILITY_POLICY.md`: providers
write only their current schema version and read that version plus the previous
two versions. Older input must produce a version-specific staged-upgrade
diagnostic.
Configuration fragments are interpreted only by the module that owns them. For
example, workflow imports workflow definitions; forms imports form schemas;
mail imports mail templates and delivery defaults; payments imports payment
+67
View File
@@ -0,0 +1,67 @@
# DataGrid Sizing Contract
`DataGrid` turns every declared track into a deterministic pixel layout after
its container has a measurable width. The same contract is used on initial
layout, container resize, persisted-layout restore, and pointer resize.
## Column Declarations
- `width: number` or `Npx` is the preferred pixel width.
- `width: N%` is a preferred share of the measured container.
- `width: Nfr` shares residual width by fraction weight.
- `width: minmax(Npx, preferred)` combines a hard lower bound with any
supported preferred width.
- An omitted width and the legacy `fill` flag are one-fraction flexible tracks.
- `minWidth` is a hard floor. Header sort, filter, and resize controls may raise
the effective accessible floor.
- `maxWidth` bounds direct user growth and free/constrained compensation. In a
cover layout it is a preferred maximum: passive tracks may exceed it when
that is necessary to keep the table flush with its container.
## Layout Modes
| `initialFit` | `resizeBehavior` | Initial layout | Pointer resize |
| --- | --- | --- | --- |
| `container` | `cover` | Real columns fill the container. Hard-minimum excess scrolls horizontally. | Growth may create horizontal overflow. Shrink first consumes overflow, then grows resizable columns to the right; it stops before underflow. |
| `content` | `cover` | After measurement, the same cover invariant applies. | Same as cover above. |
| `container` | `constrained` | Real columns fill the container. | Peer compensation respects every hard min/max and stops the active resize when capacity is exhausted. |
| `content` | `free` | Declared content widths are retained. | Only the active column changes, so underflow or horizontal overflow is allowed. |
| `container` | `free` | The initial layout fills the container. | Later user resizing is free. A right-sticky column promotes this mode to cover so its edge remains stable. |
Sticky columns do not absorb ordinary cover residuals and are not resize
compensation targets. A last resizable column may grow into overflow. It may
shrink only by the current overflow, because shrinking farther would require a
blank filler track. Dragging farther past that stop does not bank width changes:
the column remains stopped until the pointer crosses the same boundary again.
## Persistence
Only the pixel layout resulting from an explicit user resize is persisted.
Persisted widths are keyed by a signature containing column IDs, declared
widths and bounds, resize affordances, sticky placement, initial fit, and resize
behavior. A changed signature discards the old override and recomputes the
declared layout.
Container reconciliation is suspended while a pointer drag is active. On
release, the already-rendered pixel layout becomes the persisted preference.
Reconciliation may grow it to prevent underflow, but never shrinks intentional
user overflow, so there is no drag-end snap.
## Regression Matrix
`webui/tests/data-grid-sizing.test.ts` covers:
- pixel, percentage, fraction, `minmax`, omitted, and legacy-fill tracks;
- preferred max exhaustion without a synthetic filler column;
- hard-minimum horizontal overflow;
- fixed-only cover grids;
- persisted overrides under growth and viewport pressure;
- stale layout signatures;
- first and middle-column right-side compensation;
- last-resizable-column overflow, underflow stop, and reverse-pointer boundary;
- free, cover, and constrained resizing;
- cover-expanded tracks that already exceed preferred maxima; and
- preservation of the pointer layout across the commit fit.
`webui/tests/data-grid-actions.test.tsx` also verifies the rendered fixed-cover
shape and guards against reintroducing a synthetic buffer cell.
+22 -7
View File
@@ -144,8 +144,12 @@ tools/checks/postgres-integration-check.py \
```
The integration check runs migrations and startup smoke checks across the
standard module permutations. `--reset-schema` is destructive and belongs only
on throwaway databases.
standard module permutations. It first requires the retirement atomicity proof,
using Files' real secret-owning provider and Audit's persistent recorder. That
proof uses only random, test-owned schemas and cleans them afterward; it does
not reset `public`. `--reset-schema` is destructive and belongs only on
throwaway databases. Do not pass `--skip-retirement-atomicity` when collecting
release evidence.
### Broker And Workers
@@ -153,13 +157,15 @@ on throwaway databases.
| --- | --- | --- |
| `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. |
| `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. |
| `CELERY_QUEUES` | `send_email,append_sent,notifications,calendar,default` | Queue list expected by worker/process manager definitions. The Calendar queue drains durable external-calendar operations. |
| `CELERY_QUEUES` | `send_email,append_sent,notifications,calendar,dataflow,workflow,events,default` | Queue list expected by worker/process manager definitions. The `events` queue drains transactional platform events; `dataflow` drains trigger deliveries and schedules; `workflow` reconciles Workflow Engine instances and module standards. |
| `PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS` | `8` | Failed durable consumer deliveries are quarantined after this many attempts. |
| `PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS` | `90` | Successful event envelopes older than this are removed by the daily retention task. Quarantined evidence is retained. |
Worker command:
```bash
python -m celery -A govoplan_core.celery_app:celery worker \
--queues send_email,append_sent,notifications,calendar,default \
--queues send_email,append_sent,notifications,calendar,dataflow,events,default \
--loglevel INFO
```
@@ -183,6 +189,11 @@ python -m celery -A govoplan_core.celery_app:celery beat --loglevel INFO
| `FILE_STORAGE_S3_ACCESS_KEY_ID` | empty | Secret; inject through deployment environment. |
| `FILE_STORAGE_S3_SECRET_ACCESS_KEY` | empty | Secret; inject through deployment environment. |
| `FILE_STORAGE_S3_BUCKET` | `files` | Managed-file object bucket. |
| `FILE_STORAGE_S3_DEPLOYMENT_MANAGED` | `false` | Reserved for the installer-owned `http://garage:3900` service. It does not authorize arbitrary internal or external S3 endpoints. |
| `FILE_ARCHIVE_MAX_ENTRIES` | `10000` | Maximum declared entries accepted during archive preview and confirmation. |
| `FILE_ARCHIVE_MAX_EXPANDED_BYTES` | `2147483648` | Maximum actual expanded bytes across selected archive content. |
| `FILE_ARCHIVE_MAX_EXPANSION_RATIO` | `100` | Maximum expanded-to-compressed archive ratio. |
| `FILE_ARCHIVE_PREVIEW_TTL_SECONDS` | `1800` | Lifetime of the sealed actor/archive/destination-bound preview token. |
Legacy `S3_*` settings remain for older storage paths but new deployments should
prefer `FILE_STORAGE_*`.
@@ -205,9 +216,13 @@ prefer `FILE_STORAGE_*`.
Interactive password login is enabled with fixed-window limits of 10 failures
per normalized identity and 100 failures per direct client over 900 seconds.
`AUTH_LOGIN_THROTTLE_*` settings change those limits. Counters use `REDIS_URL`
when Redis is reachable so replicas share state; a bounded process-local
fallback keeps development and Redis outages functional, with per-process
enforcement until Redis recovers.
when Redis is reachable so replicas share state. Production-like startup fails
when throttling is enabled without `REDIS_URL`. Set
`GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE=true` only as an explicit
single-process risk acceptance. A bounded process-local fallback keeps
development and temporary Redis outages functional, with per-process
enforcement until Redis recovers; monitor Redis because protection is weaker
during that fallback.
### Outbound Connector Egress
+2
View File
@@ -9,6 +9,7 @@ operator, and roadmap pages.
| Topic | Canonical document | Notes |
| --- | --- | --- |
| Module architecture and kernel contracts | `MODULE_ARCHITECTURE.md` | Stable module contracts, API efficiency contracts, durable boundary decisions, lifecycle, and WebUI contribution rules. |
| Compatibility policy | `COMPATIBILITY_POLICY.md` | Supported database upgrade origins, portable-schema read/write windows, runtime/API alias retirement, and compatibility-code removal criteria. |
| RBAC and resource access | `ACCESS_RBAC_MODEL.md` | Current permission, role, API-key, and resource-access model. |
| Governance hierarchy | `GOVERNANCE_MODEL.md` | System, tenant, user/group, campaign policy inheritance and admin UI structure. |
| Policy decision DTOs and provenance | `POLICY_CONTRACTS.md` | Shared explain/provenance shape; module-specific policy docs should link here. |
@@ -25,6 +26,7 @@ operator, and roadmap pages.
| Release dependencies and catalogs | `RELEASE_DEPENDENCIES.md` | Release package refs, migration baselines, release lockfiles, catalog trust/licensing, catalog publishing, and release checklist. |
| Dependency vulnerability audits | `DEPENDENCY_AUDITS.md` | Local and CI audit commands plus dated audit result notes. |
| Remote WebUI bundle design | `REMOTE_WEBUI_BUNDLES.md` | Experimental controlled-deployment design; normal releases still use package builds. |
| WebUI loading and bundle budgets | `WEBUI_BUNDLE_BUDGETS.md` | Installed-module lazy boundaries, enforced initial/async budgets, and baseline measurements. |
## Product And Module Planning
+27 -19
View File
@@ -8,25 +8,32 @@ module reactions, and operator diagnostics.
## Production Transport Decision
The first production target is a **database outbox plus in-process immediate
dispatch**:
The production transport is a **transactional database outbox plus retrying
dispatcher**:
- Use `govoplan_core.core.events.PlatformEvent` for domain and platform events.
- Use `EventBus` as the in-process dispatch contract for same-process module
reactions that are safe to run inline.
- Call `emit_platform_event(session, event)` to bind event delivery to the
domain transaction.
- The optional `platform.eventOutbox` capability persists events atomically.
The Audit module provides the current SQL implementation.
- Without an outbox provider, Core publishes to `EventBus` only after the outer
transaction commits. This preserves reduced installations but is not durable
across process failure or multiple workers.
- Use `EventBus` as the in-process dispatch contract for module reactions
invoked by the outbox dispatcher or for non-critical fallback reactions.
- Use the shared `audit_event` / `audit_from_principal` helper for audited
module actions. The helper persists the audit row and immediately publishes a
module actions. The helper persists the audit row and transactionally emits a
governed `PlatformEvent` whose `type` is the audit action.
- Use `record_change` for module delta feeds. It persists the change-sequence
row and immediately publishes a generic module change event such as
row and transactionally emits a generic module change event such as
`mail.profile.updated`.
- Persist durable integration/workflow events through a database outbox before
acknowledging the state change that produced them.
- Drain the outbox through a small dispatcher process. The dispatcher may call
in-process handlers in the same deployment first, but its storage contract is
database-backed.
- Drain the outbox with the Celery `govoplan.events.dispatch_outbox` task on the
`events` queue. The periodic schedule also retries pending rows.
- The dispatcher invokes Dataflow event ingestion when that capability is
active, then publishes to the process-local bus.
- Treat Redis/Celery as worker/job infrastructure, not as the authoritative
first event transport. A Celery dispatcher can consume the outbox later.
first event transport. PostgreSQL remains authoritative until dispatch is
recorded.
- Keep the dispatch implementation pluggable behind the `PlatformEvent`
envelope so a future message broker can be added without changing event
producers.
@@ -44,17 +51,18 @@ Event producers should write their domain state and outbox event in the same
database transaction wherever possible. Handlers must be idempotent because the
outbox dispatcher can retry after a crash or timeout.
Recommended first outbox columns:
The current outbox stores:
- `event_id`, `event_type`, `module_id`
- `correlation_id`, `causation_id`
- `payload`, `occurred_at`
- `available_at`, `attempt_count`, `claimed_at`, `claim_token`
- `processed_at`, `last_error`
- `classification`, serialized event `payload`
- `status`, `attempts`, `next_attempt_at`
- `dispatched_at`, `last_error`, timestamps
Inline `EventBus` handlers are allowed only for non-critical local reactions.
Anything that must survive process failure, restart, package update, or worker
redeployment belongs in the outbox.
Handlers must be idempotent: a worker may complete an external effect and fail
before marking its outbox row dispatched. Anything that must survive process
failure, restart, package update, or worker redeployment requires the outbox
provider and dispatcher.
## Trace IDs
@@ -0,0 +1,47 @@
# External References And Integration Maturity
GovOPlaN integrations use a shared external-reference contract instead of
storing connector-specific URLs and identifiers in every module.
An external reference identifies an object by:
- external system instance
- object type
- stable external object ID
- optional connector configuration
- canonical HTTP(S) URL without embedded credentials
- optional source version, ETag, observation time, and non-secret metadata
The identity key is `system:object_type:object_id`. A GovOPlaN object may retain
multiple references, but one reference must never silently change its identity.
Moving or escalating work creates a new object and an explicit relationship; it
does not rewrite either object's history.
## Integration Maturity
Maturity is cumulative:
1. `discover`: identify configured external systems and their health.
2. `link`: retain and open stable external references.
3. `search`: include authorized external objects in GovOPlaN search.
4. `read`: display authoritative external content.
5. `publish`: create or update external content from GovOPlaN.
6. `synchronize`: reconcile changes in both directions with conflict handling.
7. `migrate`: perform a governed, verifiable transfer into GovOPlaN.
8. `replace`: provide the native operational capability without the external tool.
Connectors must declare and document the maturity they actually implement.
`synchronize` requires durable cursors, idempotency, provenance, conflict
handling, deletion semantics, and observable failures. A link-only connector
must not imply that GovOPlaN holds an authoritative copy.
## Domain Ownership
- Domain modules own native GovOPlaN objects and their authorization.
- Connectors own protocols, credentials, discovery, transport, and sync state.
- Search owns indexing and result aggregation, but source modules remain
responsible for authorization.
- Core owns only the stable DTOs and extension contracts.
The Python contract is
`govoplan_core.core.external_references.ExternalObjectReference`.
+46 -46
View File
@@ -10,12 +10,12 @@ gates. Issues are the active backlog; this document is durable architecture
planning context and should be mirrored to the Gitea wiki.
The meta repository's
[Connected Governance Platform Roadmap](https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
[Connected Governance Platform Roadmap](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
describes the corresponding cross-product stakeholder visions, configurable
service and operating configurations, connected outcome stories, and
capability horizons. The selected five-stage delivery sequence and its gates
are in the meta repository's
[Reference Journey Program](https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/REFERENCE_JOURNEY_PROGRAM.md).
[Reference Journey Program](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/REFERENCE_JOURNEY_PROGRAM.md).
Those product documents are canonical; this Core roadmap remains their
technical sequencing and module-routing companion.
@@ -412,7 +412,7 @@ Goal: cover internal support and public issue reporting.
Create or refine in this order:
1. `govoplan-issue-reporting`: public/internal reports, categories, intake,
1. `govoplan-tickets`: public/internal reports, requests, incidents, queues,
location, evidence, and triage.
2. `govoplan-helpdesk`: service desk tickets, queues, SLAs, assignments,
escalation, and resolution evidence.
@@ -625,48 +625,48 @@ repositories or to explicit missing-module decisions.
| Idea | Owner | Tracking |
| --- | --- | --- |
| Government operations backbone reference model | `govoplan-core` | `add-ideas/govoplan-core#213` |
| Permit-to-payment configuration package | `govoplan-core` plus participating modules | `add-ideas/govoplan-core#214` |
| Fully UI-managed configuration with safety controls | `govoplan-admin`, `govoplan-core`, `govoplan-policy`, `govoplan-access`, `govoplan-audit` | `add-ideas/govoplan-core#218` |
| Access as a module | `govoplan-access` | `add-ideas/govoplan-access#7` |
| Interface ethics and decision-consequence doctrine | `govoplan-core` plus all UI-owning modules | `add-ideas/govoplan-core#227` |
| Action/effect automation layer | first `govoplan-workflow`; possible future `govoplan-automation` | `add-ideas/govoplan-workflow#1` |
| E2EE role/function postbox architecture | `govoplan-postbox`, `govoplan-identity-trust`, `govoplan-access`, `govoplan-policy`, `govoplan-audit` | `add-ideas/govoplan-postbox#15`, `add-ideas/govoplan-identity-trust#1` |
| Identity, account, function, role, right semantic model | `govoplan-access` | `add-ideas/govoplan-access#9` |
| Role-based service directory/catalog | `govoplan-portal` | `add-ideas/govoplan-portal#1` |
| Unified inbox across tasks, postbox, notifications, and portal | `govoplan-core` coordination plus owning modules | `add-ideas/govoplan-tasks#1`, `add-ideas/govoplan-notifications#1` |
| OpenProject API / project management connector | `govoplan-connectors` | `add-ideas/govoplan-connectors#1` |
| Native project-management module decision | connector-first through `govoplan-connectors`; no native project module yet | `add-ideas/govoplan-core#196`, `add-ideas/govoplan-connectors#1` |
| Datasources for databases, CSV, files, APIs | no repository yet; start with connectors/files/reporting and create `govoplan-datasources` only after the first package proves shared source-catalog ownership | `add-ideas/govoplan-core#197` |
| Dataflow for pipelines, BI, publication | no repository yet; start with workflow/reporting/connectors and create `govoplan-dataflow` only after repeated pipeline/lineage contracts emerge | `add-ideas/govoplan-core#198` |
| Monthly datasource and transformation workflows | first as configuration package across connectors, files, workflow, reporting, and templates | `add-ideas/govoplan-core#216` |
| Templates for letters, emails, forms, reports | `govoplan-templates`, separate from reporting | `add-ideas/govoplan-core#190`, `add-ideas/govoplan-templates#1` |
| Reporting and BI | `govoplan-reporting`, separate from templates | `add-ideas/govoplan-core#190`, `add-ideas/govoplan-reporting#1` |
| File connectors: Nextcloud, Seafile, SMB, NFS | `govoplan-files` | `add-ideas/govoplan-files#15` |
| Public-sector software integration catalogue | `govoplan-connectors` with core strategy index | `add-ideas/govoplan-core#191`, `add-ideas/govoplan-connectors#2` |
| Public-sector integration landscape catalogue | `govoplan-connectors` with core tracking | `add-ideas/govoplan-core#215` |
| Cases module concept | `govoplan-cases` | `add-ideas/govoplan-core#174` |
| Workflow module concept | `govoplan-workflow` | `add-ideas/govoplan-core#175` |
| Connectors module concept | `govoplan-connectors` | `add-ideas/govoplan-core#176` |
| Adrema-style address and distribution-list management | `govoplan-addresses` | `add-ideas/govoplan-addresses#1` |
| Consume sources and become a governed source | `govoplan-connectors` plus possible future `govoplan-dataflow` | `add-ideas/govoplan-connectors#3`, `add-ideas/govoplan-core#198` |
| Governed connector configuration, dry-run, and simulation runtime | `govoplan-connectors` | `add-ideas/govoplan-connectors#6` |
| Terminfindung and meeting scheduling polls | `govoplan-scheduling`; calendar primitives remain in calendar | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-scheduling#1` |
| Terminplaner and calendar primitives | `govoplan-calendar` | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-calendar#1` |
| Terminbuchung appointment booking | `govoplan-appointments` | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-appointments#1` |
| Collaborative documents | `govoplan-dms` | `add-ideas/govoplan-dms#1` |
| Forms | `govoplan-forms` for definitions and `govoplan-forms-runtime` for submissions/runtime behavior | `add-ideas/govoplan-core#194`, `add-ideas/govoplan-forms#1` |
| RSS consume and emit | `govoplan-connectors` | `add-ideas/govoplan-connectors#4` |
| LDAP, Active Directory, OpenDesk identity | `govoplan-idm` | `add-ideas/govoplan-idm#1` |
| OpenDesk stack integration map | integration profile across IDM/access, mail/calendar, files/DMS, and connectors; not a monolithic module | `add-ideas/govoplan-core#195`, `add-ideas/govoplan-connectors#5` |
| Open-Xchange mail/groupware | `govoplan-mail` | `add-ideas/govoplan-mail#5` |
| Open-Xchange calendar | `govoplan-calendar` | `add-ideas/govoplan-calendar#2` |
| Scalability profiles and autoscaling readiness | `govoplan-ops`, `govoplan-core` | `add-ideas/govoplan-core#217` |
| Hardware sizing matrix and requirements calculator | `govoplan-ops`, `govoplan-core` | `add-ideas/govoplan-core#219` |
| Collaboration suite integration strategy | `govoplan-connectors`, `govoplan-dms`, `govoplan-workflow`, `govoplan-tasks`, `govoplan-appointments`, `govoplan-calendar` | `add-ideas/govoplan-core#220` |
| Install/runtime configuration contract | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#19` |
| Installer/deployment operator flow | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#26` |
| Production-like deployment documentation | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#28` |
| Government operations backbone reference model | `govoplan-core` | `GovOPlaN/govoplan-core#213` |
| Permit-to-payment configuration package | `govoplan-core` plus participating modules | `GovOPlaN/govoplan-core#214` |
| Fully UI-managed configuration with safety controls | `govoplan-admin`, `govoplan-core`, `govoplan-policy`, `govoplan-access`, `govoplan-audit` | `GovOPlaN/govoplan-core#218` |
| Access as a module | `govoplan-access` | `GovOPlaN/govoplan-access#7` |
| Interface ethics and decision-consequence doctrine | `govoplan-core` plus all UI-owning modules | `GovOPlaN/govoplan-core#227` |
| Action/effect automation layer | first `govoplan-workflow`; possible future `govoplan-automation` | `GovOPlaN/govoplan-workflow#1` |
| E2EE role/function postbox architecture | `govoplan-postbox`, `govoplan-identity-trust`, `govoplan-access`, `govoplan-policy`, `govoplan-audit` | `GovOPlaN/govoplan-postbox#15`, `GovOPlaN/govoplan-identity-trust#1` |
| Identity, account, function, role, right semantic model | `govoplan-access` | `GovOPlaN/govoplan-access#9` |
| Role-based service directory/catalog | `govoplan-portal` | `GovOPlaN/govoplan-portal#1` |
| Unified inbox across tasks, postbox, notifications, and portal | `govoplan-core` coordination plus owning modules | `GovOPlaN/govoplan-tasks#1`, `GovOPlaN/govoplan-notifications#1` |
| OpenProject API / project management connector | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#1` |
| Native project-management module decision | connector-first through `govoplan-connectors`; no native project module yet | `GovOPlaN/govoplan-core#196`, `GovOPlaN/govoplan-connectors#1` |
| Datasources for databases, CSV, files, APIs | no repository yet; start with connectors/files/reporting and create `govoplan-datasources` only after the first package proves shared source-catalog ownership | `GovOPlaN/govoplan-core#197` |
| Dataflow for pipelines, BI, publication | no repository yet; start with workflow/reporting/connectors and create `govoplan-dataflow` only after repeated pipeline/lineage contracts emerge | `GovOPlaN/govoplan-core#198` |
| Monthly datasource and transformation workflows | first as configuration package across connectors, files, workflow, reporting, and templates | `GovOPlaN/govoplan-core#216` |
| Templates for letters, emails, forms, reports | `govoplan-templates`, separate from reporting | `GovOPlaN/govoplan-core#190`, `GovOPlaN/govoplan-templates#1` |
| Reporting and BI | `govoplan-reporting`, separate from templates | `GovOPlaN/govoplan-core#190`, `GovOPlaN/govoplan-reporting#1` |
| File connectors: Nextcloud, Seafile, SMB, NFS | `govoplan-files` | `GovOPlaN/govoplan-files#15` |
| Public-sector software integration catalogue | `govoplan-connectors` with core strategy index | `GovOPlaN/govoplan-core#191`, `GovOPlaN/govoplan-connectors#2` |
| Public-sector integration landscape catalogue | `govoplan-connectors` with core tracking | `GovOPlaN/govoplan-core#215` |
| Cases module concept | `govoplan-cases` | `GovOPlaN/govoplan-core#174` |
| Workflow module concept | `govoplan-workflow` | `GovOPlaN/govoplan-core#175` |
| Connectors module concept | `govoplan-connectors` | `GovOPlaN/govoplan-core#176` |
| Adrema-style address and distribution-list management | `govoplan-addresses` | `GovOPlaN/govoplan-addresses#1` |
| Consume sources and become a governed source | `govoplan-connectors` plus possible future `govoplan-dataflow` | `GovOPlaN/govoplan-connectors#3`, `GovOPlaN/govoplan-core#198` |
| Governed connector configuration, dry-run, and simulation runtime | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#6` |
| Terminfindung and meeting scheduling polls | `govoplan-scheduling`; calendar primitives remain in calendar | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-scheduling#1` |
| Terminplaner and calendar primitives | `govoplan-calendar` | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-calendar#1` |
| Terminbuchung appointment booking | `govoplan-appointments` | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-appointments#1` |
| Collaborative documents | `govoplan-dms` | `GovOPlaN/govoplan-dms#1` |
| Forms | `govoplan-forms` for definitions and `govoplan-forms-runtime` for submissions/runtime behavior | `GovOPlaN/govoplan-core#194`, `GovOPlaN/govoplan-forms#1` |
| RSS consume and emit | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#4` |
| LDAP, Active Directory, OpenDesk identity | `govoplan-idm` | `GovOPlaN/govoplan-idm#1` |
| OpenDesk stack integration map | integration profile across IDM/access, mail/calendar, files/DMS, and connectors; not a monolithic module | `GovOPlaN/govoplan-core#195`, `GovOPlaN/govoplan-connectors#5` |
| Open-Xchange mail/groupware | `govoplan-mail` | `GovOPlaN/govoplan-mail#5` |
| Open-Xchange calendar | `govoplan-calendar` | `GovOPlaN/govoplan-calendar#2` |
| Scalability profiles and autoscaling readiness | `govoplan-ops`, `govoplan-core` | `GovOPlaN/govoplan-core#217` |
| Hardware sizing matrix and requirements calculator | `govoplan-ops`, `govoplan-core` | `GovOPlaN/govoplan-core#219` |
| Collaboration suite integration strategy | `govoplan-connectors`, `govoplan-dms`, `govoplan-workflow`, `govoplan-tasks`, `govoplan-appointments`, `govoplan-calendar` | `GovOPlaN/govoplan-core#220` |
| Install/runtime configuration contract | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#19` |
| Installer/deployment operator flow | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#26` |
| Production-like deployment documentation | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#28` |
Boundary rationale lives in `MODULE_ARCHITECTURE.md`. Current decisions:
@@ -705,7 +705,7 @@ Release composition and tag-only repository handling are documented in
## Next Practical Work
The active cross-product story is
[`add-ideas/govoplan#14`](https://git.add-ideas.de/add-ideas/govoplan/issues/14).
[`GovOPlaN/govoplan#14`](https://git.add-ideas.de/GovOPlaN/govoplan/issues/14).
Module repositories own implementation issues; do not clone their state here.
Immediate issue buckets:
+93 -3
View File
@@ -74,6 +74,10 @@ The compatibility/deprecation plan for the current split line is:
- reject new cross-module imports that bypass manifests, capabilities, events,
or public module APIs
The retention windows and removal checklist for database bridges,
configuration/export schemas, and runtime/API aliases are defined in
`COMPATIBILITY_POLICY.md`.
## Stable Kernel Contracts
The following contracts are the baseline API that modules can rely on:
@@ -87,7 +91,8 @@ The following contracts are the baseline API that modules can rely on:
- capability factory contract
- access DTO/protocol contracts in `govoplan_core.core.access`
- resource ACL provider contract
- tenant summary provider contract
- bounded reference-option search provider contract
- single-tenant and optional batched tenant summary provider contracts
- tenant delete-veto provider contract
- WebUI module contribution contract
- navigation metadata contract
@@ -96,6 +101,15 @@ The following contracts are the baseline API that modules can rely on:
Changes to these contracts must be versioned or accompanied by compatibility shims.
Tenant list pages prefer `tenant_summary_batch_providers`. A batch provider
receives the unique tenant IDs on the current page and returns count mappings
keyed by tenant ID. Missing tenant keys mean that the provider has no counts for
that tenant; provider errors remain visible. Modules that expose only the
single-tenant contract remain compatible through a per-tenant fallback.
Destructive tenant lifecycle planning deliberately continues to use the
single-tenant path so it invokes every registered provider for the target
tenant, independent of ordinary list-page projections.
This list is the Milestone A kernel-contract freeze baseline. New module work
may extend the kernel by adding explicit contracts, but existing contracts must
remain source-compatible through the 0.1.x split line unless a migration shim
@@ -134,6 +148,16 @@ Other stable runtime capabilities currently include:
- `calendar.outbox` and `calendar.scheduling`
- `poll.scheduling`
- `notifications.dispatch`
- `workflow.definitionContributions` and `workflow.runtimeWorker`
Modules contribute reusable process baselines through
`ModuleManifest.workflow_definitions`. Each contribution pins its origin module
and version, stable key, schema and content hash, native graph/BPMN content,
governance ceilings, execution mode, and required capabilities/interfaces.
`govoplan-workflow-engine` reconciles these declarations idempotently. A module
upgrade appends a baseline revision without replacing the active revision or
mutating a local override; the optional `govoplan-workflow` package supplies
the comparison, derivation, and reset UI.
### Named Interface Contracts
@@ -263,6 +287,23 @@ unsafe methods.
This avoids retransmitting unchanged snapshots. It does not identify which row
changed inside a collection.
### Mutation Preconditions
Weak response ETags are cache validators only. Mutable aggregates expose a
separate positive, monotonic revision and an opaque strong ETag generated by
`govoplan_core.core.concurrency.strong_resource_etag`. HTTP mutations send that
strong ETag in `If-Match`; capability and worker calls carry the equivalent
typed `expected_revision`.
Core's compare-and-set primitive advances the revision in the same transaction
as the domain mutation. A missing HTTP precondition is `428 Precondition
Required`, a stale HTTP precondition is `412 Precondition Failed`, and a
domain/reconciliation conflict is `409 Conflict`. Conflict responses contain
bounded resource and revision metadata rather than the complete current
object. Modules may opt into the conservative three-way merge helper, but must
declare protected workflow, delivery, ownership, lock, evidence, signature,
and cryptographic paths that can never be merged automatically.
### Delta Collections
Collection endpoints that can expose row-level changes should use the shared
@@ -333,6 +374,31 @@ full snapshot with `full: true`. A first-use `seq:0` watermark remains valid
until such a floor exists, even if unrelated collections have advanced the
global sequence.
### Bounded Reference Selectors
Cross-module selectors use the module-neutral contract in
`govoplan_core.core.references`; consumers must not load an optional module's
complete directory and filter it in memory.
- Providers receive a normalized `ReferenceSearchRequest` with `kind`,
`tenant_id`, `query`, `selected_values`, `limit`, optional opaque `cursor`,
and policy context.
- Providers apply visibility and text filtering before materializing rows and
return `ReferenceSearchPage(options, next_cursor, has_more)`.
- A page contains at most the requested bounded search results. Already-selected
references are retained in addition to that bound so historical values remain
readable and removable even when they are inactive, deleted, or outside the
current search page.
- API consumers expose `next_cursor` and `has_more`. The current searchable
selector requests the first bounded page for each query; later load-more UI
can use the same cursor without changing the provider contract.
- `access.reference_options` supplies SQL-backed account, membership, and group
searches. When it is absent, Core degrades to the legacy Access directory or
to principal-only/unavailable references without importing Access.
- The shared WebUI `apiReferenceOptionProvider` resolves selected values in
chunks of at most 200, preventing a large existing selection from turning
into an unbounded request.
### Cursor/Keyset Pages
Offset pagination remains supported for compatibility and for first page loads,
@@ -453,6 +519,18 @@ The manifest should declare:
- navigation metadata using serializable icon names
- uninstall guard providers for data, migration, worker, or scheduler vetoes
A tenant-level managed `RoleTemplate` may set `default_authenticated=True`
only when every authenticated tenant member must receive that narrow baseline
while the contributing module is installed. Access derives the explicit grant
from the active manifest set during authorization without mutating the request
transaction. It may materialize a non-assignable role row for administration,
but no per-user assignment is required and role edits cannot remove the
baseline.
This is not a shortcut for feature authorization: keep the template narrow and
continue to enforce each domain action's own permission and resource policy.
System-level, unmanaged, wildcard-bearing, or slug-colliding automatic
templates are rejected by registry validation.
Backend nav metadata must use icon-name strings, not frontend components:
```python
@@ -573,11 +651,18 @@ Uninstall remains non-destructive unless the operator explicitly requests
## WebUI Contract
A WebUI module exports a `PlatformWebModule` from its package. The object contributes local/fallback metadata and route render functions.
A WebUI module exports a `PlatformWebModule` from its package. The object
contributes local/fallback metadata and route render functions. The package
must ship `src/module.ts` with the default contribution export: Core's Vite
host imports that descriptor directly after the backend reports the module as
enabled. This keeps package-root re-exports from pulling page implementations
into the initial shell.
Example:
```ts
const FilesPage = lazy(() => import("./features/files/FilesPage"));
export const filesModule: PlatformWebModule = {
id: "files",
label: "Files",
@@ -592,6 +677,11 @@ export const filesModule: PlatformWebModule = {
};
```
Route pages and substantial panels must use stable lazy imports. Core supplies
the shared loading and retryable error state around route rendering. The
initial static import closure and largest asynchronous chunk are enforced by
the budgets documented in [WEBUI_BUNDLE_BUDGETS.md](WEBUI_BUNDLE_BUDGETS.md).
WebUI modules receive only the core route context:
- `settings`
@@ -809,7 +899,7 @@ First slice:
- `govoplan-files` owns file-backed governed locations and uploaded/stored file
evidence.
- `govoplan-reporting` owns report/data views and scheduled outputs.
- `govoplan-workflow` owns process state, approvals, scheduling of process
- `govoplan-workflow-engine` owns process state, approvals, scheduling of process
steps, and human review.
Future `govoplan-datasources` is justified when GovOPlaN needs a broad source
+15
View File
@@ -13,6 +13,7 @@ consistent while each module still owns its domain rules.
| RBAC/access policy | `govoplan-access` | access capabilities in `govoplan_core.core.access` | Permission decisions should use access capability contracts. Explain responses should adopt `PolicyDecision` when an API-level explanation is added. |
| Governance defaults | `govoplan-admin` plus `govoplan-access` materializer | admin settings, governance template routes, access materialization capability | System governance can block tenant-local groups, roles, and API keys. |
| Delegation and ownership policy | access/campaign/mail/files modules | capability checks and owner-scoped APIs | Source provenance should use this contract when policies become externally explainable. |
| Definition governance | `govoplan-policy` | capability `policy.definitionGovernance` | Resolves view, edit, run/start, reuse, derive, and automate for system, tenant, group, and user Dataflow/Workflow definitions. |
## Policy Decision
@@ -111,6 +112,20 @@ matching compatibility DTOs only for its legacy admin surface. Admin overview
responses remain module-local because the same counters are exposed from
different menu contexts and are not yet a separately versioned platform API.
## Definition Governance
Dataflow and Workflow submit a `DefinitionGovernanceRequest` using only stable
scope, principal, status, definition-kind, and limit fields. Policy returns a
standard `PolicyDecision`. System definitions may be inherited as read-only;
group and user definitions are visible only in matching contexts. Templates
may be viewed and derived but never run or automated. A derived definition
passes its pinned ancestor limits back through the request context, and Policy
applies those limits as ceilings rather than defaults that can be broadened.
When the capability is absent, modules must not silently emulate cross-scope
inheritance. Their conservative fallback is limited to local tenant
definitions and disables reuse, derivation, and automation.
## Frontend Contract
Policy UIs must:
+29
View File
@@ -57,6 +57,11 @@ The trust layer should provide:
- key rotation and epoch tracking
- recovery policy hooks
Recovery must be organizationally governed. A server-held universal plaintext
key would defeat the E2EE claim; any escrow, threshold recovery, or emergency
grant needs an explicit assurance profile, authority/quorum, audit trail, and
user-visible consequence.
## Role And Function Postboxes
Role-bound access needs special handling. A postbox can be bound to an
@@ -75,6 +80,30 @@ rewrapping service:
Key epochs are required when role membership changes. Older messages may remain
readable according to policy, but new access must use the current epoch.
The function-bound container exists independently of membership. It may remain
vacant and continue to receive ciphertext without falling back to an unrelated
personal mailbox. Zero, one, or several incumbents are valid states. Each
incumbent receives an independently auditable, device-bound wrapped-key path;
the postbox is never copied into their account ownership.
A new assignment or hand-over rotates the function/postbox key epoch. Envelope
encryption permits the normal rotation path to rewrap per-message data keys
rather than rewrite large ciphertext objects; a security policy may require
full content re-encryption for selected compromise or cryptographic-profile
events. The history available to a new incumbent must be selected policy (all
retained history, a bounded historical window, or assignment-time content) and
recorded with the grant.
Delegation is a time-bounded represented-function grant, not a copy or
substitution of the postbox. Expiry or withdrawal stops future key release and
actions. It cannot revoke plaintext already decrypted, printed, exported, or
captured outside the platform. Multiple simultaneous incumbents and delegates
remain distinguishable in key-fetch and action evidence.
Postbox content and signed manifests are immutable. Correction or replacement
creates a linked new object/version; it never silently substitutes ciphertext
or evidence that another actor may already have inspected.
## External Recipients
External recipients may need one-time or time-limited access without a full
@@ -5,6 +5,9 @@ before deciding to replace specialist workflows. This document is the core
strategy index. The executable connector catalogue lives in
`govoplan-connectors/docs/PUBLIC_SECTOR_INTEGRATION_CATALOGUE.md`.
The canonical cumulative maturity model and external-object DTO are documented
in [EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md](EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md).
## Strategy Labels
Use one or more of these labels for every external system family:
+41 -23
View File
@@ -40,26 +40,26 @@ cd /mnt/DATA/git/govoplan
Update those refs when cutting a release:
```text
govoplan-tenancy git@git.add-ideas.de:add-ideas/govoplan-tenancy.git v0.1.8
govoplan-organizations git@git.add-ideas.de:add-ideas/govoplan-organizations.git v0.1.8
govoplan-identity git@git.add-ideas.de:add-ideas/govoplan-identity.git v0.1.8
govoplan-idm git@git.add-ideas.de:add-ideas/govoplan-idm.git v0.1.8
govoplan-access git@git.add-ideas.de:add-ideas/govoplan-access.git v0.1.8
govoplan-admin git@git.add-ideas.de:add-ideas/govoplan-admin.git v0.1.8
govoplan-policy git@git.add-ideas.de:add-ideas/govoplan-policy.git v0.1.8
govoplan-audit git@git.add-ideas.de:add-ideas/govoplan-audit.git v0.1.8
govoplan-dashboard git@git.add-ideas.de:add-ideas/govoplan-dashboard.git v0.1.8
govoplan-addresses git@git.add-ideas.de:add-ideas/govoplan-addresses.git v0.1.8
govoplan-files git@git.add-ideas.de:add-ideas/govoplan-files.git v0.1.8
govoplan-mail git@git.add-ideas.de:add-ideas/govoplan-mail.git v0.1.8
govoplan-campaign git@git.add-ideas.de:add-ideas/govoplan-campaign.git v0.1.8
govoplan-calendar git@git.add-ideas.de:add-ideas/govoplan-calendar.git v0.1.8
govoplan-poll git@git.add-ideas.de:add-ideas/govoplan-poll.git v0.1.8
govoplan-scheduling git@git.add-ideas.de:add-ideas/govoplan-scheduling.git v0.1.8
govoplan-notifications git@git.add-ideas.de:add-ideas/govoplan-notifications.git v0.1.8
govoplan-evaluation git@git.add-ideas.de:add-ideas/govoplan-evaluation.git v0.1.8
govoplan-docs git@git.add-ideas.de:add-ideas/govoplan-docs.git v0.1.8
govoplan-ops git@git.add-ideas.de:add-ideas/govoplan-ops.git v0.1.8
govoplan-tenancy git@git.add-ideas.de:GovOPlaN/govoplan-tenancy.git v0.1.8
govoplan-organizations git@git.add-ideas.de:GovOPlaN/govoplan-organizations.git v0.1.8
govoplan-identity git@git.add-ideas.de:GovOPlaN/govoplan-identity.git v0.1.8
govoplan-idm git@git.add-ideas.de:GovOPlaN/govoplan-idm.git v0.1.8
govoplan-access git@git.add-ideas.de:GovOPlaN/govoplan-access.git v0.1.8
govoplan-admin git@git.add-ideas.de:GovOPlaN/govoplan-admin.git v0.1.8
govoplan-policy git@git.add-ideas.de:GovOPlaN/govoplan-policy.git v0.1.8
govoplan-audit git@git.add-ideas.de:GovOPlaN/govoplan-audit.git v0.1.8
govoplan-dashboard git@git.add-ideas.de:GovOPlaN/govoplan-dashboard.git v0.1.8
govoplan-addresses git@git.add-ideas.de:GovOPlaN/govoplan-addresses.git v0.1.8
govoplan-files git@git.add-ideas.de:GovOPlaN/govoplan-files.git v0.1.8
govoplan-mail git@git.add-ideas.de:GovOPlaN/govoplan-mail.git v0.1.8
govoplan-campaign git@git.add-ideas.de:GovOPlaN/govoplan-campaign.git v0.1.8
govoplan-calendar git@git.add-ideas.de:GovOPlaN/govoplan-calendar.git v0.1.8
govoplan-poll git@git.add-ideas.de:GovOPlaN/govoplan-poll.git v0.1.8
govoplan-scheduling git@git.add-ideas.de:GovOPlaN/govoplan-scheduling.git v0.1.8
govoplan-notifications git@git.add-ideas.de:GovOPlaN/govoplan-notifications.git v0.1.8
govoplan-evaluation git@git.add-ideas.de:GovOPlaN/govoplan-evaluation.git v0.1.8
govoplan-docs git@git.add-ideas.de:GovOPlaN/govoplan-docs.git v0.1.8
govoplan-ops git@git.add-ideas.de:GovOPlaN/govoplan-ops.git v0.1.8
```
## WebUI Packages
@@ -151,7 +151,8 @@ Current tag-only module repositories:
- `govoplan-search`
- `govoplan-tasks`
- `govoplan-templates`
- `govoplan-workflow`
- `govoplan-workflow-engine` (headless definitions, migrations, and execution)
- `govoplan-workflow` (optional authoring and inspection WebUI)
- `govoplan-xoev`
- `govoplan-xrechnung`
- `govoplan-xta-osci`
@@ -788,10 +789,27 @@ tools/checks/postgres-integration-check.py \
The script checks migrations and `/health` startup for core-only, files-only,
mail-only, campaign-only, campaign+files, campaign+mail, and full-product
module sets. `--reset-schema` is destructive and must only be used against a
throwaway database.
throwaway database. Before those permutations, the required Core proof runs in
random, test-owned schemas without modifying `public`. It exercises Files' real
credential-owning retirement provider and proves that credential scrubbing,
non-secret audit
insertion, and table retirement commit together; database-injected audit and
DDL failures roll the entire unit back. A 500 ms PostgreSQL `lock_timeout` and
captured backend process IDs also prove that each `DROP TABLE` uses the
installer Session connection instead of waiting through a second connection.
The meta check enables the release-gate flag so missing PostgreSQL configuration
or full-stack test packages are a hard failure; ordinary Core-only test discovery
skips this integration proof. Do not pass `--skip-retirement-atomicity` when
collecting release evidence.
## Migration Baselines
Release migration support follows `COMPATIBILITY_POLICY.md`: every tagged
`0.1.x` installation is a supported upgrade origin, released revision IDs are
immutable, and migration-only reconciliation remains available for at least one
subsequent major release cycle after the matching runtime compatibility path is
removed.
Development migrations may be small and numerous while a feature is moving.
GovOPlaN keeps those detailed migrations on an explicit development track and
publishes reviewed release shortcuts on the release track. Before a stable
@@ -881,7 +899,7 @@ before that baseline, so pre-v0.1.7 development revisions are not release
upgrade targets. Future release-to-release changes must start from a recorded
release baseline and add a new release-track step-up instead of replacing prior
release shortcuts. The tracking issue is
`add-ideas/govoplan-core#223`.
`GovOPlaN/govoplan-core#223`.
## Related Operator Documents
+24
View File
@@ -0,0 +1,24 @@
# Shared fixed-window throttling
Core exposes `govoplan_core.core.throttling` for security-sensitive endpoints
that need bounded fixed-window counters. Subjects are SHA-256 hashed before they
become store keys. A configured Redis instance provides atomic counters shared
across API workers. Development and temporary Redis outages use a bounded
process-local fallback. Production-like startup rejects an enabled login
throttle without `REDIS_URL` unless
`GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE=true` explicitly acknowledges the
single-process limitation. When Redis fails at runtime, local attempts are still
mirrored so losing the distributed store does not reset the active worker's
protection window.
Callers define one or more `ThrottleDimension` values with a controlled
namespace, a subject and a positive limit. They must call `check` before an
expensive verifier, `record` after a failed attempt, and may `reset` the relevant
dimension after successful verification. A blocked decision includes a
`retry_after_seconds` value suitable for an HTTP `Retry-After` header.
The first consumer is Scheduling's anonymous participation password challenge.
Its namespace is `poll-participation-password`; its subject combines tenant,
scheduling request and Poll's non-secret invitation-token fingerprint. Access's
login throttle predates this primitive and should be migrated onto it in a
separate compatibility-preserving slice.
+89 -4
View File
@@ -6,7 +6,7 @@ binding design reference: future implementation should follow these decisions
unless the decision is explicitly revised here and affected screens are updated
to match.
Active tracking issue: `add-ideas/govoplan-core#225`.
Active tracking issue: `GovOPlaN/govoplan-core#225`.
## Operating Rule
@@ -46,6 +46,10 @@ contestability, responsibility, and traceability at the point of action.
| UX-020 | Centrally exported Core components are mandatory wherever their contract covers the interaction. A custom reusable control, presentation primitive, or module-local substitute requires explicit product-owner authorization, a narrowly specific purpose, and documented rationale and scope; it must not duplicate a central component. | Accepted | Core WebUI and all module WebUIs |
| UX-021 | A collection-wide create action belongs in that collection's page heading and is not duplicated in a persistent side panel. When a side panel is the creation surface, it is present for the creation view only. | Accepted | List-detail, directory, and create surfaces |
| UX-022 | Use central `Card` components for logical sections, `DataGrid` for tabular row collections and their ordered actions, and `ToggleSwitch` for boolean settings. Repeatable people/contact editors use one structured row per person with name, email address, and actions; free-form address parsing is reserved for an explicitly designed bulk-import flow. | Accepted | All WebUI forms and collection editors |
| UX-023 | `FieldLabel` is the standard label/help surface for every field that is not self-explanatory. Any field rendered without it must be recorded in the omission register below, including its accessible-name source and rationale. Users may hide inline help markers through their persisted interface preference; the field label itself remains visible. | Accepted | All Core and module forms |
| UX-024 | Explicit `Discard` actions and dirty in-application navigation use the shared `UnsavedChangesProvider` dialog. A page registers save/discard behavior with `useUnsavedDraftGuard`; its Discard button calls `requestDiscard`, and route changes use `useGuardedNavigate` or `requestNavigation`. | Accepted | All create/edit surfaces |
| UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code |
| UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables |
## Confirmed Implementation Decisions
@@ -191,9 +195,10 @@ explicitly retains the exception.
Decision: Scheduling requests provide a concrete reference application of the
universal placement and component rules.
- The `Scheduling requests` header owns one `Add` action.
- Its left panel is used for the creation view only, not as a permanent second
creation affordance.
- The persistent left panel stacks `My scheduling requests` and `Scheduling
requests for me`; it is list context, not a second creation affordance.
- The left panel's `Scheduling requests` header owns one `Add` action. It opens
the shared view/create/edit surface in the right main panel.
- Basic information, Calendar integration, candidate slots, and participants
use the central `Card` component as four logical sections.
- Candidate slots and participants use the central `DataGrid`, including its
@@ -207,6 +212,78 @@ universal placement and component rules.
Equivalent list/create/edit surfaces use the same underlying rules. These are
not Scheduling-local component variants.
### DUE-011: Field Help, Discard, And Table Action Contracts
Decision: the central components own these interactions; modules compose them
instead of reproducing their behavior.
- `FormField` and `ToggleSwitch` already render `FieldLabel`. Direct field
compositions use `FieldLabel` explicitly when the meaning or limitation is
not self-explanatory.
- `help` content is contextual guidance, not the accessible name. The persisted
`show_inline_help_hints` user preference hides only the `InlineHelp` marker by
applying `ui-hide-help-hints` at the document root.
- A dirty editor registers once with `useUnsavedDraftGuard`. An explicit
Discard button calls `useUnsavedChanges().requestDiscard(afterResolve)`; SPA
navigation uses `useGuardedNavigate` or `requestNavigation`. Both paths show
the same shared unsaved-changes dialog. A browser tab/window unload remains a
browser-controlled confirmation because browsers do not permit a custom
modal at that boundary.
- `TableActionGroup` receives the table's stable action set. Use `disabled` and
`disabledReason` for row state; omit an action only when that action does not
belong to the table. `minimumSlots` reserves trailing positions for an empty
row. `DataGridEmptyAction` does this for the standard add/move/remove layout.
- A paginated `DataGrid` has exactly one query owner. Client mode receives the
complete logical row set and applies filtering and sorting before slicing a
page. Server mode receives only the loaded page, requires `onQueryChange`,
and the backend applies every emitted filter/sort before pagination while
returning `totalRows` for the filtered result. Server list filters declare
their complete option domain instead of deriving it from the loaded page.
External filter affordances such as summary-count shortcuts update the
grid's `query` contract; the grid header controls and backend query therefore
always display and execute the same filter state.
- Feedback and confirmation use `Dialog`, `ConfirmDialog`, or
`DismissibleAlert`. They never fall back to `window.alert`.
### DUE-012: Rich HTML Editing Contract
Decision: modules that edit persisted HTML use the central
`WysiwygEditor` exported from `@govoplan/core-webui/wysiwyg`.
- The dedicated subpath is intentional: the editor and its engine remain a
shared Core contract without adding their code to module combinations that
never consume rich-text editing.
- Consumers provide controlled HTML and domain-specific token labels. The
editor owns visual/source switching, formatting, links, images, safe URL
handling, and atomic inline token rendering; it does not own template
semantics or persistence.
- Existing HTML outside the supported visual subset opens in source mode.
Rendering the value must not rewrite it, and users receive an explicit
warning before choosing the visual surface.
- Domain placeholders remain their original serialized text. Atomic token
presentation is an editing aid only, so backend renderers and existing
templates do not need a new storage format.
#### FieldLabel Omission Register
Every Core field surface that intentionally does not render `FieldLabel` is
listed here. Module repositories keep an equivalent register in their durable
UI documentation until a central cross-repository audit is available.
| Core scope | Why `FieldLabel` is omitted | Accessible/context label source |
| --- | --- | --- |
| `PasswordField`, `ColorPickerField`, `DateField`, `TimeField`, and `DateTimeField` input internals | These are label-neutral composite primitives and are placed inside `FormField`/`FieldLabel` by the consuming form. Rendering another label inside the primitive would duplicate it. | Enclosing label; a direct consumer must pass an accessible name and record that direct composition here. |
| `ToggleSwitch` native checkbox | The shared component already renders its visible text through `FieldLabel`; the native input must not render a second label. | The enclosing native label and derived `aria-label`. |
| `FileDropZone` hidden file input | The input is an implementation detail of the labelled keyboard-operable drop target. | Drop target text and `inputLabel`/`aria-label`. |
| `AdminSelectionList` and `DataGrid` list-filter checkboxes | Each option is self-explanatory and already enclosed by its visible option label. | Enclosing native option label. |
| `EmailAddressInput` compact Name and Email fields | These two conventional fields are self-explanatory in the compact address popover; richer address guidance belongs to the enclosing field. | Visible native labels; the free-form editor also has a descriptive `aria-label`. |
| `DataGrid` page-size, filter, and inline cell editors | The surrounding column header/filter heading supplies field context; repeating a labelled help marker in every cell would add noise. | Column header, filter heading/native label, or generated cell `aria-label`. |
| Retention-policy value controls | `PolicyRow` owns the field label, help, effective value, and provenance for its control. | The containing `PolicyRow` label/help contract. |
#### Alert Exception Register
No `window.alert` or global `alert` exception is authorized.
## Implementation Sequence
| Phase | Scope | Output |
@@ -277,6 +354,14 @@ Every new or changed admin/configuration surface should answer:
- Are logical sections, tabular collections, boolean settings, and repeatable
people/contact rows composed with `Card`, `DataGrid`, `ToggleSwitch`, and one
structured row per person respectively?
- Does every non-self-explanatory field use `FieldLabel`, and is every omission
recorded with its rationale and accessible-name source?
- Do explicit Discard and dirty navigation use the shared unsaved-changes
registration/dialog rather than a page-local confirmation?
- Does every row retain the table's action set in the same order, disabling
unavailable actions and reserving the same empty-row slots?
- Is feedback rendered with a central dialog/alert component, with no
unauthorized `window.alert` or global `alert` call?
- If automation is involved, can the user see the trigger, system actor,
observed effects, and failure/manual-intervention state?
- Are technical details available without being the first thing the user sees?
+71
View File
@@ -0,0 +1,71 @@
# WebUI Loading And Bundle Budgets
The Core WebUI host owns the loading boundary for installed module packages.
Vite discovers configured packages at build time, but emits an asynchronous
loader for each package's `src/module.ts` contribution descriptor. At runtime,
Core imports only descriptors whose backend manifests are enabled and identify
the matching `frontend.package_name`.
The direct descriptor entry is intentional. A package root may re-export pages
for consumers; importing that barrel as module wiring can cause those pages to
be evaluated before navigation. Route pages and substantial panels should use
`React.lazy`, and Core wraps routes in the shared loading/error boundary.
## Enforced Budgets
`webui/bundle-budget.json` contains the production limits:
| Measurement | Raw limit | Gzip limit |
| --- | ---: | ---: |
| Initial JavaScript static import closure | 512 KiB | 160 KiB |
| Largest individual asynchronous JavaScript chunk | 384 KiB | 110 KiB |
`npm run build` writes a Vite manifest, measures the entry and its recursive
static imports, writes `dist/bundle-metrics.json`, and fails when either budget
is exceeded. `npm run test:module-permutations` applies the same gate to every
permutation and records the collected results in
`dist/module-permutation-bundle-metrics.json`. In CI, each result is also added
to the step summary.
Budgets are limits, not targets. A change that approaches a limit should add a
new lazy boundary or remove unnecessary entry code instead of raising the
limit without measurement and review.
## 2026-07-30 Baseline
Measurements use the same full-product source tree and Node 22 runtime. The
post-change build additionally includes the Search module in the default and
full-product sets.
| Initial-load measurement | Before | After | Reduction |
| --- | ---: | ---: | ---: |
| JavaScript assets in initial static closure | 1 | 1 | 0% |
| Raw JavaScript | 1,387,043 B | 453,769 B | 67.3% |
| Gzip level 9 | 364,767 B | 141,725 B | 61.1% |
| Brotli quality 11 | 254,797 B | 106,701 B | 58.1% |
| Parse proxy median | 18.776 ms | 7.750 ms | 58.7% |
| Parse proxy p95 | 21.980 ms | 8.739 ms | 60.2% |
The parse proxy constructs a fresh `node:vm` `SourceTextModule` from the entry
source 30 times with a randomized source marker. It is useful for a controlled
before/after comparison, but is not enforced in CI because absolute timings
vary across runners. Transfer budgets use deterministic raw and gzip byte
counts.
The first budgeted full-product build reported:
- initial JavaScript: 453,769 B raw / 141,725 B gzip;
- largest async chunk: `CampaignWorkspace`, 353,724 B raw / 98,142 B gzip.
## Verification
```bash
cd /mnt/DATA/git/govoplan-core/webui
npm run build
npm run check:bundle-budget
npm run test:module-permutations
```
The build gate also catches accidental eager imports: a page pulled into the
entry closure consumes the initial budget, while an oversized page or module
descriptor consumes the asynchronous chunk budget.
@@ -8,7 +8,7 @@
"description": "Portal form, case workflow, task creation, mail notification, payment setup, and access roles for a simple application process.",
"publisher": "ADD ideas",
"category": "workflow",
"artifact_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-configuration-packages.git@application-handling-basic-v0.1.0",
"artifact_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-configuration-packages.git@application-handling-basic-v0.1.0",
"artifact_sha256": "<sha256>",
"required_modules": [
{ "module_id": "portal", "version": ">=0.1.0" },
+500 -4
View File
@@ -81,8 +81,8 @@
],
"recorded_at": "2026-07-11T01:39:45Z",
"release": "0.1.7",
"track": "release",
"squash_policy": "reviewed-manual"
"squash_policy": "reviewed-manual",
"track": "release"
},
{
"heads": [
@@ -165,8 +165,504 @@
],
"recorded_at": "2026-07-20T02:45:41Z",
"release": "0.1.8",
"track": "release",
"squash_policy": "reviewed-manual"
"squash_policy": "reviewed-manual",
"track": "release"
},
{
"heads": [
{
"owner": "govoplan-campaign",
"revision": "4d5e6f7a9203"
},
{
"owner": "govoplan-mail",
"revision": "608192abcdef"
},
{
"owner": "govoplan-poll",
"revision": "6e7f8a9b0c1d"
},
{
"owner": "govoplan-notifications",
"revision": "6f7a8b9c0d1e"
},
{
"owner": "govoplan-idm",
"revision": "8f9a0b1c2d3e"
},
{
"owner": "govoplan-files",
"revision": "a7b8c9d0e1f3"
},
{
"owner": "govoplan-calendar",
"revision": "af1b2c3d4e5f"
},
{
"owner": "govoplan-scheduling",
"revision": "c9d4e7f1a2b3"
},
{
"owner": "govoplan-addresses",
"revision": "e1f2a4b5c6d"
}
],
"owner_heads": [
{
"owner": "govoplan-access",
"revisions": [
"4a5b6c7d8e9f"
]
},
{
"owner": "govoplan-addresses",
"revisions": [
"e1f2a4b5c6d"
]
},
{
"owner": "govoplan-calendar",
"revisions": [
"af1b2c3d4e5f"
]
},
{
"owner": "govoplan-campaign",
"revisions": [
"4d5e6f7a9203"
]
},
{
"owner": "govoplan-core",
"revisions": [
"4f2a9c8e7b6d"
]
},
{
"owner": "govoplan-files",
"revisions": [
"a7b8c9d0e1f3"
]
},
{
"owner": "govoplan-identity",
"revisions": [
"5c6d7e8f9a10"
]
},
{
"owner": "govoplan-idm",
"revisions": [
"8f9a0b1c2d3e"
]
},
{
"owner": "govoplan-mail",
"revisions": [
"608192abcdef"
]
},
{
"owner": "govoplan-notifications",
"revisions": [
"6f7a8b9c0d1e"
]
},
{
"owner": "govoplan-organizations",
"revisions": [
"6d7e8f9a0b1c"
]
},
{
"owner": "govoplan-poll",
"revisions": [
"6e7f8a9b0c1d"
]
},
{
"owner": "govoplan-scheduling",
"revisions": [
"c9d4e7f1a2b3"
]
}
],
"recorded_at": "2026-07-22T02:42:27Z",
"release": "0.1.11",
"squash_policy": "reviewed-manual",
"track": "release"
},
{
"heads": [
{
"owner": "govoplan-mail",
"revision": "608192abcdef"
},
{
"owner": "govoplan-poll",
"revision": "6e7f8a9b0c1d"
},
{
"owner": "govoplan-notifications",
"revision": "6f7a8b9c0d1e"
},
{
"owner": "govoplan-idm",
"revision": "8f9a0b1c2d3e"
},
{
"owner": "govoplan-files",
"revision": "a7b8c9d0e1f3"
},
{
"owner": "govoplan-calendar",
"revision": "af1b2c3d4e5f"
},
{
"owner": "govoplan-scheduling",
"revision": "c9d4e7f1a2b3"
},
{
"owner": "govoplan-campaign",
"revision": "d8b3e2c1f4a5"
},
{
"owner": "govoplan-addresses",
"revision": "e1f2a4b5c6d"
}
],
"owner_heads": [
{
"owner": "govoplan-access",
"revisions": [
"4a5b6c7d8e9f"
]
},
{
"owner": "govoplan-addresses",
"revisions": [
"e1f2a4b5c6d"
]
},
{
"owner": "govoplan-calendar",
"revisions": [
"af1b2c3d4e5f"
]
},
{
"owner": "govoplan-campaign",
"revisions": [
"d8b3e2c1f4a5"
]
},
{
"owner": "govoplan-core",
"revisions": [
"4f2a9c8e7b6d"
]
},
{
"owner": "govoplan-files",
"revisions": [
"a7b8c9d0e1f3"
]
},
{
"owner": "govoplan-identity",
"revisions": [
"5c6d7e8f9a10"
]
},
{
"owner": "govoplan-idm",
"revisions": [
"8f9a0b1c2d3e"
]
},
{
"owner": "govoplan-mail",
"revisions": [
"608192abcdef"
]
},
{
"owner": "govoplan-notifications",
"revisions": [
"6f7a8b9c0d1e"
]
},
{
"owner": "govoplan-organizations",
"revisions": [
"6d7e8f9a0b1c"
]
},
{
"owner": "govoplan-poll",
"revisions": [
"6e7f8a9b0c1d"
]
},
{
"owner": "govoplan-scheduling",
"revisions": [
"c9d4e7f1a2b3"
]
}
],
"recorded_at": "2026-07-22T07:06:21Z",
"release": "0.1.12",
"squash_policy": "reviewed-manual",
"track": "release"
},
{
"heads": [
{
"owner": "govoplan-mail",
"revision": "608192abcdef"
},
{
"owner": "govoplan-poll",
"revision": "6e7f8a9b0c1d"
},
{
"owner": "govoplan-notifications",
"revision": "6f7a8b9c0d1e"
},
{
"owner": "govoplan-idm",
"revision": "8f9a0b1c2d3e"
},
{
"owner": "govoplan-files",
"revision": "a7b8c9d0e1f3"
},
{
"owner": "govoplan-calendar",
"revision": "af1b2c3d4e5f"
},
{
"owner": "govoplan-scheduling",
"revision": "c9d4e7f1a2b3"
},
{
"owner": "govoplan-campaign",
"revision": "d8b3e2c1f4a5"
},
{
"owner": "govoplan-addresses",
"revision": "e1f2a4b5c6d"
}
],
"owner_heads": [
{
"owner": "govoplan-access",
"revisions": [
"4a5b6c7d8e9f"
]
},
{
"owner": "govoplan-addresses",
"revisions": [
"e1f2a4b5c6d"
]
},
{
"owner": "govoplan-calendar",
"revisions": [
"af1b2c3d4e5f"
]
},
{
"owner": "govoplan-campaign",
"revisions": [
"d8b3e2c1f4a5"
]
},
{
"owner": "govoplan-core",
"revisions": [
"4f2a9c8e7b6d"
]
},
{
"owner": "govoplan-files",
"revisions": [
"a7b8c9d0e1f3"
]
},
{
"owner": "govoplan-identity",
"revisions": [
"5c6d7e8f9a10"
]
},
{
"owner": "govoplan-idm",
"revisions": [
"8f9a0b1c2d3e"
]
},
{
"owner": "govoplan-mail",
"revisions": [
"608192abcdef"
]
},
{
"owner": "govoplan-notifications",
"revisions": [
"6f7a8b9c0d1e"
]
},
{
"owner": "govoplan-organizations",
"revisions": [
"6d7e8f9a0b1c"
]
},
{
"owner": "govoplan-poll",
"revisions": [
"6e7f8a9b0c1d"
]
},
{
"owner": "govoplan-scheduling",
"revisions": [
"c9d4e7f1a2b3"
]
}
],
"recorded_at": "2026-07-22T08:39:02Z",
"release": "0.1.13",
"squash_policy": "reviewed-manual",
"track": "release"
},
{
"heads": [
{
"owner": "govoplan-mail",
"revision": "608192abcdef"
},
{
"owner": "govoplan-poll",
"revision": "6e7f8a9b0c1d"
},
{
"owner": "govoplan-notifications",
"revision": "6f7a8b9c0d1e"
},
{
"owner": "govoplan-idm",
"revision": "8f9a0b1c2d3e"
},
{
"owner": "govoplan-files",
"revision": "a7b8c9d0e1f3"
},
{
"owner": "govoplan-calendar",
"revision": "af1b2c3d4e5f"
},
{
"owner": "govoplan-scheduling",
"revision": "c9d4e7f1a2b3"
},
{
"owner": "govoplan-campaign",
"revision": "d8b3e2c1f4a5"
},
{
"owner": "govoplan-addresses",
"revision": "e1f2a4b5c6d"
}
],
"owner_heads": [
{
"owner": "govoplan-access",
"revisions": [
"4a5b6c7d8e9f"
]
},
{
"owner": "govoplan-addresses",
"revisions": [
"e1f2a4b5c6d"
]
},
{
"owner": "govoplan-calendar",
"revisions": [
"af1b2c3d4e5f"
]
},
{
"owner": "govoplan-campaign",
"revisions": [
"d8b3e2c1f4a5"
]
},
{
"owner": "govoplan-core",
"revisions": [
"4f2a9c8e7b6d"
]
},
{
"owner": "govoplan-files",
"revisions": [
"a7b8c9d0e1f3"
]
},
{
"owner": "govoplan-identity",
"revisions": [
"5c6d7e8f9a10"
]
},
{
"owner": "govoplan-idm",
"revisions": [
"8f9a0b1c2d3e"
]
},
{
"owner": "govoplan-mail",
"revisions": [
"608192abcdef"
]
},
{
"owner": "govoplan-notifications",
"revisions": [
"6f7a8b9c0d1e"
]
},
{
"owner": "govoplan-organizations",
"revisions": [
"6d7e8f9a0b1c"
]
},
{
"owner": "govoplan-poll",
"revisions": [
"6e7f8a9b0c1d"
]
},
{
"owner": "govoplan-scheduling",
"revisions": [
"c9d4e7f1a2b3"
]
}
],
"recorded_at": "2026-07-22T18:15:01Z",
"release": "0.1.14",
"squash_policy": "reviewed-manual",
"track": "release"
}
],
"version": 1
+21 -13
View File
@@ -12,9 +12,9 @@
"version": "0.1.4",
"action": "install",
"python_package": "govoplan-files",
"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/GovOPlaN/govoplan-files.git@v0.1.4",
"webui_package": "@govoplan/files-webui",
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4",
"webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.4",
"migration_safety": "forward_only",
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
"migration_after": ["access"],
@@ -54,14 +54,14 @@
],
"artifact_integrity": {
"python": {
"ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.4",
"ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.4",
"sha256": "<sha256 of the resolved Python artifact>",
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-0.1.4.spdx.json",
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-0.1.4.intoto.jsonl",
"git_ref": "refs/tags/v0.1.4"
},
"webui": {
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4",
"ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.4",
"sha256": "<sha256 of the resolved WebUI package artifact>",
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-webui-0.1.4.spdx.json",
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-webui-0.1.4.intoto.jsonl",
@@ -78,25 +78,33 @@
"version": "0.1.4",
"action": "install",
"python_package": "govoplan-mail",
"python_ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.4",
"python_ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.4",
"webui_package": "@govoplan/mail-webui",
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4",
"webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.4",
"provides_interfaces": [
{
"name": "mail.campaign_delivery",
"version": "1.4.0"
"version": "0.2.0"
}
],
"requires_interfaces": [
{
"name": "campaigns.access",
"version_min": "0.1.0",
"version_max_exclusive": "0.2.0",
"optional": true
}
],
"artifact_integrity": {
"python": {
"ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.4",
"ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.4",
"sha256": "<sha256 of the resolved Python artifact>",
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-0.1.4.spdx.json",
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-0.1.4.intoto.jsonl",
"git_ref": "refs/tags/v0.1.4"
},
"webui": {
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4",
"ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.4",
"sha256": "<sha256 of the resolved WebUI package artifact>",
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-webui-0.1.4.spdx.json",
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-webui-0.1.4.intoto.jsonl",
@@ -113,19 +121,19 @@
"version": "0.1.6",
"action": "install",
"python_package": "govoplan-dashboard",
"python_ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git@v0.1.6",
"python_ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.6",
"webui_package": "@govoplan/dashboard-webui",
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.6",
"webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.6",
"artifact_integrity": {
"python": {
"ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git@v0.1.6",
"ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.6",
"sha256": "<sha256 of the resolved Python artifact>",
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-0.1.6.spdx.json",
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-0.1.6.intoto.jsonl",
"git_ref": "refs/tags/v0.1.6"
},
"webui": {
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.6",
"ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.6",
"sha256": "<sha256 of the resolved WebUI package artifact>",
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-webui-0.1.6.spdx.json",
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-webui-0.1.6.intoto.jsonl",
+7 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-core"
version = "0.1.9"
version = "0.1.14"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md"
requires-python = ">=3.12"
@@ -27,6 +27,12 @@ where = ["src"]
[tool.setuptools.package-data]
govoplan_core = ["py.typed"]
[tool.setuptools.data-files]
"govoplan_core_runtime" = ["alembic.ini"]
"govoplan_core_runtime/alembic" = ["alembic/env.py", "alembic/script.py.mako"]
"govoplan_core_runtime/alembic/versions" = ["alembic/versions/*.py"]
"govoplan_core_runtime/alembic/dev_versions" = ["alembic/dev_versions/*.py"]
[project.scripts]
govoplan-config = "govoplan_core.commands.config:main"
govoplan-devserver = "govoplan_core.devserver:main"
+18
View File
@@ -39,6 +39,24 @@ class DeltaCollectionResponse(BaseModel):
full: bool = False
class ReferenceOptionResponse(BaseModel):
value: str
label: str
description: str | None = None
kind: str | None = None
availability: Literal["available", "inactive", "unavailable"] = "available"
disabled: bool = False
source_module: str | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
class ReferenceOptionListResponse(BaseModel):
options: list[ReferenceOptionResponse] = Field(default_factory=list)
provider_available: bool = True
next_cursor: str | None = None
has_more: bool = False
class LoginRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
+8 -4
View File
@@ -16,7 +16,7 @@ from govoplan_core.core.events import (
PlatformEvent,
current_event_trace,
normalize_trace_id,
publish_platform_event,
emit_platform_event,
)
from govoplan_core.core.runtime import get_registry
from govoplan_core.privacy.retention import sanitize_audit_details_for_policy
@@ -171,9 +171,13 @@ class _NullAuditRecorder:
)
def _publish_audit_platform_event(item: AuditRecordRef) -> None:
def _publish_audit_platform_event(
session: Session,
item: AuditRecordRef,
) -> None:
trace = _compact_trace(item.details.get("_trace") if isinstance(item.details, Mapping) else None)
publish_platform_event(
emit_platform_event(
session,
PlatformEvent(
type=item.action,
module_id=_module_id_for_audit_action(item.action),
@@ -252,7 +256,7 @@ def audit_event(
actor_id=user_id or api_key_id,
payload={"scope": scope, "action": action, "object_type": object_type, "object_id": object_id},
)
_publish_audit_platform_event(item)
_publish_audit_platform_event(session, item)
if commit:
session.commit()
return item
+1 -1
View File
@@ -118,7 +118,7 @@ def _registry_from_request(request: Request) -> PlatformRegistry | None:
def _api_principal_provider_from_request(request: Request) -> ApiPrincipalProvider:
registry = _registry_from_request(request)
if registry is None or not registry.has_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Auth provider is not available")
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Auth provider is not available")
capability = registry.require_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER)
if not isinstance(capability, ApiPrincipalProvider):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid auth provider capability")
+470
View File
@@ -1,12 +1,40 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from celery import Celery
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_OUTBOX, CalendarOutboxProvider
from govoplan_core.core.dataflows import (
CAPABILITY_DATAFLOW_RUN_WORKER,
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER,
DataflowRunWorker,
DataflowTriggerDispatcher,
)
from govoplan_core.core.events import (
CAPABILITY_PLATFORM_EVENT_OUTBOX,
DurableEventConsumer,
PlatformEvent,
PlatformEventOutbox,
publish_platform_event,
)
from govoplan_core.core.idm import (
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE,
IdmAssignmentLifecycle,
)
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.mail import CAPABILITY_MAIL_DELIVERY_OUTBOX, MailDeliveryOutboxProvider
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_ROUTING,
PostboxRoutingProvider,
)
from govoplan_core.core.workflows import (
CAPABILITY_WORKFLOW_RUNTIME_WORKER,
WorkflowRuntimeWorker,
)
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import configure_runtime
from govoplan_core.settings import settings
@@ -28,7 +56,17 @@ celery.conf.update(
"govoplan.campaigns.append_sent": {"queue": "append_sent"},
"govoplan.notifications.deliver": {"queue": "notifications"},
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
"govoplan.mail.dispatch_outbox": {"queue": "mail"},
"govoplan.mail.purge_outbox": {"queue": "mail"},
"govoplan.calendar.dispatch_outbox": {"queue": "calendar"},
"govoplan.dataflow.dispatch_runs": {"queue": "dataflow"},
"govoplan.dataflow.purge_runs": {"queue": "dataflow"},
"govoplan.dataflow.dispatch_triggers": {"queue": "dataflow"},
"govoplan.workflow.reconcile": {"queue": "workflow"},
"govoplan.postbox.dispatch_routes": {"queue": "postbox"},
"govoplan.events.dispatch_outbox": {"queue": "events"},
"govoplan.events.purge_outbox": {"queue": "events"},
"govoplan.idm.expire_assignments": {"queue": "idm"},
},
worker_prefetch_multiplier=1,
task_acks_late=True,
@@ -39,6 +77,56 @@ celery.conf.update(
"schedule": 60.0,
"args": (None, 100),
},
"mail-outbox-every-five-seconds": {
"task": "govoplan.mail.dispatch_outbox",
"schedule": 5.0,
"args": (None, 25),
},
"mail-outbox-retention-daily": {
"task": "govoplan.mail.purge_outbox",
"schedule": 24 * 60 * 60.0,
"args": (250,),
},
"dataflow-triggers-every-minute": {
"task": "govoplan.dataflow.dispatch_triggers",
"schedule": 60.0,
"args": (100,),
},
"dataflow-runs-every-five-seconds": {
"task": "govoplan.dataflow.dispatch_runs",
"schedule": 5.0,
"args": (10,),
},
"dataflow-run-retention-daily": {
"task": "govoplan.dataflow.purge_runs",
"schedule": 24 * 60 * 60.0,
"args": (500,),
},
"workflow-reconcile-every-five-seconds": {
"task": "govoplan.workflow.reconcile",
"schedule": 5.0,
"args": (50,),
},
"postbox-routes-every-minute": {
"task": "govoplan.postbox.dispatch_routes",
"schedule": 60.0,
"args": (None, 50),
},
"platform-events-every-ten-seconds": {
"task": "govoplan.events.dispatch_outbox",
"schedule": 10.0,
"args": (100,),
},
"platform-event-retention-daily": {
"task": "govoplan.events.purge_outbox",
"schedule": 24 * 60 * 60.0,
"args": (500,),
},
"idm-assignment-expiry-every-minute": {
"task": "govoplan.idm.expire_assignments",
"schedule": 60.0,
"args": (None, 100),
},
},
)
@@ -86,6 +174,94 @@ def _calendar_outbox() -> CalendarOutboxProvider | None:
return capability
def _mail_delivery_outbox() -> MailDeliveryOutboxProvider | None:
registry = _platform_registry()
if not registry.has_capability(CAPABILITY_MAIL_DELIVERY_OUTBOX):
return None
capability = registry.require_capability(CAPABILITY_MAIL_DELIVERY_OUTBOX)
if not isinstance(capability, MailDeliveryOutboxProvider):
raise RuntimeError("Mail delivery outbox capability is invalid")
return capability
def _dataflow_trigger_dispatcher(
registry: PlatformRegistry | None = None,
) -> DataflowTriggerDispatcher | None:
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER):
return None
capability = registry.require_capability(
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER
)
if not isinstance(capability, DataflowTriggerDispatcher):
raise RuntimeError("Dataflow trigger dispatcher capability is invalid")
return capability
def _dataflow_run_worker(
registry: PlatformRegistry | None = None,
) -> DataflowRunWorker | None:
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_DATAFLOW_RUN_WORKER):
return None
capability = registry.require_capability(CAPABILITY_DATAFLOW_RUN_WORKER)
if not isinstance(capability, DataflowRunWorker):
raise RuntimeError("Dataflow run worker capability is invalid")
return capability
def _workflow_runtime_worker(
registry: PlatformRegistry | None = None,
) -> WorkflowRuntimeWorker | None:
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_WORKFLOW_RUNTIME_WORKER):
return None
capability = registry.require_capability(
CAPABILITY_WORKFLOW_RUNTIME_WORKER
)
if not isinstance(capability, WorkflowRuntimeWorker):
raise RuntimeError("Workflow runtime worker capability is invalid")
return capability
def _postbox_routing_provider(
registry: PlatformRegistry | None = None,
) -> PostboxRoutingProvider | None:
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_POSTBOX_ROUTING):
return None
capability = registry.require_capability(CAPABILITY_POSTBOX_ROUTING)
if not isinstance(capability, PostboxRoutingProvider):
raise RuntimeError("Postbox routing capability is invalid")
return capability
def _platform_event_outbox(
registry: PlatformRegistry | None = None,
) -> PlatformEventOutbox | None:
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX):
return None
capability = registry.require_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
if not isinstance(capability, PlatformEventOutbox):
raise RuntimeError("Platform event outbox capability is invalid")
return capability
def _idm_assignment_lifecycle(
registry: PlatformRegistry | None = None,
) -> IdmAssignmentLifecycle | None:
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE):
return None
capability = registry.require_capability(
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE
)
if not isinstance(capability, IdmAssignmentLifecycle):
raise RuntimeError("IDM assignment lifecycle capability is invalid")
return capability
@celery.task(name="govoplan.campaigns.send_email", bind=True, max_retries=0)
def send_email(self, job_id: str):
"""Send one explicitly queued campaign job.
@@ -136,6 +312,51 @@ def deliver_pending_notifications(self, tenant_id: str | None = None, limit: int
return result
@celery.task(name="govoplan.mail.dispatch_outbox", bind=True, max_retries=0)
def dispatch_mail_outbox(
self,
tenant_id: str | None = None,
limit: int = 25,
):
"""Drain durable Mail commands; retry and reconciliation live in Mail."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
provider = _mail_delivery_outbox()
if provider is None:
return {
"selected": 0,
"accepted": 0,
"partially_refused": 0,
"retrying": 0,
"failed": 0,
"outcome_unknown": 0,
"command_ids": [],
}
return dict(
provider.dispatch_due(
session,
tenant_id=tenant_id,
limit=limit,
worker_id=getattr(self.request, "hostname", None),
)
)
@celery.task(name="govoplan.mail.purge_outbox", bind=True, max_retries=0)
def purge_mail_outbox(self, limit: int = 250):
"""Minimize expired Mail payloads while retaining delivery evidence."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
provider = _mail_delivery_outbox()
if provider is None:
return {"purged": 0}
return dict(provider.purge_expired(session, limit=limit))
@celery.task(name="govoplan.calendar.dispatch_outbox", bind=True, max_retries=0)
def dispatch_calendar_outbox(self, tenant_id: str | None = None, limit: int = 50):
"""Drain durable Calendar operations; retry timing lives in the database."""
@@ -149,3 +370,252 @@ def dispatch_calendar_outbox(self, tenant_id: str | None = None, limit: int = 50
result = dict(provider.dispatch_due(session, tenant_id=tenant_id, limit=limit))
session.commit()
return result
@celery.task(
name="govoplan.dataflow.dispatch_triggers",
bind=True,
max_retries=0,
)
def dispatch_dataflow_triggers(self, limit: int = 100):
"""Drain durable Dataflow trigger deliveries and due schedules."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
provider = _dataflow_trigger_dispatcher()
if provider is None:
return {
"queued": 0,
"processed": 0,
"succeeded": 0,
"failed": 0,
"blocked": 0,
"skipped": 0,
}
result = dict(provider.dispatch_due(session, limit=limit))
session.commit()
return result
@celery.task(
name="govoplan.dataflow.dispatch_runs",
bind=True,
max_retries=0,
)
def dispatch_dataflow_runs(self, limit: int = 10):
"""Claim and execute durable Dataflow runs outside the API process."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
provider = _dataflow_run_worker()
if provider is None:
return {
"claimed": 0,
"succeeded": 0,
"retrying": 0,
"failed": 0,
"cancelled": 0,
}
result = dict(
provider.dispatch_pending(
session,
limit=limit,
worker_id=getattr(self.request, "hostname", None),
)
)
session.commit()
return result
@celery.task(
name="govoplan.dataflow.purge_runs",
bind=True,
max_retries=0,
)
def purge_dataflow_runs(self, limit: int = 500):
"""Apply Dataflow evidence-retention policy without deleting run records."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
provider = _dataflow_run_worker()
if provider is None:
return {"purged": 0}
result = dict(provider.purge_expired(session, limit=limit))
session.commit()
return result
@celery.task(
name="govoplan.workflow.reconcile",
bind=True,
max_retries=0,
)
def reconcile_workflow_instances(self, limit: int = 50):
"""Resume asynchronous Workflow steps from durable provider state."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
provider = _workflow_runtime_worker()
if provider is None:
return {
"inspected": 0,
"advanced": 0,
"waiting": 0,
"failed": 0,
}
result = dict(provider.reconcile_pending(session, limit=limit))
session.commit()
return result
@celery.task(
name="govoplan.postbox.dispatch_routes",
bind=True,
max_retries=0,
)
def dispatch_postbox_routes(
self,
tenant_id: str | None = None,
limit: int = 50,
):
"""Deliver due Postbox vacancy escalations from durable route rows."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
provider = _postbox_routing_provider()
if provider is None:
return {
"selected": 0,
"delivered": 0,
"vacant": 0,
"rescheduled": 0,
"cancelled": 0,
"failed": 0,
"route_ids": [],
}
result = dict(
provider.dispatch_due_routes(
session,
tenant_id=tenant_id,
limit=limit,
)
)
session.commit()
return result
@celery.task(
name="govoplan.idm.expire_assignments",
bind=True,
max_retries=0,
)
def expire_idm_assignments(
self,
tenant_id: str | None = None,
limit: int = 100,
):
"""Emit idempotent lifecycle events for elapsed IDM assignments."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
provider = _idm_assignment_lifecycle()
if provider is None:
return {"selected": 0, "expired": 0, "assignment_ids": []}
result = dict(
provider.process_expired(
session,
tenant_id=tenant_id,
limit=limit,
)
)
session.commit()
return result
@celery.task(
name="govoplan.events.dispatch_outbox",
bind=True,
max_retries=0,
)
def dispatch_platform_events(self, limit: int = 100):
"""Deliver committed platform events through persistent consumer ledgers."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
registry = _platform_registry()
outbox = _platform_event_outbox(registry)
if outbox is None:
return {
"selected": 0,
"delivered": 0,
"retrying": 0,
"quarantined": 0,
"dispatched": 0,
"observer_failed": 0,
}
dataflow_dispatcher = _dataflow_trigger_dispatcher(registry)
consumers = ()
if dataflow_dispatcher is not None:
def deliver_to_dataflow(
event: PlatformEvent,
_delivery_key: str,
) -> None:
dataflow_dispatcher.ingest_event(
session,
event=event,
)
consumers = (
DurableEventConsumer(
consumer_id="dataflow.event-triggers.v1",
event_types=frozenset({"*"}),
classifications=frozenset({"public", "internal"}),
handler=deliver_to_dataflow,
),
)
result = dict(
outbox.dispatch_pending(
session,
consumers=consumers,
observer=publish_platform_event,
limit=limit,
)
)
session.commit()
return result
@celery.task(
name="govoplan.events.purge_outbox",
bind=True,
max_retries=0,
)
def purge_platform_events(self, limit: int = 500):
"""Remove old terminal event envelopes while retaining quarantine evidence."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
outbox = _platform_event_outbox()
if outbox is None:
return {"deleted": 0}
before = datetime.now(timezone.utc) - timedelta(
days=settings.platform_event_outbox_terminal_retention_days
)
result = dict(
outbox.purge_terminal(
session,
before=before,
limit=limit,
)
)
session.commit()
return result
+11
View File
@@ -28,6 +28,9 @@ CAPABILITY_AUDIT_RECORDER = "audit.recorder"
CAPABILITY_AUDIT_RETENTION = "audit.retention"
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER = "auth.apiPrincipalProvider"
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER = (
"auth.automationPrincipalProvider"
)
CAPABILITY_AUTH_PRINCIPAL_RESOLVER = "auth.principalResolver"
CAPABILITY_AUTH_PERMISSION_EVALUATOR = "auth.permissionEvaluator"
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER = "auth.tenantContextSwitcher"
@@ -48,6 +51,7 @@ ACCESS_CAPABILITY_NAMES = frozenset(
CAPABILITY_AUDIT_RECORDER,
CAPABILITY_AUDIT_RETENTION,
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
@@ -57,12 +61,19 @@ ACCESS_CAPABILITY_NAMES = frozenset(
AUTH_CAPABILITY_NAMES = frozenset(
{
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
}
)
DEFAULT_CAPABILITY_PROVIDERS: Mapping[str, str] = {
CAPABILITY_AUTH_PRINCIPAL_RESOLVER: ACCESS_MODULE_ID,
CAPABILITY_AUTH_PERMISSION_EVALUATOR: ACCESS_MODULE_ID,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER: ACCESS_MODULE_ID,
}
AuthMethod = Literal["session", "api_key", "service_account"]
AccessSubjectKind = Literal[
"identity",
+393
View File
@@ -0,0 +1,393 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.access import (
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
)
AutomationInvocationKind = Literal[
"manual",
"api",
"schedule",
"event",
"workflow",
"dependency",
"retry",
"backfill",
]
AutomationSubjectKind = Literal["delegated_user", "service_account"]
AUTOMATION_PRINCIPAL_CONTRACT_VERSION = "1"
ACTION_EFFECT_CONTRACT_VERSION = "1"
ActionRiskLevel = Literal["low", "moderate", "high", "critical"]
ActionReversibility = Literal[
"reversible",
"compensatable",
"corrective_only",
"irreversible",
]
ActionExecutionState = Literal[
"pending",
"running",
"completed",
"blocked",
"retryable",
"quarantined",
"manual_required",
"compensation_required",
]
EffectOperation = Literal[
"created",
"changed",
"deleted",
"sent",
"notified",
"locked",
"retained",
"external",
]
@dataclass(frozen=True, slots=True)
class EffectDefinition:
effect_key: str
owner_module: str
operation: EffectOperation
description: str
resource_types: tuple[str, ...] = ()
visibility_classification: str = "internal"
audit_event_types: tuple[str, ...] = ()
compensation_hint: str | None = None
contract_version: str = ACTION_EFFECT_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract_version(self.contract_version)
_require_text(self.effect_key, "Effect key")
_require_text(self.owner_module, "Effect owner module")
_require_text(self.description, "Effect description")
@dataclass(frozen=True, slots=True)
class ActionDefinition:
action_key: str
owner_module: str
description: str
input_schema_ref: str
required_scopes: tuple[str, ...] = ()
required_capabilities: tuple[str, ...] = ()
policy_checks: tuple[str, ...] = ()
risk_level: ActionRiskLevel = "moderate"
reversibility: ActionReversibility = "corrective_only"
expected_effect_keys: tuple[str, ...] = ()
idempotency_strategy: str = "caller_supplied"
audit_event_types: tuple[str, ...] = ()
preview_required: bool = True
contract_version: str = ACTION_EFFECT_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract_version(self.contract_version)
_require_text(self.action_key, "Action key")
_require_text(self.owner_module, "Action owner module")
_require_text(self.description, "Action description")
_require_text(self.input_schema_ref, "Action input schema reference")
_require_text(self.idempotency_strategy, "Action idempotency strategy")
if any(not value.strip() for value in self.required_scopes):
raise ValueError("Action scopes must not be empty")
if any(not value.strip() for value in self.required_capabilities):
raise ValueError("Action capabilities must not be empty")
if any(not value.strip() for value in self.expected_effect_keys):
raise ValueError("Expected effect keys must not be empty")
@dataclass(frozen=True, slots=True)
class ActionExecutionRequest:
tenant_id: str
action_key: str
input: Mapping[str, object]
idempotency_key: str
invocation: AutomationInvocation
actor_ref: str | None = None
preview_ref: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
contract_version: str = ACTION_EFFECT_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract_version(self.contract_version)
_require_text(self.tenant_id, "Action tenant id")
_require_text(self.action_key, "Action key")
_require_text(self.idempotency_key, "Action idempotency key")
@dataclass(frozen=True, slots=True)
class EffectPreview:
effect_key: str
summary: str
resource_refs: tuple[str, ...] = ()
external_system_refs: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class ActionPreview:
action_key: str
allowed: bool
summary: str
risk_level: ActionRiskLevel
reversibility: ActionReversibility
effects: tuple[EffectPreview, ...] = ()
blockers: tuple[str, ...] = ()
policy_provenance: tuple[Mapping[str, object], ...] = ()
preview_ref: str | None = None
@dataclass(frozen=True, slots=True)
class ObservedEffect:
effect_key: str
operation: EffectOperation
resource_ref: str | None = None
external_system_ref: str | None = None
audit_event_ref: str | None = None
summary: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ActionExecutionResult:
state: ActionExecutionState
output: Mapping[str, object] = field(default_factory=dict)
observed_effects: tuple[ObservedEffect, ...] = ()
error: str | None = None
retry_after: datetime | None = None
manual_instructions: str | None = None
compensation_action_key: str | None = None
audit_event_refs: tuple[str, ...] = ()
@runtime_checkable
class ActionEffectProvider(Protocol):
def action_definitions(self) -> tuple[ActionDefinition, ...]: ...
def effect_definitions(self) -> tuple[EffectDefinition, ...]: ...
def preview_action(
self,
session: object,
principal: object,
*,
request: ActionExecutionRequest,
) -> ActionPreview: ...
def execute_action(
self,
session: object,
principal: object,
*,
request: ActionExecutionRequest,
) -> ActionExecutionResult: ...
@dataclass(frozen=True, slots=True)
class AutomationInvocation:
kind: AutomationInvocationKind = "manual"
trigger_ref: str | None = None
delivery_ref: str | None = None
event_id: str | None = None
event_type: str | None = None
correlation_id: str | None = None
causation_id: str | None = None
scheduled_for: datetime | None = None
requested_by: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class AutomationPrincipalRequest:
tenant_id: str
authorization_ref: str
grant_scopes: tuple[str, ...]
account_id: str | None = None
membership_id: str | None = None
service_account_id: str | None = None
subject_kind: AutomationSubjectKind = "delegated_user"
context: Mapping[str, object] = field(default_factory=dict)
contract_version: str = AUTOMATION_PRINCIPAL_CONTRACT_VERSION
def __post_init__(self) -> None:
if (
self.contract_version
!= AUTOMATION_PRINCIPAL_CONTRACT_VERSION
):
raise ValueError(
"Unsupported automation-principal contract version"
)
if not self.tenant_id.strip():
raise ValueError("Automation tenant id is required")
if not self.authorization_ref.strip():
raise ValueError(
"Automation authorization artifact reference is required"
)
if self.subject_kind == "delegated_user":
if (
not self.account_id
or not self.membership_id
or self.service_account_id is not None
):
raise ValueError(
"Delegated-user automation requires account and "
"membership references only"
)
elif (
not self.service_account_id
or self.account_id is not None
or self.membership_id is not None
):
raise ValueError(
"Service-account automation requires only a service-account "
"reference"
)
if any(
not scope.strip()
for scope in self.grant_scopes
):
raise ValueError("Automation grant scopes must not be empty")
@classmethod
def delegated_user(
cls,
*,
tenant_id: str,
account_id: str,
membership_id: str,
authorization_ref: str,
grant_scopes: tuple[str, ...],
context: Mapping[str, object] | None = None,
) -> AutomationPrincipalRequest:
return cls(
tenant_id=tenant_id,
account_id=account_id,
membership_id=membership_id,
authorization_ref=authorization_ref,
grant_scopes=grant_scopes,
context=context or {},
)
@classmethod
def service_account(
cls,
*,
tenant_id: str,
service_account_id: str,
authorization_ref: str,
grant_scopes: tuple[str, ...],
context: Mapping[str, object] | None = None,
) -> AutomationPrincipalRequest:
return cls(
tenant_id=tenant_id,
service_account_id=service_account_id,
subject_kind="service_account",
authorization_ref=authorization_ref,
grant_scopes=grant_scopes,
context=context or {},
)
@dataclass(frozen=True, slots=True)
class AutomationPrincipalResolution:
allowed: bool
principal: object | None = None
reason: str | None = None
granted_scopes: tuple[str, ...] = ()
missing_scopes: tuple[str, ...] = ()
provenance: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class AutomationPrincipalProvider(Protocol):
def resolve_automation_principal(
self,
session: object,
*,
request: AutomationPrincipalRequest,
) -> AutomationPrincipalResolution:
...
def automation_principal_provider(
registry: object | None,
) -> AutomationPrincipalProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
)
):
return None
capability = registry.capability(
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
)
return (
capability
if isinstance(capability, AutomationPrincipalProvider)
else None
)
def action_effect_provider(
registry: object | None,
capability_name: str,
) -> ActionEffectProvider | None:
name = capability_name.strip()
if (
not name
or registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(name)
):
return None
capability = registry.capability(name)
return (
capability
if isinstance(capability, ActionEffectProvider)
else None
)
def _validate_contract_version(value: str) -> None:
if value != ACTION_EFFECT_CONTRACT_VERSION:
raise ValueError("Unsupported action/effect contract version")
def _require_text(value: str, label: str) -> None:
if not value.strip():
raise ValueError(f"{label} is required")
__all__ = [
"ACTION_EFFECT_CONTRACT_VERSION",
"CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER",
"AUTOMATION_PRINCIPAL_CONTRACT_VERSION",
"ActionDefinition",
"ActionEffectProvider",
"ActionExecutionRequest",
"ActionExecutionResult",
"ActionExecutionState",
"AutomationInvocation",
"AutomationInvocationKind",
"AutomationPrincipalProvider",
"AutomationPrincipalRequest",
"AutomationPrincipalResolution",
"AutomationSubjectKind",
"ActionPreview",
"ActionReversibility",
"ActionRiskLevel",
"EffectDefinition",
"EffectOperation",
"EffectPreview",
"ObservedEffect",
"action_effect_provider",
"automation_principal_provider",
]
+5 -4
View File
@@ -6,7 +6,7 @@ from typing import Any, Iterable, Sequence
from sqlalchemy import BigInteger, DateTime, Index, Integer, JSON, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, Session, mapped_column
from govoplan_core.core.events import EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, publish_platform_event
from govoplan_core.core.events import EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, emit_platform_event
from govoplan_core.db.base import Base, utcnow
WATERMARK_PREFIX = "seq:"
@@ -96,13 +96,14 @@ def record_change(
payload=payload or {},
)
session.add(entry)
_publish_change_event(entry)
_publish_change_event(session, entry)
return entry
def _publish_change_event(entry: ChangeSequenceEntry) -> None:
def _publish_change_event(session: Session, entry: ChangeSequenceEntry) -> None:
event_type = _change_event_type(entry.module_id, entry.resource_type, entry.operation)
publish_platform_event(
emit_platform_event(
session,
PlatformEvent(
type=event_type,
module_id=entry.module_id,
+559
View File
@@ -0,0 +1,559 @@
from __future__ import annotations
import copy
import hashlib
import json
from dataclasses import dataclass, field
from typing import Any, Callable, Iterable, Mapping, Sequence
from sqlalchemy.orm import Session
class ConcurrencyError(RuntimeError):
"""Base class for mutation precondition and compare-and-set failures."""
class MissingPreconditionError(ConcurrencyError):
def __init__(self, *, resource_type: str, resource_id: str) -> None:
self.resource_type = resource_type
self.resource_id = resource_id
super().__init__("A strong If-Match precondition is required for this mutation.")
def as_dict(self) -> dict[str, Any]:
return {
"code": "precondition_required",
"resource": {
"type": self.resource_type,
"id": self.resource_id,
},
"retryable": True,
}
class RevisionConflictError(ConcurrencyError):
def __init__(
self,
*,
resource_type: str,
resource_id: str,
current_revision: int,
submitted_base_revision: int,
refresh_path: str | None = None,
conflicts: Sequence["MergeConflict"] = (),
merge_candidate: Any = None,
current_etag: str | None = None,
) -> None:
self.resource_type = resource_type
self.resource_id = resource_id
self.current_revision = int(current_revision)
self.submitted_base_revision = int(submitted_base_revision)
self.refresh_path = refresh_path
self.conflicts = tuple(conflicts)
self.merge_candidate = merge_candidate
self.current_etag = current_etag
super().__init__(
f"{resource_type} {resource_id} changed from revision "
f"{submitted_base_revision} to {current_revision}"
)
@property
def safe_merge_available(self) -> bool:
return self.merge_candidate is not None and not self.conflicts
def as_dict(
self,
*,
include_values: bool = False,
include_merge_candidate: bool = False,
) -> dict[str, Any]:
result: dict[str, Any] = {
"code": "revision_conflict",
"resource": {
"type": self.resource_type,
"id": self.resource_id,
},
"current_revision": self.current_revision,
"submitted_base_revision": self.submitted_base_revision,
"retryable": True,
"safe_merge_available": self.safe_merge_available,
"conflicts": [
conflict.as_dict(include_values=include_values)
for conflict in self.conflicts[:100]
],
}
if self.refresh_path:
result["refresh_path"] = self.refresh_path
if self.current_etag:
result["current_etag"] = self.current_etag
if include_merge_candidate and self.safe_merge_available:
result["merge_candidate"] = copy.deepcopy(self.merge_candidate)
return result
@dataclass(frozen=True, slots=True)
class MergeConflict:
path: str
kind: str
base_value: Any = None
local_value: Any = None
current_value: Any = None
def as_dict(self, *, include_values: bool = False) -> dict[str, Any]:
result: dict[str, Any] = {
"path": self.path or "/",
"kind": self.kind,
}
if include_values:
result.update(
{
"base_value": _bounded_json_value(self.base_value),
"local_value": _bounded_json_value(self.local_value),
"current_value": _bounded_json_value(self.current_value),
}
)
return result
@dataclass(slots=True)
class ThreeWayMergeResult:
value: Any
conflicts: list[MergeConflict] = field(default_factory=list)
applied_paths: list[str] = field(default_factory=list)
@property
def merged(self) -> bool:
return not self.conflicts
ProtectedPath = str | Callable[[str], bool]
def strong_resource_etag(
resource_type: str,
resource_id: str,
revision: int,
) -> str:
"""Return an opaque strong ETag for one mutable aggregate revision."""
normalized_revision = int(revision)
if normalized_revision < 1:
raise ValueError("Resource revisions must be positive integers")
digest = hashlib.sha256(
"\x00".join(
(
"govoplan-strong-revision-v1",
str(resource_type),
str(resource_id),
str(normalized_revision),
)
).encode("utf-8")
).hexdigest()
return f'"sha256-{digest}"'
def if_match_matches(header_value: str | None, expected_etag: str) -> bool:
"""Apply strong comparison semantics to an If-Match header."""
if not header_value:
return False
for raw_candidate in header_value.split(","):
candidate = raw_candidate.strip()
if candidate == "*":
return True
if candidate.startswith("W/"):
continue
if candidate == expected_etag:
return True
return False
def assert_revision_precondition(
if_match: str | None,
*,
resource_type: str,
resource_id: str,
submitted_base_revision: int,
) -> None:
if not if_match:
raise MissingPreconditionError(
resource_type=resource_type,
resource_id=resource_id,
)
submitted_etag = strong_resource_etag(
resource_type,
resource_id,
submitted_base_revision,
)
if not if_match_matches(if_match, submitted_etag):
raise ConcurrencyError(
"If-Match does not identify the submitted base revision."
)
def claim_revision(
session: Session,
*,
model: type[Any],
filters: Iterable[Any],
revision_attribute: str,
expected_revision: int,
resource_type: str,
resource_id: str,
refresh_path: str | None = None,
) -> int:
"""Atomically claim the next revision in the caller's transaction."""
revision_column = getattr(model, revision_attribute)
expected = int(expected_revision)
normalized_filters = tuple(filters)
query = session.query(model).filter(*normalized_filters)
updated = query.filter(revision_column == expected).update(
{revision_column: revision_column + 1},
synchronize_session=False,
)
if updated == 1:
session.flush()
return expected + 1
current = (
session.query(revision_column)
.filter(*normalized_filters)
.scalar()
)
if current is None:
raise LookupError(f"{resource_type} {resource_id} was not found")
raise RevisionConflictError(
resource_type=resource_type,
resource_id=resource_id,
current_revision=int(current),
submitted_base_revision=expected,
refresh_path=refresh_path,
current_etag=strong_resource_etag(
resource_type,
resource_id,
int(current),
),
)
def three_way_merge(
base: Any,
local: Any,
current: Any,
*,
protected_paths: Sequence[ProtectedPath] = (),
stable_id_fields: Sequence[str] = ("id",),
) -> ThreeWayMergeResult:
"""Conservatively merge local changes onto a concurrently changed value."""
return _merge_value(
copy.deepcopy(base),
copy.deepcopy(local),
copy.deepcopy(current),
path="",
protected_paths=tuple(protected_paths),
stable_id_fields=tuple(stable_id_fields),
)
def _merge_value(
base: Any,
local: Any,
current: Any,
*,
path: str,
protected_paths: Sequence[ProtectedPath],
stable_id_fields: Sequence[str],
) -> ThreeWayMergeResult:
local_changed = local != base
current_changed = current != base
if not local_changed:
return ThreeWayMergeResult(value=current)
if local == current:
return ThreeWayMergeResult(value=current)
if _is_protected(path, protected_paths):
return _conflict(path, "protected_path", base, local, current)
if not current_changed:
return ThreeWayMergeResult(
value=local,
applied_paths=[path or "/"],
)
if isinstance(base, Mapping) and isinstance(local, Mapping) and isinstance(current, Mapping):
return _merge_mapping(
base,
local,
current,
path=path,
protected_paths=protected_paths,
stable_id_fields=stable_id_fields,
)
if isinstance(base, list) and isinstance(local, list) and isinstance(current, list):
return _merge_list(
base,
local,
current,
path=path,
protected_paths=protected_paths,
stable_id_fields=stable_id_fields,
)
return _conflict(path, "same_path_changed", base, local, current)
def _merge_mapping(
base: Mapping[str, Any],
local: Mapping[str, Any],
current: Mapping[str, Any],
*,
path: str,
protected_paths: Sequence[ProtectedPath],
stable_id_fields: Sequence[str],
) -> ThreeWayMergeResult:
missing = object()
result: dict[str, Any] = {}
conflicts: list[MergeConflict] = []
applied_paths: list[str] = []
keys = list(dict.fromkeys((*current.keys(), *local.keys(), *base.keys())))
for key in keys:
child_path = _join_path(path, str(key))
base_value = base.get(key, missing)
local_value = local.get(key, missing)
current_value = current.get(key, missing)
merged = _merge_presence(
base_value,
local_value,
current_value,
missing=missing,
path=child_path,
protected_paths=protected_paths,
stable_id_fields=stable_id_fields,
)
conflicts.extend(merged.conflicts)
applied_paths.extend(merged.applied_paths)
if merged.value is not missing:
result[str(key)] = merged.value
return ThreeWayMergeResult(
value=result,
conflicts=conflicts,
applied_paths=applied_paths,
)
def _merge_presence(
base: Any,
local: Any,
current: Any,
*,
missing: object,
path: str,
protected_paths: Sequence[ProtectedPath],
stable_id_fields: Sequence[str],
) -> ThreeWayMergeResult:
if local is missing and current is missing:
return ThreeWayMergeResult(value=missing)
if base is missing:
if local is missing:
return ThreeWayMergeResult(value=current)
if current is missing:
if _is_protected(path, protected_paths):
return _conflict(path, "protected_path", None, local, None)
return ThreeWayMergeResult(value=local, applied_paths=[path])
if local == current:
return ThreeWayMergeResult(value=current)
return _conflict(path, "concurrent_add", None, local, current)
if local is missing:
if current == base:
if _is_protected(path, protected_paths):
return _conflict(path, "protected_path", base, None, current)
return ThreeWayMergeResult(value=missing, applied_paths=[path])
return _conflict(path, "delete_vs_edit", base, None, current)
if current is missing:
if local == base:
return ThreeWayMergeResult(value=missing)
return _conflict(path, "edit_vs_delete", base, local, None)
return _merge_value(
base,
local,
current,
path=path,
protected_paths=protected_paths,
stable_id_fields=stable_id_fields,
)
def _merge_list(
base: list[Any],
local: list[Any],
current: list[Any],
*,
path: str,
protected_paths: Sequence[ProtectedPath],
stable_id_fields: Sequence[str],
) -> ThreeWayMergeResult:
identity_field = _stable_identity_field(
(base, local, current),
stable_id_fields,
)
if identity_field is None:
return _conflict(path, "unkeyed_collection", base, local, current)
base_by_id = {str(item[identity_field]): item for item in base}
local_by_id = {str(item[identity_field]): item for item in local}
current_by_id = {str(item[identity_field]): item for item in current}
base_order = list(base_by_id)
local_order = list(local_by_id)
current_order = list(current_by_id)
local_reordered = _common_order(local_order, base_order) != _common_order(
base_order,
local_order,
)
current_reordered = _common_order(current_order, base_order) != _common_order(
base_order,
current_order,
)
if local_reordered and current_reordered and local_order != current_order:
return _conflict(path, "collection_reorder", base_order, local_order, current_order)
result_by_id: dict[str, Any] = {}
conflicts: list[MergeConflict] = []
applied_paths: list[str] = []
identities = list(dict.fromkeys((*current_order, *local_order, *base_order)))
missing = object()
for identity in identities:
item_path = _join_path(path, f"{identity_field}={identity}")
merged = _merge_presence(
base_by_id.get(identity, missing),
local_by_id.get(identity, missing),
current_by_id.get(identity, missing),
missing=missing,
path=item_path,
protected_paths=protected_paths,
stable_id_fields=stable_id_fields,
)
conflicts.extend(merged.conflicts)
applied_paths.extend(merged.applied_paths)
if merged.value is not missing:
result_by_id[identity] = merged.value
order_source = local_order if local_reordered and not current_reordered else current_order
merged_order = [identity for identity in order_source if identity in result_by_id]
for identity in identities:
if identity in result_by_id and identity not in merged_order:
merged_order.append(identity)
return ThreeWayMergeResult(
value=[result_by_id[identity] for identity in merged_order],
conflicts=conflicts,
applied_paths=applied_paths,
)
def _stable_identity_field(
values: Sequence[list[Any]],
candidates: Sequence[str],
) -> str | None:
all_items = [item for value in values for item in value]
if not all_items or not all(isinstance(item, Mapping) for item in all_items):
return None
for candidate in candidates:
valid = True
for value in values:
identities = [
str(item.get(candidate, "")).strip()
for item in value
if isinstance(item, Mapping)
]
if any(not identity for identity in identities) or len(identities) != len(
set(identities)
):
valid = False
break
if valid:
return candidate
return None
def _common_order(left: Sequence[str], right: Sequence[str]) -> list[str]:
right_set = set(right)
return [item for item in left if item in right_set]
def _is_protected(path: str, protected_paths: Sequence[ProtectedPath]) -> bool:
normalized = path or "/"
for protected in protected_paths:
if callable(protected):
if protected(normalized):
return True
continue
prefix = protected.rstrip("/") or "/"
if normalized == prefix or normalized.startswith(f"{prefix}/"):
return True
return False
def _join_path(parent: str, segment: str) -> str:
escaped = segment.replace("~", "~0").replace("/", "~1")
return f"{parent}/{escaped}" if parent else f"/{escaped}"
def _conflict(
path: str,
kind: str,
base: Any,
local: Any,
current: Any,
) -> ThreeWayMergeResult:
return ThreeWayMergeResult(
value=current,
conflicts=[
MergeConflict(
path=path or "/",
kind=kind,
base_value=base,
local_value=local,
current_value=current,
)
],
)
def _bounded_json_value(value: Any, *, depth: int = 0) -> Any:
if depth >= 4:
return {"summary": type(value).__name__}
if value is None or isinstance(value, (bool, int, float)):
return value
if isinstance(value, str):
return value[:500]
if isinstance(value, Mapping):
result = {
str(key)[:100]: _bounded_json_value(item, depth=depth + 1)
for key, item in list(value.items())[:20]
}
if len(value) > 20:
result["_truncated_items"] = len(value) - 20
return result
if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray)):
result = [
_bounded_json_value(item, depth=depth + 1)
for item in list(value)[:20]
]
if len(value) > 20:
result.append({"_truncated_items": len(value) - 20})
return result
try:
return json.loads(json.dumps(value))
except (TypeError, ValueError):
return {"summary": type(value).__name__}
__all__ = [
"ConcurrencyError",
"MergeConflict",
"MissingPreconditionError",
"RevisionConflictError",
"ThreeWayMergeResult",
"assert_revision_precondition",
"claim_revision",
"if_match_matches",
"strong_resource_etag",
"three_way_merge",
]
@@ -301,6 +301,18 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
maintenance_required=True,
notes="This deployment-wide egress boundary remains out of band and applies to every connector worker.",
),
ConfigurationFieldSafety(
key="FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
label="Installer-managed Garage trust",
owner_module="files",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Reserved for the exact installer-owned http://garage:3900 service; it must never authorize another S3 endpoint.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
label="Structured connector response limit",
+289
View File
@@ -0,0 +1,289 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
from govoplan_core.core.automation import AutomationInvocation
from govoplan_core.core.events import PlatformEvent
CAPABILITY_DATAFLOW_RUN_LIFECYCLE = "dataflow.runLifecycle"
CAPABILITY_DATAFLOW_RUN_WORKER = "dataflow.runWorker"
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER = "dataflow.triggerDispatcher"
CAPABILITY_DATAFLOW_DATASET_OUTPUT = "dataflow.dataset_output"
class DataflowRunError(ValueError):
"""Stable base error for module-neutral Dataflow run operations."""
class DataflowRunNotFoundError(DataflowRunError):
pass
class DataflowRunConflictError(DataflowRunError):
pass
class DataflowRunUnavailableError(DataflowRunError):
pass
@dataclass(frozen=True, slots=True)
class DataflowDatasetDescriptor:
pipeline_ref: str
name: str
revision: int
definition_hash: str
status: str
description: str | None = None
updated_at: datetime | None = None
parameters: Mapping[str, object] = field(default_factory=dict)
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DataflowDatasetRequest:
pipeline_ref: str
revision: int
parameters: Mapping[str, object] = field(default_factory=dict)
row_limit: int = 500
expected_definition_hash: str | None = None
expected_source_fingerprints: tuple[Mapping[str, object], ...] = ()
@dataclass(frozen=True, slots=True)
class DataflowDatasetResult:
pipeline_ref: str
revision: int
definition_hash: str
rows: tuple[Mapping[str, object], ...]
total_rows: int
truncated: bool
output_hash: str
executor_version: str
run_ref: str | None = None
source_fingerprints: tuple[Mapping[str, object], ...] = ()
diagnostics: tuple[Mapping[str, object], ...] = ()
generated_at: datetime | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class DataflowDatasetOutputProvider(Protocol):
def list_outputs(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
) -> Sequence[DataflowDatasetDescriptor]: ...
def read_output(
self,
session: object,
principal: object,
*,
request: DataflowDatasetRequest,
) -> DataflowDatasetResult: ...
@dataclass(frozen=True, slots=True)
class DataflowPublicationTarget:
target_datasource_ref: str | None = None
name: str | None = None
source_name: str | None = None
description: str | None = None
freeze: bool = False
frozen_label: str | None = None
set_current: bool = True
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DataflowRunRequest:
pipeline_ref: str
revision: int
idempotency_key: str
row_limit: int = 500
execution_backend: str = "auto"
environment: str = "development"
max_attempts: int = 3
retention_days: int = 30
publication: DataflowPublicationTarget | None = None
invocation: AutomationInvocation = field(
default_factory=AutomationInvocation
)
@dataclass(frozen=True, slots=True)
class DataflowRunDescriptor:
ref: str
pipeline_ref: str
revision: int
status: str
definition_hash: str
executor_version: str
input_row_count: int = 0
output_row_count: int = 0
output_publication_ref: str | None = None
output_datasource_ref: str | None = None
output_materialization_ref: str | None = None
invocation_kind: str = "manual"
trigger_ref: str | None = None
delivery_ref: str | None = None
error: str | None = None
started_at: datetime | None = None
finished_at: datetime | None = None
replayed: bool = False
metadata: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class DataflowRunLifecycleProvider(Protocol):
def start_run(
self,
session: object,
principal: object,
*,
request: DataflowRunRequest,
) -> DataflowRunDescriptor:
...
def get_run(
self,
session: object,
principal: object,
*,
run_ref: str,
) -> DataflowRunDescriptor | None:
...
def cancel_run(
self,
session: object,
principal: object,
*,
run_ref: str,
) -> DataflowRunDescriptor:
...
@runtime_checkable
class DataflowTriggerDispatcher(Protocol):
def dispatch_due(
self,
session: object,
*,
now: datetime | None = None,
limit: int = 50,
) -> Mapping[str, object]:
...
def ingest_event(
self,
session: object,
*,
event: PlatformEvent,
) -> Mapping[str, object]:
...
@runtime_checkable
class DataflowRunWorker(Protocol):
"""Durable worker boundary for queued Dataflow execution.
Implementations own claim transaction boundaries so a lease is committed
before potentially long-running execution starts.
"""
def dispatch_pending(
self,
session: object,
*,
now: datetime | None = None,
limit: int = 10,
worker_id: str | None = None,
) -> Mapping[str, object]:
...
def purge_expired(
self,
session: object,
*,
now: datetime | None = None,
limit: int = 500,
) -> Mapping[str, object]:
...
def dataflow_run_lifecycle(
registry: object | None,
) -> DataflowRunLifecycleProvider | None:
capability = _capability(registry, CAPABILITY_DATAFLOW_RUN_LIFECYCLE)
return capability if isinstance(capability, DataflowRunLifecycleProvider) else None
def dataflow_run_worker(
registry: object | None,
) -> DataflowRunWorker | None:
capability = _capability(registry, CAPABILITY_DATAFLOW_RUN_WORKER)
return capability if isinstance(capability, DataflowRunWorker) else None
def dataflow_trigger_dispatcher(
registry: object | None,
) -> DataflowTriggerDispatcher | None:
capability = _capability(registry, CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER)
return (
capability
if isinstance(capability, DataflowTriggerDispatcher)
else None
)
def dataflow_dataset_output(
registry: object | None,
) -> DataflowDatasetOutputProvider | None:
capability = _capability(registry, CAPABILITY_DATAFLOW_DATASET_OUTPUT)
return capability if isinstance(capability, DataflowDatasetOutputProvider) else None
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
__all__ = [
"CAPABILITY_DATAFLOW_RUN_LIFECYCLE",
"CAPABILITY_DATAFLOW_RUN_WORKER",
"CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER",
"CAPABILITY_DATAFLOW_DATASET_OUTPUT",
"DataflowDatasetDescriptor",
"DataflowDatasetOutputProvider",
"DataflowDatasetRequest",
"DataflowDatasetResult",
"DataflowPublicationTarget",
"DataflowRunConflictError",
"DataflowRunDescriptor",
"DataflowRunError",
"DataflowRunLifecycleProvider",
"DataflowRunNotFoundError",
"DataflowRunRequest",
"DataflowRunUnavailableError",
"DataflowRunWorker",
"DataflowTriggerDispatcher",
"dataflow_run_lifecycle",
"dataflow_run_worker",
"dataflow_trigger_dispatcher",
"dataflow_dataset_output",
]
+438
View File
@@ -0,0 +1,438 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
CAPABILITY_DATASOURCE_CATALOGUE = "datasources.catalogue"
CAPABILITY_DATASOURCE_LIFECYCLE = "datasources.lifecycle"
CAPABILITY_DATASOURCE_PUBLICATION = "datasources.publication"
CAPABILITY_DATASOURCE_ORIGINS = "connectors.datasourceOrigins"
DatasourceMode = Literal["live", "cached", "static"]
DatasourceKind = Literal[
"upload",
"database",
"http",
"rest",
"directory",
"file",
"feed",
"custom",
]
DatasourceShape = Literal["tabular", "document", "binary", "directory", "stream"]
DatasourceConsistency = Literal["current", "live", "frozen"]
class DatasourceError(ValueError):
"""Stable base error for provider-neutral datasource operations."""
class DatasourceNotFoundError(DatasourceError):
pass
class DatasourceAccessError(DatasourceError):
pass
class DatasourceValidationError(DatasourceError):
pass
class DatasourceUnavailableError(DatasourceError):
pass
@dataclass(frozen=True, slots=True)
class DatasourceField:
name: str
data_type: str
nullable: bool = True
@dataclass(frozen=True, slots=True)
class DatasourceDescriptor:
ref: str
source_name: str
name: str
kind: DatasourceKind
mode: DatasourceMode
shape: DatasourceShape
status: str = "active"
description: str | None = None
provider: str | None = None
provider_ref: str | None = None
schema: tuple[DatasourceField, ...] = ()
schema_version: str = "1"
fingerprint: str = ""
current_materialization_ref: str | None = None
row_count: int | None = None
byte_count: int | None = None
updated_at: datetime | None = None
capabilities: tuple[str, ...] = ("read",)
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DatasourceMaterialization:
ref: str
datasource_ref: str
revision: int
state: str
fingerprint: str
schema: tuple[DatasourceField, ...] = ()
row_count: int | None = None
byte_count: int | None = None
frozen_at: datetime | None = None
frozen_label: str | None = None
source_timestamp: datetime | None = None
created_at: datetime | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DatasourceStage:
ref: str
name: str
source_name: str
kind: DatasourceKind
mode: DatasourceMode
shape: DatasourceShape
state: str
target_datasource_ref: str | None = None
fingerprint: str = ""
schema: tuple[DatasourceField, ...] = ()
row_count: int | None = None
byte_count: int | None = None
validation: Mapping[str, object] = field(default_factory=dict)
created_at: datetime | None = None
promoted_at: datetime | None = None
promoted_materialization_ref: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DatasourceReadRequest:
datasource_ref: str
materialization_ref: str | None = None
consistency: DatasourceConsistency = "current"
limit: int = 250
offset: int = 0
columns: tuple[str, ...] = ()
expected_fingerprint: str | None = None
@dataclass(frozen=True, slots=True)
class DatasourceReadResult:
datasource: DatasourceDescriptor
rows: tuple[Mapping[str, object], ...]
total_rows: int
truncated: bool
materialization: DatasourceMaterialization | None = None
@dataclass(frozen=True, slots=True)
class DatasourceStageInput:
name: str
source_name: str
kind: DatasourceKind
mode: DatasourceMode
shape: DatasourceShape
rows: tuple[Mapping[str, object], ...]
description: str | None = None
target_datasource_ref: str | None = None
provider: str | None = None
provider_ref: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DatasourcePublicationRequest:
producer_module: str
producer_run_ref: str
idempotency_key: str
rows: tuple[Mapping[str, object], ...]
target_datasource_ref: str | None = None
name: str | None = None
source_name: str | None = None
description: str | None = None
freeze: bool = False
frozen_label: str | None = None
set_current: bool = True
source_timestamp: datetime | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DatasourcePublicationResult:
ref: str
status: str
datasource: DatasourceDescriptor
materialization: DatasourceMaterialization
replayed: bool = False
@dataclass(frozen=True, slots=True)
class DatasourceOrigin:
ref: str
source_name: str
name: str
kind: DatasourceKind
shape: DatasourceShape
supported_modes: tuple[DatasourceMode, ...]
provider: str
description: str | None = None
schema: tuple[DatasourceField, ...] = ()
schema_version: str = "1"
fingerprint: str = ""
row_count: int | None = None
byte_count: int | None = None
updated_at: datetime | None = None
capabilities: tuple[str, ...] = ("read",)
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DatasourceOriginReadRequest:
origin_ref: str
limit: int = 250
offset: int = 0
columns: tuple[str, ...] = ()
expected_fingerprint: str | None = None
@dataclass(frozen=True, slots=True)
class DatasourceOriginReadResult:
origin: DatasourceOrigin
rows: tuple[Mapping[str, object], ...]
total_rows: int
truncated: bool
@runtime_checkable
class DatasourceCatalogueProvider(Protocol):
def list_datasources(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
) -> Sequence[DatasourceDescriptor]:
...
def get_datasource(
self,
session: object,
principal: object,
*,
datasource_ref: str,
) -> DatasourceDescriptor | None:
...
def read_datasource(
self,
session: object,
principal: object,
*,
request: DatasourceReadRequest,
) -> DatasourceReadResult:
...
def list_materializations(
self,
session: object,
principal: object,
*,
datasource_ref: str,
) -> Sequence[DatasourceMaterialization]:
...
@runtime_checkable
class DatasourceLifecycleProvider(Protocol):
def list_stages(
self,
session: object,
principal: object,
*,
limit: int = 100,
) -> Sequence[DatasourceStage]:
...
def create_stage(
self,
session: object,
principal: object,
*,
stage: DatasourceStageInput,
) -> DatasourceStage:
...
def promote_stage(
self,
session: object,
principal: object,
*,
stage_ref: str,
freeze: bool = False,
frozen_label: str | None = None,
) -> tuple[DatasourceDescriptor, DatasourceMaterialization]:
...
def register_origin(
self,
session: object,
principal: object,
*,
origin_ref: str,
name: str,
source_name: str,
mode: DatasourceMode,
description: str | None = None,
) -> DatasourceDescriptor:
...
def refresh_datasource(
self,
session: object,
principal: object,
*,
datasource_ref: str,
) -> tuple[DatasourceDescriptor, DatasourceMaterialization]:
...
def freeze_datasource(
self,
session: object,
principal: object,
*,
datasource_ref: str,
label: str | None = None,
) -> DatasourceMaterialization:
...
def retire_datasource(
self,
session: object,
principal: object,
*,
datasource_ref: str,
) -> DatasourceDescriptor:
...
@runtime_checkable
class DatasourcePublicationProvider(Protocol):
def publish_rows(
self,
session: object,
principal: object,
*,
request: DatasourcePublicationRequest,
) -> DatasourcePublicationResult:
...
@runtime_checkable
class DatasourceOriginProvider(Protocol):
def list_origins(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
) -> Sequence[DatasourceOrigin]:
...
def get_origin(
self,
session: object,
principal: object,
*,
origin_ref: str,
) -> DatasourceOrigin | None:
...
def read_origin(
self,
session: object,
principal: object,
*,
request: DatasourceOriginReadRequest,
) -> DatasourceOriginReadResult:
...
def datasource_catalogue(registry: object | None) -> DatasourceCatalogueProvider | None:
capability = _capability(registry, CAPABILITY_DATASOURCE_CATALOGUE)
return capability if isinstance(capability, DatasourceCatalogueProvider) else None
def datasource_lifecycle(registry: object | None) -> DatasourceLifecycleProvider | None:
capability = _capability(registry, CAPABILITY_DATASOURCE_LIFECYCLE)
return capability if isinstance(capability, DatasourceLifecycleProvider) else None
def datasource_publication(
registry: object | None,
) -> DatasourcePublicationProvider | None:
capability = _capability(registry, CAPABILITY_DATASOURCE_PUBLICATION)
return capability if isinstance(capability, DatasourcePublicationProvider) else None
def datasource_origins(registry: object | None) -> DatasourceOriginProvider | None:
capability = _capability(registry, CAPABILITY_DATASOURCE_ORIGINS)
return capability if isinstance(capability, DatasourceOriginProvider) else None
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
__all__ = [
"CAPABILITY_DATASOURCE_CATALOGUE",
"CAPABILITY_DATASOURCE_LIFECYCLE",
"CAPABILITY_DATASOURCE_ORIGINS",
"DatasourceAccessError",
"DatasourceCatalogueProvider",
"DatasourceConsistency",
"DatasourceDescriptor",
"DatasourceError",
"DatasourceField",
"DatasourceKind",
"DatasourceLifecycleProvider",
"DatasourceMaterialization",
"DatasourceMode",
"DatasourceNotFoundError",
"DatasourceOrigin",
"DatasourceOriginProvider",
"DatasourceOriginReadRequest",
"DatasourceOriginReadResult",
"DatasourceReadRequest",
"DatasourceReadResult",
"DatasourceShape",
"DatasourceStage",
"DatasourceStageInput",
"DatasourceUnavailableError",
"DatasourceValidationError",
"datasource_catalogue",
"datasource_lifecycle",
"datasource_origins",
]
+455
View File
@@ -0,0 +1,455 @@
from __future__ import annotations
from collections import deque
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True, slots=True)
class DefinitionPort:
id: str
label: str
required: bool = True
multiple: bool = False
minimum_connections: int = 1
@dataclass(frozen=True, slots=True)
class DefinitionConfigField:
id: str
label: str
kind: str
required: bool = False
description: str | None = None
options: tuple[tuple[str, str], ...] = ()
@dataclass(frozen=True, slots=True)
class DefinitionNodeType:
type: str
category: str
label: str
description: str
icon: str
input_ports: tuple[DefinitionPort, ...] = ()
output_ports: tuple[DefinitionPort, ...] = (
DefinitionPort(id="output", label="Output"),
)
config_fields: tuple[DefinitionConfigField, ...] = ()
default_config: Mapping[str, Any] = field(default_factory=dict)
metadata: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DefinitionNode:
id: str
type: str
@dataclass(frozen=True, slots=True)
class DefinitionEdge:
id: str
source: str
target: str
source_port: str = "output"
target_port: str = "input"
@dataclass(frozen=True, slots=True)
class DefinitionNodeCountConstraint:
code: str
label: str
minimum: int = 0
maximum: int | None = None
node_types: tuple[str, ...] = ()
type_prefixes: tuple[str, ...] = ()
categories: tuple[str, ...] = ()
def matches(self, node: DefinitionNode, node_type: DefinitionNodeType | None) -> bool:
return (
node.type in self.node_types
or any(node.type.startswith(prefix) for prefix in self.type_prefixes)
or node_type is not None
and node_type.category in self.categories
)
@dataclass(frozen=True, slots=True)
class DefinitionGraphConstraints:
max_nodes: int = 100
max_edges: int = 200
allow_cycles: bool = False
require_connected: bool = True
node_counts: tuple[DefinitionNodeCountConstraint, ...] = ()
@dataclass(frozen=True, slots=True)
class DefinitionGraphLibrary:
id: str
version: str
category_labels: Mapping[str, str]
node_types: tuple[DefinitionNodeType, ...]
constraints: DefinitionGraphConstraints = field(default_factory=DefinitionGraphConstraints)
def node_type(self, type_id: str) -> DefinitionNodeType | None:
return next((item for item in self.node_types if item.type == type_id), None)
@dataclass(frozen=True, slots=True)
class DefinitionDiagnostic:
severity: str
code: str
message: str
node_id: str | None = None
field: str | None = None
def validate_definition_graph(
library: DefinitionGraphLibrary,
*,
nodes: Sequence[DefinitionNode],
edges: Sequence[DefinitionEdge],
) -> tuple[DefinitionDiagnostic, ...]:
if not nodes:
return (_error("graph.empty", "Add nodes before saving the definition."),)
constraints = library.constraints
diagnostics = _graph_size_diagnostics(
node_count=len(nodes),
edge_count=len(edges),
constraints=constraints,
)
node_by_id = {node.id: node for node in nodes}
definitions: dict[str, DefinitionNodeType] = {}
for item in library.node_types:
definitions.setdefault(item.type, item)
diagnostics.extend(_identifier_diagnostics(nodes=nodes, edges=edges))
incoming, undirected, topology_edges, edge_diagnostics = (
_validate_definition_edges(
edges=edges,
node_by_id=node_by_id,
definitions=definitions,
)
)
diagnostics.extend(edge_diagnostics)
diagnostics.extend(
_node_port_diagnostics(
nodes=nodes,
definitions=definitions,
incoming=incoming,
library_id=library.id,
)
)
diagnostics.extend(
_node_count_diagnostics(
nodes=nodes,
definitions=definitions,
constraints=constraints,
)
)
diagnostics.extend(
_topology_diagnostics(
nodes=nodes,
node_by_id=node_by_id,
topology_edges=topology_edges,
undirected=undirected,
constraints=constraints,
)
)
return tuple(diagnostics)
def _graph_size_diagnostics(
*,
node_count: int,
edge_count: int,
constraints: DefinitionGraphConstraints,
) -> list[DefinitionDiagnostic]:
diagnostics: list[DefinitionDiagnostic] = []
if node_count > constraints.max_nodes:
diagnostics.append(
_error(
"graph.node_limit",
f"Definitions are limited to {constraints.max_nodes:,} nodes.",
)
)
if edge_count > constraints.max_edges:
diagnostics.append(
_error(
"graph.edge_limit",
f"Definitions are limited to {constraints.max_edges:,} edges.",
)
)
return diagnostics
def _identifier_diagnostics(
*,
nodes: Sequence[DefinitionNode],
edges: Sequence[DefinitionEdge],
) -> list[DefinitionDiagnostic]:
diagnostics: list[DefinitionDiagnostic] = []
if len({node.id for node in nodes}) != len(nodes):
diagnostics.append(_error("graph.duplicate_node", "Node identifiers must be unique."))
if len({edge.id for edge in edges}) != len(edges):
diagnostics.append(_error("graph.duplicate_edge", "Edge identifiers must be unique."))
return diagnostics
def _validate_definition_edges(
*,
edges: Sequence[DefinitionEdge],
node_by_id: Mapping[str, DefinitionNode],
definitions: Mapping[str, DefinitionNodeType],
) -> tuple[
dict[str, list[DefinitionEdge]],
dict[str, set[str]],
list[DefinitionEdge],
list[DefinitionDiagnostic],
]:
incoming: dict[str, list[DefinitionEdge]] = {node_id: [] for node_id in node_by_id}
undirected: dict[str, set[str]] = {node_id: set() for node_id in node_by_id}
topology_edges: list[DefinitionEdge] = []
diagnostics: list[DefinitionDiagnostic] = []
for edge in edges:
structural_error = _edge_structure_diagnostic(edge, node_by_id)
if structural_error is not None:
diagnostics.append(structural_error)
continue
topology_edges.append(edge)
port_error = _edge_port_diagnostic(
edge,
source_definition=definitions.get(node_by_id[edge.source].type),
target_definition=definitions.get(node_by_id[edge.target].type),
)
if port_error is not None:
diagnostics.append(port_error)
continue
incoming[edge.target].append(edge)
undirected[edge.source].add(edge.target)
undirected[edge.target].add(edge.source)
return incoming, undirected, topology_edges, diagnostics
def _edge_structure_diagnostic(
edge: DefinitionEdge,
node_by_id: Mapping[str, DefinitionNode],
) -> DefinitionDiagnostic | None:
if edge.source not in node_by_id:
return _error(
"edge.unknown_source",
f"Edge {edge.id!r} references an unknown source node.",
)
if edge.target not in node_by_id:
return _error(
"edge.unknown_target",
f"Edge {edge.id!r} references an unknown target node.",
)
if edge.source == edge.target:
return _error(
"edge.self_reference",
"A node cannot connect to itself.",
node_id=edge.source,
)
return None
def _edge_port_diagnostic(
edge: DefinitionEdge,
*,
source_definition: DefinitionNodeType | None,
target_definition: DefinitionNodeType | None,
) -> DefinitionDiagnostic | None:
if source_definition is not None and edge.source_port not in {
port.id for port in source_definition.output_ports
}:
return _error(
"edge.unknown_source_port",
f"Node {edge.source!r} has no output port {edge.source_port!r}.",
node_id=edge.source,
)
if target_definition is not None and edge.target_port not in {
port.id for port in target_definition.input_ports
}:
return _error(
"edge.unknown_target_port",
f"Node {edge.target!r} has no input port {edge.target_port!r}.",
node_id=edge.target,
)
return None
def _node_port_diagnostics(
*,
nodes: Sequence[DefinitionNode],
definitions: Mapping[str, DefinitionNodeType],
incoming: Mapping[str, Sequence[DefinitionEdge]],
library_id: str,
) -> list[DefinitionDiagnostic]:
diagnostics: list[DefinitionDiagnostic] = []
for node in nodes:
definition = definitions.get(node.type)
if definition is None:
diagnostics.append(
_error(
"node.unsupported_type",
f"Node type {node.type!r} is not in the {library_id!r} library.",
node_id=node.id,
field="type",
)
)
continue
for port in definition.input_ports:
connections = [
edge for edge in incoming.get(node.id, ()) if edge.target_port == port.id
]
minimum = port.minimum_connections if port.required else 0
if len(connections) < minimum:
diagnostics.append(
_error(
"node.input_required",
(
f"{definition.label} requires {minimum} "
f"{port.label.lower()} connection"
f"{'' if minimum == 1 else 's'}."
),
node_id=node.id,
)
)
if not port.multiple and len(connections) > 1:
diagnostics.append(
_error(
"node.input_multiple",
f"{port.label} accepts only one connection.",
node_id=node.id,
)
)
return diagnostics
def _node_count_diagnostics(
*,
nodes: Sequence[DefinitionNode],
definitions: Mapping[str, DefinitionNodeType],
constraints: DefinitionGraphConstraints,
) -> list[DefinitionDiagnostic]:
diagnostics: list[DefinitionDiagnostic] = []
for count_constraint in constraints.node_counts:
count = sum(
count_constraint.matches(node, definitions.get(node.type))
for node in nodes
)
if count < count_constraint.minimum:
diagnostics.append(
_error(
count_constraint.code,
(
f"A definition needs at least {count_constraint.minimum} "
f"{count_constraint.label} node"
f"{'' if count_constraint.minimum == 1 else 's'}."
),
)
)
if count_constraint.maximum is not None and count > count_constraint.maximum:
diagnostics.append(
_error(
count_constraint.code,
(
f"A definition allows at most {count_constraint.maximum} "
f"{count_constraint.label} node"
f"{'' if count_constraint.maximum == 1 else 's'}."
),
)
)
return diagnostics
def _topology_diagnostics(
*,
nodes: Sequence[DefinitionNode],
node_by_id: Mapping[str, DefinitionNode],
topology_edges: Sequence[DefinitionEdge],
undirected: Mapping[str, set[str]],
constraints: DefinitionGraphConstraints,
) -> list[DefinitionDiagnostic]:
diagnostics: list[DefinitionDiagnostic] = []
_, cyclic = definition_topological_order(nodes, topology_edges)
if cyclic and not constraints.allow_cycles:
diagnostics.append(_error("graph.cycle", "Definition edges must form an acyclic graph."))
if constraints.require_connected and len(node_by_id) > 1:
connected = _connected_nodes(next(iter(node_by_id)), undirected)
if len(connected) != len(node_by_id):
diagnostics.append(
_error("graph.disconnected", "Every node must belong to one connected definition.")
)
return diagnostics
def definition_topological_order(
nodes: Sequence[DefinitionNode],
edges: Sequence[DefinitionEdge],
) -> tuple[tuple[str, ...], bool]:
node_ids = [node.id for node in nodes]
incoming_count = {node_id: 0 for node_id in node_ids}
outgoing: dict[str, list[str]] = {node_id: [] for node_id in node_ids}
for edge in edges:
if (
edge.source in outgoing
and edge.target in incoming_count
and edge.source != edge.target
):
outgoing[edge.source].append(edge.target)
incoming_count[edge.target] += 1
ready = deque(node_id for node_id in node_ids if incoming_count[node_id] == 0)
ordered: list[str] = []
while ready:
node_id = ready.popleft()
ordered.append(node_id)
for target in outgoing[node_id]:
incoming_count[target] -= 1
if incoming_count[target] == 0:
ready.append(target)
return tuple(ordered), len(ordered) != len(node_ids)
def _connected_nodes(start: str, adjacency: Mapping[str, set[str]]) -> set[str]:
seen: set[str] = set()
pending = [start]
while pending:
node_id = pending.pop()
if node_id in seen:
continue
seen.add(node_id)
pending.extend(adjacency.get(node_id, ()))
return seen
def _error(
code: str,
message: str,
*,
node_id: str | None = None,
field: str | None = None,
) -> DefinitionDiagnostic:
return DefinitionDiagnostic(
severity="error",
code=code,
message=message,
node_id=node_id,
field=field,
)
__all__ = [
"DefinitionConfigField",
"DefinitionDiagnostic",
"DefinitionEdge",
"DefinitionGraphConstraints",
"DefinitionGraphLibrary",
"DefinitionNode",
"DefinitionNodeCountConstraint",
"DefinitionNodeType",
"DefinitionPort",
"definition_topological_order",
"validate_definition_graph",
]
@@ -0,0 +1,402 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
CAPABILITY_DISTRIBUTION_LIST_SOURCE = "dist_lists.source"
CAPABILITY_DISTRIBUTION_LIST_EXPAND = "dist_lists.expand"
CAPABILITY_DISTRIBUTION_LIST_WRITER = "dist_lists.writer"
CAPABILITY_RECIPIENT_CHANNEL_FACTS = "addresses.channel_facts"
CAPABILITY_POLICY_DISTRIBUTION_CHANNELS = "policy.distribution_channels"
DistributionDefinitionKind = Literal["static", "parameterized", "dynamic", "template"]
DistributionEntryMode = Literal["include", "exclude", "override"]
DistributionEntryKind = Literal[
"address_contact",
"address_list",
"address_email",
"raw_email",
"raw_postal_address",
"internal_mail",
"portal",
"idm_identity",
"idm_group",
"organization_unit",
"function",
"effective_function_incumbent",
"dataflow_result",
"distribution_list",
]
DistributionChannel = Literal["email", "postal", "internal_mail", "portal"]
DistributionOutcome = Literal[
"usable",
"unresolved",
"invalid",
"suppressed",
"ambiguous",
"duplicate",
"policy_blocked",
"provider_unavailable",
"stale",
]
DistributionExplanationSeverity = Literal["info", "warning", "error"]
class DistributionListError(ValueError):
"""Stable base error for provider-neutral distribution-list operations."""
class DistributionListNotFoundError(DistributionListError):
pass
class DistributionListConflictError(DistributionListError):
pass
class DistributionListUnavailableError(DistributionListError):
pass
@dataclass(frozen=True, slots=True)
class DistributionSourceReference:
provider: str
resource_type: str
resource_id: str
revision: str | None = None
fingerprint: str | None = None
label: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionExplanation:
code: str
message: str
severity: DistributionExplanationSeverity = "warning"
provider: str | None = None
source: DistributionSourceReference | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionParameterDefinition:
key: str
value_type: Literal[
"string",
"integer",
"number",
"boolean",
"date",
"datetime",
"string_list",
]
label: str | None = None
required: bool = False
default: object | None = None
allowed_values: tuple[object, ...] = ()
minimum: float | None = None
maximum: float | None = None
pattern: str | None = None
description: str | None = None
@dataclass(frozen=True, slots=True)
class DistributionListEntryRef:
id: str
kind: DistributionEntryKind
mode: DistributionEntryMode
source: DistributionSourceReference
label: str | None = None
purpose: str | None = None
requested_channels: tuple[DistributionChannel, ...] = ()
effective_from: datetime | None = None
effective_until: datetime | None = None
order: int = 0
configuration: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionListSourceRef:
id: str
tenant_id: str
name: str
revision_id: str
revision: int
definition_hash: str
definition_kind: DistributionDefinitionKind = "static"
description: str | None = None
status: str = "active"
entry_count: int = 0
read_only: bool = False
stale: bool = False
parameters: tuple[DistributionParameterDefinition, ...] = ()
updated_at: datetime | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionExpansionLimits:
max_entries: int = 500
max_results: int = 5_000
max_depth: int = 8
max_provider_results: int = 2_000
@dataclass(frozen=True, slots=True)
class DistributionExpansionRequest:
list_id: str
revision: int | None = None
effective_at: datetime | None = None
purpose: str | None = None
requested_channels: tuple[DistributionChannel, ...] = ()
parameters: Mapping[str, object] = field(default_factory=dict)
preview: bool = False
freeze: bool = False
idempotency_key: str | None = None
limits: DistributionExpansionLimits = field(
default_factory=DistributionExpansionLimits
)
@dataclass(frozen=True, slots=True)
class DistributionChannelCandidate:
channel: DistributionChannel
target: str
target_key: str
status: DistributionOutcome = "usable"
contact_point_id: str | None = None
locale: str | None = None
preferred: bool = False
reason_code: str | None = None
explanation: str | None = None
source: DistributionSourceReference | None = None
decision_provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionRecipientRef:
recipient_key: str
display_name: str
status: DistributionOutcome
channels: tuple[DistributionChannelCandidate, ...] = ()
identity_id: str | None = None
account_id: str | None = None
contact_id: str | None = None
organization_unit_id: str | None = None
function_id: str | None = None
source_entry_ids: tuple[str, ...] = ()
explanations: tuple[DistributionExplanation, ...] = ()
attributes: Mapping[str, object] = field(default_factory=dict)
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionProviderEvidence:
provider: str
source: DistributionSourceReference
actual_revision: str | None = None
actual_fingerprint: str | None = None
stale: bool = False
generated_at: datetime | None = None
details: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionExpansionResult:
source: DistributionListSourceRef
request: DistributionExpansionRequest
recipients: tuple[DistributionRecipientRef, ...]
excluded: tuple[DistributionRecipientRef, ...] = ()
diagnostics: tuple[DistributionExplanation, ...] = ()
provider_evidence: tuple[DistributionProviderEvidence, ...] = ()
expansion_hash: str = ""
generated_at: datetime | None = None
snapshot_id: str | None = None
stale: bool = False
truncated: bool = False
@dataclass(frozen=True, slots=True)
class DistributionSnapshotRef:
id: str
tenant_id: str
list_id: str
revision_id: str
revision: int
expansion_hash: str
generated_at: datetime
effective_at: datetime
recipient_count: int
excluded_count: int
stale: bool
truncated: bool
request: Mapping[str, object] = field(default_factory=dict)
recipients: tuple[DistributionRecipientRef, ...] = ()
excluded: tuple[DistributionRecipientRef, ...] = ()
diagnostics: tuple[DistributionExplanation, ...] = ()
provider_evidence: tuple[DistributionProviderEvidence, ...] = ()
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionWriteDecision:
list_id: str | None
operation: str
allowed: bool
reason_code: str
explanation: str
read_only: bool = False
required_scopes: tuple[str, ...] = ()
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class RecipientChannelFactsRequest:
tenant_id: str
source: DistributionSourceReference
recipient_key: str
effective_at: datetime
purpose: str | None = None
requested_channels: tuple[DistributionChannel, ...] = ()
context: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class RecipientChannelFacts:
candidates: tuple[DistributionChannelCandidate, ...]
explanations: tuple[DistributionExplanation, ...] = ()
source_revision: str | None = None
source_fingerprint: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionChannelPolicyRequest:
tenant_id: str
list_id: str
purpose: str | None
effective_at: datetime
recipient: DistributionRecipientRef
candidate: DistributionChannelCandidate
context: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionChannelPolicyDecision:
allowed: bool
reason_code: str
explanation: str
source_path: tuple[Mapping[str, object], ...] = ()
requirements: tuple[str, ...] = ()
details: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class DistributionListSourceProvider(Protocol):
def list_sources(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
) -> Sequence[DistributionListSourceRef]: ...
def get_source(
self,
session: object,
principal: object,
*,
list_id: str,
revision: int | None = None,
) -> DistributionListSourceRef | None: ...
@runtime_checkable
class DistributionListExpansionProvider(Protocol):
def expand(
self,
session: object,
principal: object,
*,
request: DistributionExpansionRequest,
) -> DistributionExpansionResult: ...
def get_snapshot(
self,
session: object,
principal: object,
*,
snapshot_id: str,
) -> DistributionSnapshotRef | None: ...
@runtime_checkable
class DistributionListWriter(Protocol):
def explain_write(
self,
session: object,
principal: object,
*,
list_id: str | None,
operation: str,
) -> DistributionWriteDecision: ...
@runtime_checkable
class RecipientChannelFactsProvider(Protocol):
def resolve_channel_facts(
self,
session: object,
principal: object,
*,
request: RecipientChannelFactsRequest,
) -> RecipientChannelFacts: ...
@runtime_checkable
class DistributionChannelPolicyProvider(Protocol):
def resolve_distribution_channel(
self,
session: object,
principal: object,
*,
request: DistributionChannelPolicyRequest,
) -> DistributionChannelPolicyDecision: ...
def distribution_list_source_provider(
registry: object | None,
) -> DistributionListSourceProvider | None:
capability = _capability(registry, CAPABILITY_DISTRIBUTION_LIST_SOURCE)
return capability if isinstance(capability, DistributionListSourceProvider) else None
def distribution_list_expansion_provider(
registry: object | None,
) -> DistributionListExpansionProvider | None:
capability = _capability(registry, CAPABILITY_DISTRIBUTION_LIST_EXPAND)
return (
capability
if isinstance(capability, DistributionListExpansionProvider)
else None
)
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
__all__ = [name for name in globals() if name.startswith("CAPABILITY_") or name.startswith("Distribution") or name.startswith("Recipient") or name.startswith("distribution_")]
+187 -2
View File
@@ -1,17 +1,23 @@
from __future__ import annotations
from collections import defaultdict
from collections.abc import Callable, Mapping
from collections.abc import Callable, Mapping, Sequence
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass, field
from datetime import datetime, timezone
import re
from typing import Any, Literal
from typing import Any, Literal, Protocol, runtime_checkable
import uuid
from sqlalchemy import event as sqlalchemy_event
from sqlalchemy.orm import Session
_TRACE_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
_CONSUMER_ID_RE = re.compile(r"^[a-z][a-z0-9_.:-]{0,127}$")
_PENDING_EVENTS_KEY = "govoplan.pending_platform_events"
CAPABILITY_PLATFORM_EVENT_OUTBOX = "platform.eventOutbox"
def new_event_id() -> str:
@@ -100,6 +106,105 @@ class PlatformEvent:
EventHandler = Callable[[PlatformEvent], None]
DurableEventHandler = Callable[[PlatformEvent, str], None]
@dataclass(frozen=True, slots=True)
class DurableEventConsumer:
"""Allowlisted durable consumer with an explicit disclosure boundary."""
consumer_id: str
handler: DurableEventHandler
event_types: frozenset[str] = field(
default_factory=lambda: frozenset({"*"})
)
classifications: frozenset[EventClassification] = field(
default_factory=lambda: frozenset({"public", "internal"})
)
policy_decision_ref: str | None = None
def __post_init__(self) -> None:
if not _CONSUMER_ID_RE.fullmatch(self.consumer_id):
raise ValueError("Durable event consumer id is invalid")
if not self.event_types or any(
item != "*" and not _TRACE_ID_RE.fullmatch(item)
for item in self.event_types
):
raise ValueError(
"Durable event consumers require valid event-type allowlists"
)
invalid_classifications = set(self.classifications) - {
"public",
"internal",
"confidential",
"restricted",
}
if not self.classifications or invalid_classifications:
raise ValueError(
"Durable event consumer classifications are invalid"
)
if (
self.classifications & {"confidential", "restricted"}
and not normalize_trace_id(self.policy_decision_ref)
):
raise ValueError(
"Confidential or restricted event subscriptions require "
"an explicit policy decision reference"
)
def accepts(self, event: PlatformEvent) -> bool:
return (
event.classification in self.classifications
and (
"*" in self.event_types
or event.type in self.event_types
)
)
def delivery_key(self, event: PlatformEvent) -> str:
return f"{event.event_id}:{self.consumer_id}"
@runtime_checkable
class PlatformEventOutbox(Protocol):
def enqueue(self, session: object, event: PlatformEvent) -> object:
...
def dispatch_pending(
self,
session: object,
*,
consumers: Sequence[DurableEventConsumer] = (),
observer: EventHandler | None = None,
limit: int = 100,
) -> Mapping[str, int]:
...
def replay_delivery(
self,
session: object,
*,
event_id: str,
consumer_id: str,
operator_id: str,
reason: str,
) -> Mapping[str, object]:
...
def purge_terminal(
self,
session: object,
*,
before: datetime,
limit: int = 500,
) -> Mapping[str, int]:
...
def delivery_metrics(
self,
session: object,
) -> Mapping[str, object]:
...
def current_event_trace() -> EventTrace | None:
@@ -189,5 +294,85 @@ def publish_platform_event(event: PlatformEvent) -> None:
platform_event_bus().publish(event)
def platform_event_outbox(
registry: object | None = None,
) -> PlatformEventOutbox | None:
if registry is None:
from govoplan_core.core.runtime import get_registry
registry = get_registry()
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
):
return None
capability = registry.capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
return capability if isinstance(capability, PlatformEventOutbox) else None
def emit_platform_event(
session: Session,
event: PlatformEvent,
*,
registry: object | None = None,
) -> None:
"""Persist an event with its transaction or publish it after commit.
The durable outbox is optional so reduced module combinations remain
usable. Without it, the event is kept on the SQLAlchemy session and only
delivered to the process-local bus after the outer transaction commits.
"""
traced = ensure_event_trace(event)
outbox = platform_event_outbox(registry)
if outbox is not None:
outbox.enqueue(session, traced)
return
transaction = (
session.get_nested_transaction()
or session.get_transaction()
or session.begin()
)
pending_by_transaction = session.info.setdefault(
_PENDING_EVENTS_KEY,
{},
)
pending_by_transaction.setdefault(transaction, []).append(
(platform_event_bus(), traced)
)
@sqlalchemy_event.listens_for(Session, "after_commit")
def _publish_committed_events(session: Session) -> None:
transaction = session.get_nested_transaction() or session.get_transaction()
if transaction is None:
return
pending_by_transaction = session.info.get(_PENDING_EVENTS_KEY)
if not isinstance(pending_by_transaction, dict):
return
pending = pending_by_transaction.pop(transaction, ())
parent = transaction.parent
if parent is not None:
pending_by_transaction.setdefault(parent, []).extend(pending)
else:
for bus, event in pending:
bus.publish(event)
if not pending_by_transaction:
session.info.pop(_PENDING_EVENTS_KEY, None)
@sqlalchemy_event.listens_for(Session, "after_rollback")
def _discard_rolled_back_events(session: Session) -> None:
transaction = session.get_nested_transaction() or session.get_transaction()
pending_by_transaction = session.info.get(_PENDING_EVENTS_KEY)
if transaction is None or not isinstance(pending_by_transaction, dict):
return
pending_by_transaction.pop(transaction, None)
if transaction.parent is None or not pending_by_transaction:
session.info.pop(_PENDING_EVENTS_KEY, None)
def _compact_dict(value: Mapping[str, Any]) -> dict[str, Any]:
return {key: item for key, item in value.items() if item is not None}
@@ -0,0 +1,137 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal
from urllib.parse import urlsplit
IntegrationMaturity = Literal[
"discover",
"link",
"search",
"read",
"publish",
"synchronize",
"migrate",
"replace",
]
INTEGRATION_MATURITY_ORDER: tuple[IntegrationMaturity, ...] = (
"discover",
"link",
"search",
"read",
"publish",
"synchronize",
"migrate",
"replace",
)
class ExternalReferenceValidationError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class ExternalObjectReference:
"""Stable identity and provenance for an object owned by another system."""
system: str
object_type: str
object_id: str
maturity: IntegrationMaturity = "link"
connector_id: str | None = None
canonical_url: str | None = None
version: str | None = None
etag: str | None = None
observed_at: datetime | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
for field_name in ("system", "object_type", "object_id"):
value = str(getattr(self, field_name) or "").strip()
if not value:
raise ExternalReferenceValidationError(
f"External reference {field_name} is required."
)
if len(value) > 255:
raise ExternalReferenceValidationError(
f"External reference {field_name} is limited to 255 characters."
)
object.__setattr__(self, field_name, value)
if self.maturity not in INTEGRATION_MATURITY_ORDER:
raise ExternalReferenceValidationError(
f"Unsupported integration maturity: {self.maturity!r}."
)
if self.connector_id is not None:
connector_id = self.connector_id.strip()
if not connector_id:
raise ExternalReferenceValidationError(
"External reference connector_id cannot be blank."
)
object.__setattr__(self, "connector_id", connector_id)
if self.canonical_url is not None:
object.__setattr__(
self,
"canonical_url",
_validated_reference_url(self.canonical_url),
)
@property
def identity_key(self) -> str:
return f"{self.system}:{self.object_type}:{self.object_id}"
def supports(self, maturity: IntegrationMaturity) -> bool:
return integration_maturity_rank(self.maturity) >= integration_maturity_rank(
maturity
)
def to_dict(self) -> dict[str, object]:
return {
"system": self.system,
"object_type": self.object_type,
"object_id": self.object_id,
"maturity": self.maturity,
"connector_id": self.connector_id,
"canonical_url": self.canonical_url,
"version": self.version,
"etag": self.etag,
"observed_at": (
self.observed_at.isoformat() if self.observed_at is not None else None
),
"metadata": dict(self.metadata),
}
def integration_maturity_rank(maturity: IntegrationMaturity) -> int:
try:
return INTEGRATION_MATURITY_ORDER.index(maturity)
except ValueError as exc:
raise ExternalReferenceValidationError(
f"Unsupported integration maturity: {maturity!r}."
) from exc
def _validated_reference_url(value: str) -> str:
normalized = value.strip()
parsed = urlsplit(normalized)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ExternalReferenceValidationError(
"External reference URLs must use HTTP or HTTPS."
)
if parsed.username is not None or parsed.password is not None:
raise ExternalReferenceValidationError(
"External reference URLs must not contain credentials."
)
return normalized
__all__ = [
"ExternalObjectReference",
"ExternalReferenceValidationError",
"INTEGRATION_MATURITY_ORDER",
"IntegrationMaturity",
"integration_maturity_rank",
]
+91 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
@@ -8,6 +8,8 @@ from typing import Literal, Protocol, runtime_checkable
IDM_MODULE_ID = "idm"
CAPABILITY_IDM_DIRECTORY = "idm.directory"
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS = "idm.function_assignments"
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE = "idm.assignment_lifecycle"
IdmStatus = Literal["active", "inactive", "suspended"]
OrganizationFunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
@@ -30,6 +32,18 @@ class OrganizationFunctionAssignmentRef:
status: IdmStatus = "active"
@dataclass(frozen=True, slots=True)
class OrganizationFunctionIncumbencyRef:
tenant_id: str
function_id: str
assignments: tuple[OrganizationFunctionAssignmentRef, ...] = ()
function_active: bool = True
@property
def vacant(self) -> bool:
return not self.assignments
@runtime_checkable
class IdmDirectory(Protocol):
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
@@ -40,6 +54,7 @@ class IdmDirectory(Protocol):
identity_id: str,
*,
tenant_id: str | None = None,
effective_at: datetime | None = None,
) -> Sequence[OrganizationFunctionAssignmentRef]:
...
@@ -48,5 +63,80 @@ class IdmDirectory(Protocol):
account_id: str,
*,
tenant_id: str | None = None,
effective_at: datetime | None = None,
) -> Sequence[OrganizationFunctionAssignmentRef]:
...
def organization_function_assignments_for_identities(
self,
identity_ids: Sequence[str],
*,
tenant_id: str | None = None,
effective_at: datetime | None = None,
) -> Mapping[str, Sequence[OrganizationFunctionAssignmentRef]]:
...
def organization_function_assignments_for_accounts(
self,
account_ids: Sequence[str],
*,
tenant_id: str | None = None,
effective_at: datetime | None = None,
) -> Mapping[str, Sequence[OrganizationFunctionAssignmentRef]]:
...
@runtime_checkable
class IdmFunctionAssignmentDirectory(Protocol):
"""Reverse lookup for effective incumbency and vacancy decisions."""
def organization_function_assignments_for_function(
self,
function_id: str,
*,
tenant_id: str | None = None,
effective_at: datetime | None = None,
) -> Sequence[OrganizationFunctionAssignmentRef]:
...
def organization_function_incumbencies(
self,
function_ids: Sequence[str],
*,
tenant_id: str,
effective_at: datetime | None = None,
) -> Mapping[str, OrganizationFunctionIncumbencyRef]:
...
@runtime_checkable
class IdmAssignmentLifecycle(Protocol):
"""Worker boundary for time-driven function-assignment transitions."""
def process_expired(
self,
session: object,
*,
tenant_id: str | None = None,
effective_at: datetime | None = None,
limit: int = 100,
) -> Mapping[str, object]:
...
def idm_assignment_lifecycle(
registry: object | None,
) -> IdmAssignmentLifecycle | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE)
):
return None
capability = registry.capability(CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE)
return (
capability
if isinstance(capability, IdmAssignmentLifecycle)
else None
)
+55 -4
View File
@@ -239,10 +239,30 @@ def _validate_cors_settings(env: Mapping[str, str], runtime: _RuntimeProfile, co
def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
storage_backend = _clean(env.get("FILE_STORAGE_BACKEND")) or "local"
deployment_managed_raw = _clean(env.get("FILE_STORAGE_S3_DEPLOYMENT_MANAGED")).lower()
if deployment_managed_raw and deployment_managed_raw not in {"true", "false", "1", "0", "yes", "no", "on", "off"}:
collector.add(
"error",
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
"Managed S3 trust must be an explicit boolean.",
"Set FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false, or let the supported installer manage Garage.",
)
deployment_managed = _truthy(deployment_managed_raw)
if storage_backend == "local":
if deployment_managed:
collector.add(
"error",
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
"Managed S3 trust cannot be enabled for local file storage.",
"Set FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false.",
)
_validate_local_file_storage(env, runtime, collector)
elif storage_backend == "s3":
_validate_s3_file_storage(env, collector)
_validate_s3_file_storage(
env,
collector,
deployment_managed=deployment_managed,
)
else:
collector.add("error", "FILE_STORAGE_BACKEND", f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.", "Use `local` or `s3`.")
@@ -254,10 +274,25 @@ def _validate_local_file_storage(env: Mapping[str, str], runtime: _RuntimeProfil
collector.add("warning", "FILE_STORAGE_BACKEND", "Production is configured for local file storage.", "Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.")
def _validate_s3_file_storage(env: Mapping[str, str], collector: _ConfigIssueCollector) -> None:
def _validate_s3_file_storage(
env: Mapping[str, str],
collector: _ConfigIssueCollector,
*,
deployment_managed: bool,
) -> None:
for key in ("FILE_STORAGE_S3_ENDPOINT_URL", "FILE_STORAGE_S3_REGION", "FILE_STORAGE_S3_ACCESS_KEY_ID", "FILE_STORAGE_S3_SECRET_ACCESS_KEY", "FILE_STORAGE_S3_BUCKET"):
if not _clean(env.get(key)):
collector.add("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
if (
deployment_managed
and _clean(env.get("FILE_STORAGE_S3_ENDPOINT_URL")) != "http://garage:3900"
):
collector.add(
"error",
"FILE_STORAGE_S3_ENDPOINT_URL",
"Installer-managed S3 trust is restricted to http://garage:3900.",
"Use the exact managed Garage endpoint or disable FILE_STORAGE_S3_DEPLOYMENT_MANAGED.",
)
def _validate_outbound_connector_policy(
@@ -338,8 +373,11 @@ ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,aud
CELERY_ENABLED=true
REDIS_URL=redis://127.0.0.1:6379/0
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
CELERY_QUEUES=send_email,append_sent,notifications,calendar,dataflow,workflow,events,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
# Deployment-wide connector egress policy. Enable private networks only when
# this installation intentionally integrates with internal services.
@@ -370,6 +408,11 @@ AUTH_COOKIE_DOMAIN=
FILE_STORAGE_BACKEND=local
FILE_STORAGE_LOCAL_ROOT=/var/lib/govoplan/files
FILE_STORAGE_LOCAL_FALLBACK_ROOTS=
FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false
FILE_ARCHIVE_MAX_ENTRIES=10000
FILE_ARCHIVE_MAX_EXPANDED_BYTES=2147483648
FILE_ARCHIVE_MAX_EXPANSION_RATIO=100
FILE_ARCHIVE_PREVIEW_TTL_SECONDS=1800
DEV_AUTO_MIGRATE_ENABLED=false
DEV_BOOTSTRAP_ENABLED=false
@@ -403,8 +446,11 @@ DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
REDIS_URL=redis://127.0.0.1:56379/0
CELERY_ENABLED=true
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
CELERY_QUEUES=send_email,append_sent,notifications,calendar,dataflow,workflow,events,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
@@ -427,6 +473,11 @@ FORWARDED_ALLOW_IPS=127.0.0.1
AUTH_COOKIE_SECURE=false
FILE_STORAGE_BACKEND=local
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false
FILE_ARCHIVE_MAX_ENTRIES=10000
FILE_ARCHIVE_MAX_EXPANDED_BYTES=2147483648
FILE_ARCHIVE_MAX_EXPANSION_RATIO=100
FILE_ARCHIVE_PREVIEW_TTL_SECONDS=1800
DEV_AUTO_MIGRATE_ENABLED=false
DEV_BOOTSTRAP_ENABLED=true
"""
+34
View File
@@ -10,6 +10,9 @@ from govoplan_core.core.module_management import ModuleManagementError, REQUIRED
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import configure_runtime
from govoplan_core.core.workflows import (
workflow_definition_contribution_provider,
)
from govoplan_core.server.route_validation import validate_router_can_mount
@@ -67,6 +70,17 @@ class ModuleLifecycleManager:
def mounted_module_ids(self) -> tuple[str, ...]:
return tuple(sorted(self._mounted_modules))
def live_apply_enabled(self) -> bool:
configured = getattr(
self.settings,
"module_live_apply_enabled",
None,
)
if configured is not None:
return bool(configured)
app_env = str(getattr(self.settings, "app_env", "dev")).casefold()
return app_env in {"dev", "development", "local", "test", "testing"}
def apply_enabled_modules(
self,
requested_enabled: Sequence[str],
@@ -105,6 +119,8 @@ class ModuleLifecycleManager:
if hook is not None:
hook(self.context)
self.reconcile_workflow_definitions()
if self._app is not None:
self._app.openapi_schema = None
@@ -116,6 +132,24 @@ class ModuleLifecycleManager:
migrations_applied=migrate,
)
def reconcile_workflow_definitions(self) -> Mapping[str, object]:
if not any(
manifest.workflow_definitions for manifest in self.registry.manifests()
):
return {"skipped": True, "reason": "no_contributions"}
provider = workflow_definition_contribution_provider(self.registry)
if provider is None:
return {"skipped": True, "reason": "provider_unavailable"}
from govoplan_core.db.session import get_database
with get_database().session() as session:
result = provider.reconcile(session)
session.commit()
if self._app is not None:
self._app.state.govoplan_workflow_reconciliation = dict(result)
return dict(result)
def _mount_module_router(self, module_id: str) -> bool:
if module_id in self._mounted_modules:
return False
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable
CAPABILITY_MAIL_DELIVERY_OUTBOX = "mail.delivery_outbox"
CAPABILITY_MAIL_NOTIFICATION_DELIVERY = "mail.notificationDelivery"
@dataclass(frozen=True, slots=True)
class NotificationMailDeliveryRequest:
tenant_id: str
notification_id: str
recipient: str
subject: str
body_text: str
body_html: str | None = None
action_url: str | None = None
mail_profile_id: str | None = None
from_address: str | None = None
smtp_server_id: str | None = None
smtp_credential_id: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class NotificationMailDeliveryProvider(Protocol):
"""Mail-owned durable submission boundary for notification email."""
def submit_notification_mail(
self,
session: object,
request: NotificationMailDeliveryRequest,
) -> Mapping[str, object]:
...
@runtime_checkable
class MailDeliveryOutboxProvider(Protocol):
"""Stable worker boundary for Mail-owned external delivery effects."""
def dispatch_due(
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 25,
worker_id: str | None = None,
) -> Mapping[str, object]:
...
def purge_expired(
self,
session: object,
*,
limit: int = 250,
) -> Mapping[str, object]:
...
def notification_mail_delivery_provider(
registry: object | None,
) -> NotificationMailDeliveryProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_MAIL_NOTIFICATION_DELIVERY)
):
return None
provider = registry.require_capability(CAPABILITY_MAIL_NOTIFICATION_DELIVERY)
if not isinstance(provider, NotificationMailDeliveryProvider):
raise TypeError(
"mail.notificationDelivery provider does not implement "
"NotificationMailDeliveryProvider"
)
return provider
@@ -3328,12 +3328,6 @@ def _wait_for_health_urls(urls: Iterable[str], *, timeout_seconds: float, interv
return payload
def _run_restart_command_legacy(command: str | None) -> dict[str, object] | None:
if not command:
return None
return _run_restart_command(command)
def _planned_python_install_packages(record: Mapping[str, object]) -> tuple[str, ...]:
raw_plan = record.get("plan")
if not isinstance(raw_plan, list):
+136 -32
View File
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from govoplan_core.admin.models import SystemSettings
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID, get_system_settings
from govoplan_core.core.access import DEFAULT_CAPABILITY_PROVIDERS
from govoplan_core.core.discovery import iter_module_entry_points
from govoplan_core.core.modules import ModuleManifest
from govoplan_core.db.session import get_database
@@ -17,8 +18,8 @@ from govoplan_core.server.registry import parse_enabled_modules
MODULE_SETTINGS_KEY = "module_management"
INSTALL_PLAN_KEY = "install_plan"
REQUIRED_PLATFORM_MODULES = ("access",)
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin")
REQUIRED_PLATFORM_MODULES: tuple[str, ...] = ()
PROTECTED_MODULES = ("admin",)
INSTALL_PLAN_ACTIONS = ("install", "update", "uninstall")
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
INSTALL_PLAN_SOURCES = ("manual", "catalog")
@@ -109,6 +110,7 @@ def startup_candidate_module_ids(
if desired is not None:
candidates.extend(str(item).strip() for item in desired if str(item).strip())
candidates.extend(REQUIRED_PLATFORM_MODULES)
candidates.extend(DEFAULT_CAPABILITY_PROVIDERS.values())
if "admin" in fallback:
candidates.append("admin")
return tuple(dict.fromkeys(candidates))
@@ -404,46 +406,148 @@ def plan_desired_enabled_modules(
*,
protected_modules: Iterable[str] = PROTECTED_MODULES,
) -> ModuleStatePlan:
requested = {str(item).strip() for item in requested_enabled if str(item).strip()}
protected = {str(item).strip() for item in protected_modules if str(item).strip()}
requested.update(protected)
requested = _normalized_module_ids(requested_enabled)
requested.update(_normalized_module_ids(protected_modules))
missing = sorted(module_id for module_id in requested if module_id not in available)
if missing:
raise ModuleManagementError("Unknown or uninstalled modules: " + ", ".join(missing))
added_dependencies: set[str] = set()
visiting: set[str] = set()
visited: set[str] = set()
ordered: list[str] = []
def visit(module_id: str) -> None:
if module_id in visited:
return
if module_id in visiting:
raise ModuleManagementError(f"Module dependency cycle includes {module_id!r}.")
manifest = available.get(module_id)
if manifest is None:
raise ModuleManagementError(f"Module {module_id!r} is required but not installed.")
visiting.add(module_id)
for dependency_id in manifest.dependencies:
if dependency_id not in available:
raise ModuleManagementError(f"Module {module_id!r} depends on uninstalled module {dependency_id!r}.")
if dependency_id not in requested:
added_dependencies.add(dependency_id)
requested.add(dependency_id)
visit(dependency_id)
visiting.remove(module_id)
visited.add(module_id)
ordered.append(module_id)
for module_id in sorted(requested):
visit(module_id)
added_dependencies = _expand_enabled_module_closure(requested, available)
ordered = _order_enabled_modules(requested, available, added_dependencies)
return ModuleStatePlan(
enabled_modules=tuple(dict.fromkeys(ordered)),
added_dependencies=tuple(sorted(added_dependencies)),
)
def _normalized_module_ids(values: Iterable[str]) -> set[str]:
return {str(item).strip() for item in values if str(item).strip()}
def _expand_enabled_module_closure(
requested: set[str],
available: Mapping[str, ModuleManifest],
) -> set[str]:
added: set[str] = set()
while True:
dependencies = {
dependency
for module_id in requested
for dependency in available[module_id].dependencies
}
_require_installed_modules(
dependencies,
available,
message="Required modules are not installed: ",
)
dependency_additions = dependencies - requested
added.update(dependency_additions)
requested.update(dependency_additions)
providers = _missing_capability_providers(requested, available)
_require_installed_modules(
providers,
available,
message="Required capability providers are not installed: ",
)
additions = providers - requested
if not additions:
return added
added.update(additions)
requested.update(additions)
def _missing_capability_providers(
requested: set[str],
available: Mapping[str, ModuleManifest],
) -> set[str]:
provided = {
capability
for module_id in requested
for capability in available[module_id].capability_factories
}
return {
provider_id
for module_id in requested
for capability in available[module_id].required_capabilities
if capability not in provided
for provider_id in (DEFAULT_CAPABILITY_PROVIDERS.get(capability),)
if provider_id is not None
}
def _require_installed_modules(
module_ids: set[str],
available: Mapping[str, ModuleManifest],
*,
message: str,
) -> None:
missing = sorted(module_id for module_id in module_ids if module_id not in available)
if missing:
raise ModuleManagementError(message + ", ".join(missing))
def _order_enabled_modules(
requested: set[str],
available: Mapping[str, ModuleManifest],
added_dependencies: set[str],
) -> list[str]:
visiting: set[str] = set()
visited: set[str] = set()
ordered: list[str] = []
for module_id in sorted(requested):
_visit_enabled_module(
module_id,
requested=requested,
available=available,
added_dependencies=added_dependencies,
visiting=visiting,
visited=visited,
ordered=ordered,
)
return ordered
def _visit_enabled_module(
module_id: str,
*,
requested: set[str],
available: Mapping[str, ModuleManifest],
added_dependencies: set[str],
visiting: set[str],
visited: set[str],
ordered: list[str],
) -> None:
if module_id in visited:
return
if module_id in visiting:
raise ModuleManagementError(f"Module dependency cycle includes {module_id!r}.")
manifest = available.get(module_id)
if manifest is None:
raise ModuleManagementError(f"Module {module_id!r} is required but not installed.")
visiting.add(module_id)
for dependency_id in manifest.dependencies:
if dependency_id not in available:
raise ModuleManagementError(
f"Module {module_id!r} depends on uninstalled module {dependency_id!r}."
)
if dependency_id not in requested:
added_dependencies.add(dependency_id)
requested.add(dependency_id)
_visit_enabled_module(
dependency_id,
requested=requested,
available=available,
added_dependencies=added_dependencies,
visiting=visiting,
visited=visited,
ordered=ordered,
)
visiting.remove(module_id)
visited.add(module_id)
ordered.append(module_id)
def module_dependents(available: Mapping[str, ModuleManifest]) -> dict[str, tuple[str, ...]]:
dependents: dict[str, list[str]] = {module_id: [] for module_id in available}
for manifest in available.values():
+130
View File
@@ -4,8 +4,16 @@ from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any, Literal, Protocol, TYPE_CHECKING
from govoplan_core.core.ownership import OwnershipProviderRegistration
from govoplan_core.core.views import ViewSurface
if TYPE_CHECKING:
from fastapi import APIRouter
from govoplan_core.core.search import (
SearchProviderRegistration,
SearchSourceProviderRegistration,
)
from govoplan_core.core.workflows import WorkflowDefinitionContribution
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
@@ -45,6 +53,7 @@ class RoleTemplate:
level: PermissionLevel = "tenant"
managed: bool = True
protected: bool = False
default_authenticated: bool = False
@dataclass(frozen=True, slots=True)
@@ -56,6 +65,7 @@ class NavItem:
required_all: tuple[str, ...] = ()
required_any: tuple[str, ...] = ()
order: int = 100
surface_id: str | None = None
@@ -67,6 +77,16 @@ class FrontendRoute:
required_all: tuple[str, ...] = ()
required_any: tuple[str, ...] = ()
order: int = 100
surface_id: str | None = None
@dataclass(frozen=True, slots=True)
class PublicFrontendRoute:
"""Explicitly allowlisted route that can render without authentication."""
path: str
component: str
order: int = 100
@dataclass(frozen=True, slots=True)
@@ -79,8 +99,10 @@ class FrontendModule:
asset_manifest_integrity: str | None = None
asset_manifest_contract_version: str = "1"
routes: tuple[FrontendRoute, ...] = ()
public_routes: tuple[PublicFrontendRoute, ...] = ()
nav_items: tuple[NavItem, ...] = ()
settings_routes: tuple[FrontendRoute, ...] = ()
view_surfaces: tuple[ViewSurface, ...] = ()
@dataclass(frozen=True, slots=True)
@@ -195,6 +217,19 @@ class ModuleContext:
DocumentationLayer = Literal["always", "configured", "available", "evidence"]
DocumentationLinkKind = Literal["runtime", "api", "repository", "wiki", "public"]
DocumentationType = Literal["admin", "user"]
DocumentationConfigurationState = Literal["enabled", "disabled", "inherited", "unavailable"]
DocumentationSourceKind = Literal[
"manifest",
"route",
"capability",
"policy",
"release_catalog",
"configuration_package",
"wiki",
"repository",
]
DocumentationSourceState = Literal["configured", "disabled", "unavailable"]
CapabilityStability = Literal["experimental", "stable", "deprecated"]
@dataclass(frozen=True, slots=True)
@@ -236,6 +271,35 @@ class DocumentationTopic:
metadata: Mapping[str, Any] = field(default_factory=dict)
def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str, ...]:
"""Return fail-closed authoring issues for a user-facing workflow topic.
Topic conditions are alternatives. Every alternative therefore needs an
explicit scope constraint; otherwise one unscoped alternative would make
the workflow visible regardless of the actor's task authority.
"""
raw_kind = topic.metadata.get("kind")
kind = raw_kind.strip().lower().replace("_", "-") if isinstance(raw_kind, str) else ""
if kind != "workflow" or "user" not in topic.documentation_types:
return ()
if not topic.conditions:
return ("user workflow topics must declare at least one scope-conditioned alternative",)
unscoped_alternatives = tuple(
index
for index, condition in enumerate(topic.conditions, start=1)
if not any(scope.strip() for scope in (*condition.required_scopes, *condition.any_scopes))
)
if not unscoped_alternatives:
return ()
alternatives = ", ".join(str(index) for index in unscoped_alternatives)
return (
"every user workflow condition alternative must declare required_scopes or any_scopes; "
f"unscoped alternative(s): {alternatives}",
)
@dataclass(frozen=True, slots=True)
class DocumentationContext:
registry: object
@@ -247,6 +311,53 @@ class DocumentationContext:
data: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DocumentationConfigurationDecision:
key: str
state: DocumentationConfigurationState
source: str | None = None
reason: str | None = None
DocumentationConfigurationResolver = Callable[
[DocumentationContext, tuple[str, ...]],
Mapping[str, DocumentationConfigurationDecision],
]
@dataclass(frozen=True, slots=True)
class DocumentationConfigurationProviderRegistration:
keys: tuple[str, ...]
resolve: DocumentationConfigurationResolver
@dataclass(frozen=True, slots=True)
class DocumentationSourceDefinition:
id: str
kind: DocumentationSourceKind
label: str
documentation_types: tuple[DocumentationType, ...] = ("admin",)
condition: DocumentationCondition = field(default_factory=DocumentationCondition)
state: DocumentationSourceState = "configured"
state_reason: str | None = None
provenance: Mapping[str, Any] = field(default_factory=dict)
inspection: Mapping[str, Any] = field(default_factory=dict)
link: DocumentationLink | None = None
configuration_key: str | None = None
@dataclass(frozen=True, slots=True)
class CapabilityDocumentation:
"""Provider-owned capability metadata safe for generic platform consumers."""
label: str
summary: str
contract_version: str | None = None
stability: CapabilityStability = "stable"
documentation_types: tuple[DocumentationType, ...] = ("admin",)
audience: tuple[str, ...] = ()
class ResourceAclProvider(Protocol):
resource_type: str
@@ -261,6 +372,10 @@ class ResourceAclProvider(Protocol):
TenantSummaryProvider = Callable[[object, str], Mapping[str, int]]
TenantSummaryBatchProvider = Callable[
[object, Sequence[str]],
Mapping[str, Mapping[str, int]],
]
@dataclass(frozen=True, slots=True)
@@ -307,12 +422,27 @@ class ModuleManifest:
nav_items: tuple[NavItem, ...] = ()
frontend: FrontendModule | None = None
resource_acl_providers: tuple[ResourceAclProvider, ...] = ()
ownership_providers: tuple[OwnershipProviderRegistration, ...] = ()
tenant_summary_providers: tuple[TenantSummaryProvider, ...] = ()
tenant_summary_batch_providers: tuple[TenantSummaryBatchProvider, ...] = ()
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = ()
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
capability_documentation: Mapping[str, CapabilityDocumentation] = field(default_factory=dict)
search_providers: tuple["SearchProviderRegistration", ...] = ()
search_sources: tuple["SearchSourceProviderRegistration", ...] = ()
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
on_activate: LifecycleHook | None = None
on_deactivate: LifecycleHook | None = None
documentation: tuple[DocumentationTopic, ...] = ()
documentation_providers: tuple[DocumentationProvider, ...] = ()
documentation_configuration_providers: tuple[
DocumentationConfigurationProviderRegistration,
...,
] = ()
documentation_sources: tuple[DocumentationSourceDefinition, ...] = ()
# A renamed or extracted module may continue to own an established
# permission namespace. This keeps persisted grants stable while the
# runtime module ID changes.
permission_namespace: str | None = None
workflow_definitions: tuple["WorkflowDefinitionContribution", ...] = ()
+336 -2
View File
@@ -1,14 +1,58 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
ORGANIZATIONS_MODULE_ID = "organizations"
CAPABILITY_ORGANIZATION_DIRECTORY = "organizations.directory"
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY = (
"organizations.hierarchyDirectory"
)
OrganizationStatus = Literal["active", "inactive", "suspended"]
OrganizationResolutionStatus = Literal[
"active",
"inactive",
"missing",
"unreachable",
"invalid",
]
OrganizationHierarchyDirection = Literal["ancestors", "descendants"]
OrganizationLifecycleResource = Literal[
"unit_type",
"structure",
"relation_type",
"unit",
"relation",
"function_type",
"function",
]
OrganizationLifecycleAction = Literal[
"created",
"updated",
"moved",
"deactivated",
]
ORGANIZATION_LIFECYCLE_EVENT_SCHEMA_VERSION = 1
ORGANIZATION_LIFECYCLE_RESOURCES: tuple[
OrganizationLifecycleResource,
...,
] = (
"unit_type",
"structure",
"relation_type",
"unit",
"relation",
"function_type",
"function",
)
ORGANIZATION_LIFECYCLE_ACTIONS: tuple[
OrganizationLifecycleAction,
...,
] = ("created", "updated", "moved", "deactivated")
@dataclass(frozen=True, slots=True)
@@ -35,6 +79,143 @@ class OrganizationFunctionRef:
delegable: bool = False
act_in_place_allowed: bool = False
status: OrganizationStatus = "active"
settings: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class OrganizationUnitTypeRef:
id: str
tenant_id: str
slug: str
name: str
description: str | None = None
status: OrganizationStatus = "active"
@dataclass(frozen=True, slots=True)
class OrganizationFunctionTypeRef:
id: str
tenant_id: str
slug: str
name: str
organization_unit_type_id: str | None = None
description: str | None = None
delegable: bool = False
act_in_place_allowed: bool = False
status: OrganizationStatus = "active"
settings: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class OrganizationStructureRef:
id: str
tenant_id: str
slug: str
name: str
structure_kind: str
description: str | None = None
status: OrganizationStatus = "active"
@dataclass(frozen=True, slots=True)
class OrganizationRelationTypeRef:
id: str
tenant_id: str
slug: str
name: str
structure_id: str | None = None
source_unit_type_id: str | None = None
target_unit_type_id: str | None = None
is_hierarchical: bool = True
allow_cycles: bool = False
description: str | None = None
status: OrganizationStatus = "active"
@dataclass(frozen=True, slots=True)
class OrganizationHierarchyCatalogRef:
tenant_id: str
structures: tuple[OrganizationStructureRef, ...] = ()
relation_types: tuple[OrganizationRelationTypeRef, ...] = ()
@dataclass(frozen=True, slots=True)
class OrganizationHierarchyEdgeRef:
id: str
tenant_id: str
structure: OrganizationStructureRef
relation_type: OrganizationRelationTypeRef
source_unit_id: str
target_unit_id: str
valid_from: datetime | None = None
valid_until: datetime | None = None
status: OrganizationStatus = "active"
@dataclass(frozen=True, slots=True)
class OrganizationHierarchyMatchRef:
unit: OrganizationUnitRef
depth: int
path: tuple[OrganizationHierarchyEdgeRef, ...]
@dataclass(frozen=True, slots=True)
class OrganizationHierarchyResolution:
tenant_id: str
root_unit_id: str
direction: OrganizationHierarchyDirection
structure_id: str
relation_type_ids: tuple[str, ...]
max_depth: int
status: OrganizationResolutionStatus
root: OrganizationUnitRef | None = None
matches: tuple[OrganizationHierarchyMatchRef, ...] = ()
cycle_detected: bool = False
depth_limited: bool = False
diagnostics: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class OrganizationHierarchyPathResolution:
tenant_id: str
source_unit_id: str
target_unit_id: str
direction: OrganizationHierarchyDirection
structure_id: str
relation_type_ids: tuple[str, ...]
max_depth: int
status: OrganizationResolutionStatus
source: OrganizationUnitRef | None = None
target: OrganizationUnitRef | None = None
path: tuple[OrganizationHierarchyEdgeRef, ...] = ()
cycle_detected: bool = False
depth_limited: bool = False
diagnostics: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class OrganizationFunctionTypeResolution:
tenant_id: str
function_type_id: str
requested_unit_ids: tuple[str, ...]
status: OrganizationResolutionStatus
function_type: OrganizationFunctionTypeRef | None = None
matches: tuple[OrganizationFunctionRef, ...] = ()
missing_unit_ids: tuple[str, ...] = ()
inactive_unit_ids: tuple[str, ...] = ()
diagnostics: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class OrganizationUnitTypeResolution:
tenant_id: str
unit_type_id: str
status: OrganizationResolutionStatus
unit_type: OrganizationUnitTypeRef | None = None
structure_id: str | None = None
root_unit_id: str | None = None
matches: tuple[OrganizationUnitRef, ...] = ()
diagnostics: tuple[str, ...] = ()
@runtime_checkable
@@ -55,3 +236,156 @@ class OrganizationDirectory(Protocol):
include_subunits: bool = False,
) -> Sequence[OrganizationFunctionRef]:
...
@runtime_checkable
class OrganizationHierarchyDirectory(Protocol):
def hierarchy_catalog(
self,
tenant_id: str,
) -> OrganizationHierarchyCatalogRef:
...
def get_unit_type(
self,
tenant_id: str,
unit_type_id: str,
) -> OrganizationUnitTypeRef | None:
...
def get_function_type(
self,
tenant_id: str,
function_type_id: str,
) -> OrganizationFunctionTypeRef | None:
...
def resolve_functions_by_type(
self,
tenant_id: str,
function_type_id: str,
*,
organization_unit_ids: Sequence[str] = (),
) -> OrganizationFunctionTypeResolution:
...
def resolve_units_by_type(
self,
tenant_id: str,
unit_type_id: str,
*,
structure_id: str | None = None,
root_unit_id: str | None = None,
relation_type_ids: Sequence[str] = (),
direction: OrganizationHierarchyDirection = "descendants",
max_depth: int = 10,
) -> OrganizationUnitTypeResolution:
...
def resolve_hierarchy_relatives(
self,
tenant_id: str,
organization_unit_ids: Sequence[str],
*,
structure_id: str,
relation_type_ids: Sequence[str] = (),
direction: OrganizationHierarchyDirection = "ancestors",
max_depth: int = 10,
) -> Sequence[OrganizationHierarchyResolution]:
...
def resolve_hierarchy_paths(
self,
tenant_id: str,
unit_pairs: Sequence[tuple[str, str]],
*,
structure_id: str,
relation_type_ids: Sequence[str] = (),
direction: OrganizationHierarchyDirection = "descendants",
max_depth: int = 10,
) -> Sequence[OrganizationHierarchyPathResolution]:
...
def organization_directory(
registry: object | None,
) -> OrganizationDirectory | None:
capability = _capability(registry, CAPABILITY_ORGANIZATION_DIRECTORY)
return capability if isinstance(capability, OrganizationDirectory) else None
def organization_hierarchy_directory(
registry: object | None,
) -> OrganizationHierarchyDirectory | None:
capability = _capability(
registry,
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY,
)
return (
capability
if isinstance(capability, OrganizationHierarchyDirectory)
else None
)
def organization_lifecycle_event_type(
resource: OrganizationLifecycleResource,
action: OrganizationLifecycleAction,
) -> str:
if resource not in ORGANIZATION_LIFECYCLE_RESOURCES:
raise ValueError("Unsupported organization lifecycle resource.")
if action not in ORGANIZATION_LIFECYCLE_ACTIONS:
raise ValueError("Unsupported organization lifecycle action.")
return f"organizations.{resource}.{action}.v1"
ORGANIZATION_DIRECTORY_INVALIDATION_EVENT_TYPES = frozenset(
organization_lifecycle_event_type(resource, action)
for resource in ORGANIZATION_LIFECYCLE_RESOURCES
for action in ORGANIZATION_LIFECYCLE_ACTIONS
)
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
__all__ = [
"CAPABILITY_ORGANIZATION_DIRECTORY",
"CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY",
"ORGANIZATION_DIRECTORY_INVALIDATION_EVENT_TYPES",
"ORGANIZATION_LIFECYCLE_ACTIONS",
"ORGANIZATION_LIFECYCLE_EVENT_SCHEMA_VERSION",
"ORGANIZATION_LIFECYCLE_RESOURCES",
"ORGANIZATIONS_MODULE_ID",
"OrganizationDirectory",
"OrganizationFunctionRef",
"OrganizationFunctionTypeRef",
"OrganizationFunctionTypeResolution",
"OrganizationHierarchyDirection",
"OrganizationHierarchyCatalogRef",
"OrganizationHierarchyDirectory",
"OrganizationHierarchyEdgeRef",
"OrganizationHierarchyMatchRef",
"OrganizationHierarchyPathResolution",
"OrganizationHierarchyResolution",
"OrganizationLifecycleAction",
"OrganizationLifecycleResource",
"OrganizationRelationTypeRef",
"OrganizationResolutionStatus",
"OrganizationStatus",
"OrganizationStructureRef",
"OrganizationUnitRef",
"OrganizationUnitTypeRef",
"OrganizationUnitTypeResolution",
"organization_directory",
"organization_hierarchy_directory",
"organization_lifecycle_event_type",
]
+965
View File
@@ -0,0 +1,965 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import StrEnum
import hashlib
import json
from typing import Any, Protocol, runtime_checkable
import uuid
from sqlalchemy import DateTime, Index, Integer, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, Session, mapped_column
from govoplan_core.db.base import Base, TimestampMixin, utcnow
class OwnershipTransferKind(StrEnum):
OWNER_INITIATED = "owner_initiated"
TARGET_REQUESTED = "target_requested"
ADMINISTRATIVE_RECOVERY = "administrative_recovery"
class OwnershipTransferStatus(StrEnum):
AWAITING_OWNER_APPROVAL = "awaiting_owner_approval"
AWAITING_TARGET_ACCEPTANCE = "awaiting_target_acceptance"
AWAITING_RECOVERY_APPROVALS = "awaiting_recovery_approvals"
RECOVERY_SCHEDULED = "recovery_scheduled"
COMPLETED = "completed"
DECLINED = "declined"
CANCELLED = "cancelled"
EXPIRED = "expired"
TERMINAL_OWNERSHIP_TRANSFER_STATUSES = frozenset(
{
OwnershipTransferStatus.COMPLETED.value,
OwnershipTransferStatus.DECLINED.value,
OwnershipTransferStatus.CANCELLED.value,
OwnershipTransferStatus.EXPIRED.value,
}
)
class OwnershipTransferError(ValueError):
pass
class OwnershipAuthorizationError(PermissionError):
pass
class OwnershipIdempotencyConflict(OwnershipTransferError):
pass
class OwnershipTransferExpired(OwnershipTransferError):
pass
@dataclass(frozen=True, slots=True)
class OwnershipSubjectRef:
type: str
id: str
label: str | None = None
scopes: frozenset[str] = frozenset()
group_ids: frozenset[str] = frozenset()
recently_authenticated: bool = False
def __post_init__(self) -> None:
if not self.type.strip() or not self.id.strip():
raise ValueError("Ownership subjects require a type and id")
@dataclass(frozen=True, slots=True)
class OwnershipResourceRef:
module_id: str
resource_type: str
resource_id: str
def __post_init__(self) -> None:
if not self.module_id.strip() or not self.resource_type.strip() or not self.resource_id.strip():
raise ValueError("Ownership resources require module, type, and id")
@dataclass(frozen=True, slots=True)
class OwnershipActionDecision:
allowed: bool
reason: str | None = None
requirements: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class OwnershipTransferPolicy:
default_expiry_days: int = 7
min_expiry_days: int = 1
max_expiry_days: int = 30
recovery_assurance_profile: str = "standard"
recovery_required_approvals: int = 2
recovery_delay_hours: int = 24
recent_authentication_required: bool = True
def __post_init__(self) -> None:
if not 1 <= self.min_expiry_days <= self.default_expiry_days <= self.max_expiry_days:
raise ValueError("Ownership transfer expiry policy is inconsistent")
if self.recovery_required_approvals < 1:
raise ValueError("Ownership recovery requires at least one approval")
if self.recovery_delay_hours < 0:
raise ValueError("Ownership recovery delay cannot be negative")
@classmethod
def development(cls) -> OwnershipTransferPolicy:
return cls(
recovery_assurance_profile="development-single-admin",
recovery_required_approvals=1,
recovery_delay_hours=0,
recent_authentication_required=False,
)
@runtime_checkable
class ResourceOwnershipProvider(Protocol):
def current_owner(
self,
session: object,
*,
tenant_id: str,
resource_id: str,
) -> OwnershipSubjectRef | None:
...
def authorize_ownership_action(
self,
session: object,
*,
tenant_id: str,
resource_id: str,
action: str,
actor: OwnershipSubjectRef,
current_owner: OwnershipSubjectRef,
target_owner: OwnershipSubjectRef,
) -> OwnershipActionDecision:
...
def apply_owner(
self,
session: object,
*,
tenant_id: str,
resource_id: str,
expected_owner: OwnershipSubjectRef,
target_owner: OwnershipSubjectRef,
actor: OwnershipSubjectRef,
reason: str | None,
) -> None:
...
@dataclass(frozen=True, slots=True)
class OwnershipProviderRegistration:
resource_type: str
provider: ResourceOwnershipProvider
module_id: str | None = None
class OwnershipTransfer(Base, TimestampMixin):
__tablename__ = "core_ownership_transfers"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"resource_module",
"idempotency_key",
name="uq_core_ownership_transfer_idempotency",
),
Index(
"ix_core_ownership_transfer_resource",
"tenant_id",
"resource_module",
"resource_type",
"resource_id",
"status",
),
Index(
"ix_core_ownership_transfer_expiry",
"status",
"expires_at",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
resource_module: Mapped[str] = mapped_column(String(100), nullable=False)
resource_type: Mapped[str] = mapped_column(String(100), nullable=False)
resource_id: Mapped[str] = mapped_column(String(255), nullable=False)
kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
status: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
current_owner_type: Mapped[str] = mapped_column(String(40), nullable=False)
current_owner_id: Mapped[str] = mapped_column(String(255), nullable=False)
target_owner_type: Mapped[str] = mapped_column(String(40), nullable=False)
target_owner_id: Mapped[str] = mapped_column(String(255), nullable=False)
initiated_by_type: Mapped[str] = mapped_column(String(40), nullable=False)
initiated_by_id: Mapped[str] = mapped_column(String(255), nullable=False)
owner_approved_by_type: Mapped[str | None] = mapped_column(String(40))
owner_approved_by_id: Mapped[str | None] = mapped_column(String(255))
target_accepted_by_type: Mapped[str | None] = mapped_column(String(40))
target_accepted_by_id: Mapped[str | None] = mapped_column(String(255))
reason: Mapped[str | None] = mapped_column(Text)
assurance_profile: Mapped[str | None] = mapped_column(String(80))
required_approvals: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
approvals: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
decisions: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(200), nullable=False)
canonical_request_hash: Mapped[str] = mapped_column(String(64), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
execute_after: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
declined_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
expired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
metadata_: Mapped[dict[str, Any]] = mapped_column(
"metadata",
JSON,
default=dict,
nullable=False,
)
def start_owner_initiated_transfer(
session: Session,
*,
tenant_id: str,
resource: OwnershipResourceRef,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
target_owner: OwnershipSubjectRef,
idempotency_key: str,
reason: str | None = None,
expiry_days: int | None = None,
policy: OwnershipTransferPolicy = OwnershipTransferPolicy(),
now: datetime | None = None,
) -> OwnershipTransfer:
return _start_transfer(
session,
tenant_id=tenant_id,
resource=resource,
provider=provider,
actor=actor,
target_owner=target_owner,
kind=OwnershipTransferKind.OWNER_INITIATED,
initial_status=OwnershipTransferStatus.AWAITING_TARGET_ACCEPTANCE,
authorization_action="propose_transfer",
idempotency_key=idempotency_key,
reason=reason,
expiry_days=expiry_days,
policy=policy,
now=now,
assurance_profile=None,
required_approvals=1,
execute_after=None,
metadata={},
)
def request_ownership(
session: Session,
*,
tenant_id: str,
resource: OwnershipResourceRef,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
target_owner: OwnershipSubjectRef | None = None,
idempotency_key: str,
reason: str | None = None,
expiry_days: int | None = None,
policy: OwnershipTransferPolicy = OwnershipTransferPolicy(),
now: datetime | None = None,
) -> OwnershipTransfer:
requested_owner = target_owner or actor
return _start_transfer(
session,
tenant_id=tenant_id,
resource=resource,
provider=provider,
actor=actor,
target_owner=requested_owner,
kind=OwnershipTransferKind.TARGET_REQUESTED,
initial_status=OwnershipTransferStatus.AWAITING_OWNER_APPROVAL,
authorization_action="request_ownership",
idempotency_key=idempotency_key,
reason=reason,
expiry_days=expiry_days,
policy=policy,
now=now,
assurance_profile=None,
required_approvals=1,
execute_after=None,
metadata={},
)
def approve_ownership_request(
session: Session,
*,
transfer: OwnershipTransfer,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
now: datetime | None = None,
) -> OwnershipTransfer:
effective_now = _utc(now)
_require_active(session, transfer, effective_now)
if (
transfer.kind != OwnershipTransferKind.TARGET_REQUESTED.value
or transfer.status != OwnershipTransferStatus.AWAITING_OWNER_APPROVAL.value
):
raise OwnershipTransferError("Ownership request is not awaiting owner approval")
_authorize(provider, session, transfer, actor, "approve_requested_transfer")
transfer.owner_approved_by_type = actor.type
transfer.owner_approved_by_id = actor.id
transfer.status = OwnershipTransferStatus.AWAITING_TARGET_ACCEPTANCE.value
_touch(transfer)
_record_decision(
transfer,
action="owner_approved",
actor=actor,
decided_at=effective_now,
)
_emit_transfer_event(session, transfer, "owner_approved", actor)
session.flush()
return transfer
def accept_ownership_transfer(
session: Session,
*,
transfer: OwnershipTransfer,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
now: datetime | None = None,
) -> OwnershipTransfer:
effective_now = _utc(now)
_require_active(session, transfer, effective_now)
if transfer.status != OwnershipTransferStatus.AWAITING_TARGET_ACCEPTANCE.value:
raise OwnershipTransferError("Ownership transfer is not awaiting target acceptance")
action = (
"accept_group_transfer"
if transfer.target_owner_type == "group"
else "accept_transfer"
)
_authorize(provider, session, transfer, actor, action)
current_owner = _owner_ref(transfer, target=False)
target_owner = _owner_ref(transfer, target=True)
provider.apply_owner(
session,
tenant_id=transfer.tenant_id,
resource_id=transfer.resource_id,
expected_owner=current_owner,
target_owner=target_owner,
actor=actor,
reason=transfer.reason,
)
transfer.target_accepted_by_type = actor.type
transfer.target_accepted_by_id = actor.id
transfer.status = OwnershipTransferStatus.COMPLETED.value
transfer.completed_at = effective_now
_touch(transfer)
_record_decision(
transfer,
action="accepted",
actor=actor,
decided_at=effective_now,
)
_emit_transfer_event(session, transfer, "completed", actor)
session.flush()
return transfer
def decline_ownership_transfer(
session: Session,
*,
transfer: OwnershipTransfer,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
now: datetime | None = None,
) -> OwnershipTransfer:
effective_now = _utc(now)
_require_active(session, transfer, effective_now)
_authorize(provider, session, transfer, actor, "decline_transfer")
transfer.status = OwnershipTransferStatus.DECLINED.value
transfer.declined_at = effective_now
_touch(transfer)
_record_decision(
transfer,
action="declined",
actor=actor,
decided_at=effective_now,
)
_emit_transfer_event(session, transfer, "declined", actor)
session.flush()
return transfer
def cancel_ownership_transfer(
session: Session,
*,
transfer: OwnershipTransfer,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
now: datetime | None = None,
) -> OwnershipTransfer:
effective_now = _utc(now)
_require_active(session, transfer, effective_now)
_authorize(provider, session, transfer, actor, "cancel_transfer")
transfer.status = OwnershipTransferStatus.CANCELLED.value
transfer.cancelled_at = effective_now
_touch(transfer)
_record_decision(
transfer,
action="cancelled",
actor=actor,
decided_at=effective_now,
)
_emit_transfer_event(session, transfer, "cancelled", actor)
session.flush()
return transfer
def start_administrative_recovery(
session: Session,
*,
tenant_id: str,
resource: OwnershipResourceRef,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
target_owner: OwnershipSubjectRef,
idempotency_key: str,
reason: str,
policy: OwnershipTransferPolicy = OwnershipTransferPolicy(),
now: datetime | None = None,
) -> OwnershipTransfer:
if not reason.strip():
raise OwnershipTransferError("Administrative recovery requires a reason")
if policy.recent_authentication_required and not actor.recently_authenticated:
raise OwnershipAuthorizationError(
"Administrative recovery requires recent authentication"
)
effective_now = _utc(now)
transfer = _start_transfer(
session,
tenant_id=tenant_id,
resource=resource,
provider=provider,
actor=actor,
target_owner=target_owner,
kind=OwnershipTransferKind.ADMINISTRATIVE_RECOVERY,
initial_status=OwnershipTransferStatus.AWAITING_RECOVERY_APPROVALS,
authorization_action="request_recovery",
idempotency_key=idempotency_key,
reason=reason,
expiry_days=policy.default_expiry_days,
policy=policy,
now=effective_now,
assurance_profile=policy.recovery_assurance_profile,
required_approvals=policy.recovery_required_approvals,
execute_after=effective_now
+ timedelta(hours=policy.recovery_delay_hours),
metadata={
"recent_authentication_required": (
policy.recent_authentication_required
),
"encryption_keys_included": False,
},
)
return transfer
def approve_administrative_recovery(
session: Session,
*,
transfer: OwnershipTransfer,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
now: datetime | None = None,
) -> OwnershipTransfer:
effective_now = _utc(now)
_require_active(session, transfer, effective_now)
_require_recovery_authentication(transfer, actor)
if (
transfer.kind != OwnershipTransferKind.ADMINISTRATIVE_RECOVERY.value
or transfer.status
not in {
OwnershipTransferStatus.AWAITING_RECOVERY_APPROVALS.value,
OwnershipTransferStatus.RECOVERY_SCHEDULED.value,
}
):
raise OwnershipTransferError("Ownership recovery is not awaiting approval")
_authorize(provider, session, transfer, actor, "approve_recovery")
approvals = list(transfer.approvals or [])
if any(
item.get("actor_type") == actor.type and item.get("actor_id") == actor.id
for item in approvals
):
return transfer
approvals.append(
{
"actor_type": actor.type,
"actor_id": actor.id,
"approved_at": effective_now.isoformat(),
}
)
transfer.approvals = approvals
if len(approvals) >= transfer.required_approvals:
transfer.status = OwnershipTransferStatus.RECOVERY_SCHEDULED.value
_touch(transfer)
_record_decision(
transfer,
action="recovery_approved",
actor=actor,
decided_at=effective_now,
details={"approval_count": len(approvals)},
)
_emit_transfer_event(session, transfer, "recovery_approved", actor)
session.flush()
return transfer
def execute_administrative_recovery(
session: Session,
*,
transfer: OwnershipTransfer,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
now: datetime | None = None,
) -> OwnershipTransfer:
effective_now = _utc(now)
_require_active(session, transfer, effective_now)
_require_recovery_authentication(transfer, actor)
if transfer.status != OwnershipTransferStatus.RECOVERY_SCHEDULED.value:
raise OwnershipTransferError("Ownership recovery has not reached its approval quorum")
if transfer.execute_after and _utc(transfer.execute_after) > effective_now:
raise OwnershipTransferError("Ownership recovery assurance delay has not elapsed")
_authorize(provider, session, transfer, actor, "execute_recovery")
provider.apply_owner(
session,
tenant_id=transfer.tenant_id,
resource_id=transfer.resource_id,
expected_owner=_owner_ref(transfer, target=False),
target_owner=_owner_ref(transfer, target=True),
actor=actor,
reason=transfer.reason,
)
transfer.status = OwnershipTransferStatus.COMPLETED.value
transfer.completed_at = effective_now
_touch(transfer)
_record_decision(
transfer,
action="recovery_executed",
actor=actor,
decided_at=effective_now,
)
_emit_transfer_event(session, transfer, "recovery_completed", actor)
session.flush()
return transfer
def expire_due_ownership_transfers(
session: Session,
*,
now: datetime | None = None,
limit: int = 250,
) -> int:
effective_now = _utc(now)
rows = (
session.query(OwnershipTransfer)
.filter(
OwnershipTransfer.status.notin_(
sorted(TERMINAL_OWNERSHIP_TRANSFER_STATUSES)
),
OwnershipTransfer.expires_at <= effective_now,
)
.order_by(OwnershipTransfer.expires_at.asc(), OwnershipTransfer.id.asc())
.limit(limit)
.all()
)
for transfer in rows:
transfer.status = OwnershipTransferStatus.EXPIRED.value
transfer.expired_at = effective_now
_touch(transfer)
_record_decision(
transfer,
action="expired",
actor=None,
decided_at=effective_now,
)
_emit_transfer_event(session, transfer, "expired", None)
session.flush()
return len(rows)
def _start_transfer(
session: Session,
*,
tenant_id: str,
resource: OwnershipResourceRef,
provider: ResourceOwnershipProvider,
actor: OwnershipSubjectRef,
target_owner: OwnershipSubjectRef,
kind: OwnershipTransferKind,
initial_status: OwnershipTransferStatus,
authorization_action: str,
idempotency_key: str,
reason: str | None,
expiry_days: int | None,
policy: OwnershipTransferPolicy,
now: datetime | None,
assurance_profile: str | None,
required_approvals: int,
execute_after: datetime | None,
metadata: dict[str, Any],
) -> OwnershipTransfer:
if not resource.module_id or resource.module_id != resource.module_id.strip():
raise OwnershipTransferError("Ownership resource module id is invalid")
clean_key = idempotency_key.strip()
if not clean_key or len(clean_key) > 200:
raise OwnershipTransferError("A bounded ownership idempotency key is required")
effective_now = _utc(now)
days = policy.default_expiry_days if expiry_days is None else expiry_days
if not policy.min_expiry_days <= days <= policy.max_expiry_days:
raise OwnershipTransferError(
"Ownership transfer expiry is outside the effective policy"
)
current_owner = provider.current_owner(
session,
tenant_id=tenant_id,
resource_id=resource.resource_id,
)
if current_owner is None:
raise OwnershipTransferError("Owned resource was not found")
if current_owner.type == target_owner.type and current_owner.id == target_owner.id:
raise OwnershipTransferError("Target is already the resource owner")
_authorize_direct(
provider,
session,
tenant_id=tenant_id,
resource_id=resource.resource_id,
current_owner=current_owner,
target_owner=target_owner,
actor=actor,
action=authorization_action,
)
request_hash = _request_hash(
tenant_id=tenant_id,
resource=resource,
kind=kind,
current_owner=current_owner,
target_owner=target_owner,
actor=actor,
reason=reason,
expiry_days=days,
assurance_profile=assurance_profile,
required_approvals=required_approvals,
execute_after=execute_after,
metadata=metadata,
)
existing = (
session.query(OwnershipTransfer)
.filter(
OwnershipTransfer.tenant_id == tenant_id,
OwnershipTransfer.resource_module == resource.module_id,
OwnershipTransfer.idempotency_key == clean_key,
)
.one_or_none()
)
if existing is not None:
if existing.canonical_request_hash != request_hash:
raise OwnershipIdempotencyConflict(
"Ownership idempotency key is already bound to another request"
)
return existing
transfer = OwnershipTransfer(
tenant_id=tenant_id,
resource_module=resource.module_id,
resource_type=resource.resource_type,
resource_id=resource.resource_id,
kind=kind.value,
status=initial_status.value,
current_owner_type=current_owner.type,
current_owner_id=current_owner.id,
target_owner_type=target_owner.type,
target_owner_id=target_owner.id,
initiated_by_type=actor.type,
initiated_by_id=actor.id,
reason=reason.strip() if reason else None,
assurance_profile=assurance_profile,
required_approvals=required_approvals,
approvals=[],
decisions=[],
idempotency_key=clean_key,
canonical_request_hash=request_hash,
expires_at=effective_now + timedelta(days=days),
execute_after=execute_after,
metadata_=dict(metadata),
)
session.add(transfer)
session.flush()
_record_decision(
transfer,
action="started",
actor=actor,
decided_at=effective_now,
details={
"kind": kind.value,
"initial_status": initial_status.value,
"assurance_profile": assurance_profile,
"required_approvals": required_approvals,
},
)
_emit_transfer_event(session, transfer, "started", actor)
return transfer
def _authorize(
provider: ResourceOwnershipProvider,
session: Session,
transfer: OwnershipTransfer,
actor: OwnershipSubjectRef,
action: str,
) -> None:
_authorize_direct(
provider,
session,
tenant_id=transfer.tenant_id,
resource_id=transfer.resource_id,
current_owner=_owner_ref(transfer, target=False),
target_owner=_owner_ref(transfer, target=True),
actor=actor,
action=action,
)
def _authorize_direct(
provider: ResourceOwnershipProvider,
session: Session,
*,
tenant_id: str,
resource_id: str,
current_owner: OwnershipSubjectRef,
target_owner: OwnershipSubjectRef,
actor: OwnershipSubjectRef,
action: str,
) -> None:
decision = provider.authorize_ownership_action(
session,
tenant_id=tenant_id,
resource_id=resource_id,
action=action,
actor=actor,
current_owner=current_owner,
target_owner=target_owner,
)
if not decision.allowed:
raise OwnershipAuthorizationError(
decision.reason or f"Ownership action is not allowed: {action}"
)
def _require_active(
session: Session,
transfer: OwnershipTransfer,
now: datetime,
) -> None:
if transfer.status in TERMINAL_OWNERSHIP_TRANSFER_STATUSES:
raise OwnershipTransferError("Ownership transfer is already final")
if _utc(transfer.expires_at) <= now:
transfer.status = OwnershipTransferStatus.EXPIRED.value
transfer.expired_at = now
_touch(transfer)
_record_decision(
transfer,
action="expired",
actor=None,
decided_at=now,
)
_emit_transfer_event(session, transfer, "expired", None)
raise OwnershipTransferExpired("Ownership transfer has expired")
def _require_recovery_authentication(
transfer: OwnershipTransfer,
actor: OwnershipSubjectRef,
) -> None:
if (
bool((transfer.metadata_ or {}).get("recent_authentication_required"))
and not actor.recently_authenticated
):
raise OwnershipAuthorizationError(
"Administrative recovery requires recent authentication"
)
def _owner_ref(
transfer: OwnershipTransfer,
*,
target: bool,
) -> OwnershipSubjectRef:
return OwnershipSubjectRef(
type=transfer.target_owner_type if target else transfer.current_owner_type,
id=transfer.target_owner_id if target else transfer.current_owner_id,
)
def _touch(transfer: OwnershipTransfer) -> None:
transfer.revision = int(transfer.revision or 0) + 1
def _record_decision(
transfer: OwnershipTransfer,
*,
action: str,
actor: OwnershipSubjectRef | None,
decided_at: datetime,
details: dict[str, Any] | None = None,
) -> None:
decisions = list(transfer.decisions or [])
decisions.append(
{
"sequence": len(decisions) + 1,
"action": action,
"actor_type": actor.type if actor else None,
"actor_id": actor.id if actor else None,
"decided_at": _utc(decided_at).isoformat(),
"status": transfer.status,
"details": dict(details or {}),
}
)
transfer.decisions = decisions
def _utc(value: datetime | None) -> datetime:
if value is None:
return utcnow()
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _request_hash(
*,
tenant_id: str,
resource: OwnershipResourceRef,
kind: OwnershipTransferKind,
current_owner: OwnershipSubjectRef,
target_owner: OwnershipSubjectRef,
actor: OwnershipSubjectRef,
reason: str | None,
expiry_days: int,
assurance_profile: str | None,
required_approvals: int,
execute_after: datetime | None,
metadata: dict[str, Any],
) -> str:
payload = {
"tenant_id": tenant_id,
"resource": {
"module_id": resource.module_id,
"type": resource.resource_type,
"id": resource.resource_id,
},
"kind": kind.value,
"current_owner": {"type": current_owner.type, "id": current_owner.id},
"target_owner": {"type": target_owner.type, "id": target_owner.id},
"actor": {"type": actor.type, "id": actor.id},
"reason": reason.strip() if reason else None,
"expiry_days": expiry_days,
"assurance_profile": assurance_profile,
"required_approvals": required_approvals,
"execute_after": (
_utc(execute_after).isoformat()
if execute_after is not None
else None
),
"metadata": metadata,
}
encoded = json.dumps(
payload,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _emit_transfer_event(
session: Session,
transfer: OwnershipTransfer,
action: str,
actor: OwnershipSubjectRef | None,
) -> None:
from govoplan_core.core.events import (
EventActorRef,
EventObjectRef,
EventTenantRef,
PlatformEvent,
emit_platform_event,
)
emit_platform_event(
session,
PlatformEvent(
type=f"core.ownership_transfer.{action}.v1",
module_id="core",
tenant=EventTenantRef(id=transfer.tenant_id),
actor=EventActorRef(type=actor.type, id=actor.id) if actor else None,
resource=EventObjectRef(
type="ownership_transfer",
id=transfer.id,
),
classification="confidential",
payload={
"schema_version": 1,
"resource_module": transfer.resource_module,
"resource_type": transfer.resource_type,
"resource_id": transfer.resource_id,
"kind": transfer.kind,
"status": transfer.status,
"current_owner_type": transfer.current_owner_type,
"current_owner_id": transfer.current_owner_id,
"target_owner_type": transfer.target_owner_type,
"target_owner_id": transfer.target_owner_id,
"assurance_profile": transfer.assurance_profile,
"required_approvals": transfer.required_approvals,
"approval_count": len(transfer.approvals or []),
"encryption_keys_included": False,
},
),
)
__all__ = [
"OwnershipActionDecision",
"OwnershipAuthorizationError",
"OwnershipIdempotencyConflict",
"OwnershipProviderRegistration",
"OwnershipResourceRef",
"OwnershipSubjectRef",
"OwnershipTransfer",
"OwnershipTransferError",
"OwnershipTransferExpired",
"OwnershipTransferKind",
"OwnershipTransferPolicy",
"OwnershipTransferStatus",
"ResourceOwnershipProvider",
"accept_ownership_transfer",
"approve_administrative_recovery",
"approve_ownership_request",
"cancel_ownership_transfer",
"decline_ownership_transfer",
"execute_administrative_recovery",
"expire_due_ownership_transfers",
"request_ownership",
"start_administrative_recovery",
"start_owner_initiated_transfer",
]
+169
View File
@@ -0,0 +1,169 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable
CAPABILITY_ACCESS_PEOPLE_SEARCH = "access.people_search"
CAPABILITY_ADDRESSES_PEOPLE_SEARCH = "addresses.people_search"
# Identity search is deliberately absent here. ``identity.search`` is an
# instance-wide canonical-identity directory and has no tenant/principal
# visibility contract. Ordinary task pickers must use principal-aware search
# providers instead.
DEFAULT_PEOPLE_SEARCH_CAPABILITIES = (
CAPABILITY_ACCESS_PEOPLE_SEARCH,
CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
)
class PeopleSearchError(ValueError):
"""Stable, non-diagnostic error raised by people-search providers."""
@dataclass(frozen=True, slots=True)
class PersonSearchCandidate:
"""A policy-filtered selection target returned by a directory provider."""
selection_key: str
kind: str
reference_id: str
display_name: str
email: str | None = None
source_module: str | None = None
source_label: str | None = None
source_ref: str | None = None
source_revision: str | None = None
description: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PeopleSearchGroup:
key: str
label: str
candidates: tuple[PersonSearchCandidate, ...] = ()
@runtime_checkable
class PeopleSearchProvider(Protocol):
"""Server-side, principal-aware people/delivery-target search.
Implementations must derive visibility from ``principal`` and may only
return records the principal can discover in its active tenant and policy
context. Callers remain responsible for authorizing the task for which the
picker is used (for example, creating a scheduling request).
"""
def search_people(
self,
session: object,
principal: object,
*,
query: str,
limit: int = 25,
) -> Sequence[PeopleSearchGroup]:
...
def people_search_providers(
registry: object | None,
*,
capability_names: Sequence[str] = DEFAULT_PEOPLE_SEARCH_CAPABILITIES,
) -> tuple[PeopleSearchProvider, ...]:
if registry is None or not hasattr(registry, "has_capability") or not hasattr(registry, "capability"):
return ()
providers: list[PeopleSearchProvider] = []
for capability_name in capability_names:
if not registry.has_capability(capability_name):
continue
capability = registry.capability(capability_name)
if isinstance(capability, PeopleSearchProvider):
providers.append(capability)
return tuple(providers)
def search_visible_people(
registry: object | None,
session: object,
principal: object,
*,
query: str,
limit: int = 25,
capability_names: Sequence[str] = DEFAULT_PEOPLE_SEARCH_CAPABILITIES,
) -> tuple[PeopleSearchGroup, ...]:
"""Collect grouped results without importing optional feature modules.
``limit`` is enforced per provider so one directory cannot starve another
result group. Providers are expected to return at most that many candidates
in total. Duplicate group keys are merged and candidate selection keys are
de-duplicated while preserving provider order.
"""
normalized_query = str(query or "").strip()
normalized_limit = max(1, min(int(limit), 100))
ordered_group_keys: list[str] = []
labels: dict[str, str] = {}
candidates_by_group: dict[str, list[PersonSearchCandidate]] = {}
candidate_keys_by_group: dict[str, set[str]] = {}
for provider in people_search_providers(registry, capability_names=capability_names):
groups = provider.search_people(
session,
principal,
query=normalized_query,
limit=normalized_limit,
)
provider_candidate_count = 0
for group in groups:
if not group.key:
continue
if group.key not in candidates_by_group:
ordered_group_keys.append(group.key)
labels[group.key] = group.label
candidates_by_group[group.key] = []
candidate_keys_by_group[group.key] = set()
for candidate in group.candidates:
if provider_candidate_count >= normalized_limit:
break
if not candidate.selection_key or candidate.selection_key in candidate_keys_by_group[group.key]:
continue
candidate_keys_by_group[group.key].add(candidate.selection_key)
candidates_by_group[group.key].append(candidate)
provider_candidate_count += 1
return tuple(
PeopleSearchGroup(
key=group_key,
label=labels[group_key],
candidates=tuple(candidates_by_group[group_key]),
)
for group_key in ordered_group_keys
if candidates_by_group[group_key]
)
def person_selection_key(kind: str, reference_id: str, *, email: str | None = None) -> str:
normalized_kind = str(kind).strip().casefold()
normalized_reference = str(reference_id).strip()
normalized_email = str(email or "").strip().casefold()
if not normalized_kind or not normalized_reference:
raise ValueError("Person selection keys require a kind and reference id.")
return ":".join(part for part in (normalized_kind, normalized_reference, normalized_email) if part)
__all__ = [
"CAPABILITY_ACCESS_PEOPLE_SEARCH",
"CAPABILITY_ADDRESSES_PEOPLE_SEARCH",
"DEFAULT_PEOPLE_SEARCH_CAPABILITIES",
"PeopleSearchError",
"PeopleSearchGroup",
"PeopleSearchProvider",
"PersonSearchCandidate",
"people_search_providers",
"person_selection_key",
"search_visible_people",
]
+273 -31
View File
@@ -4,13 +4,55 @@ from dataclasses import dataclass, field
from typing import Any, Iterable, Literal, Mapping, Protocol, cast, runtime_checkable
from urllib.parse import quote, unquote
from govoplan_core.core.access import PrincipalRef
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
SchedulingParticipantVisibility = Literal["aggregates_only", "names_and_statuses"]
DefinitionScopeType = Literal["system", "tenant", "group", "user"]
DefinitionKind = Literal["flow", "template"]
DefinitionGovernanceAction = Literal[
"view",
"edit",
"run",
"reuse",
"derive",
"automate",
]
ViewGovernanceAction = Literal[
"view",
"select",
"assign",
"edit",
"derive",
"workflow_activate",
]
FunctionAssignmentChangeKind = Literal["request", "grant"]
FunctionAssignmentGovernanceAction = Literal[
"submit",
"approve_holder",
"approve_authority",
"accept_recipient",
"request_changes",
"respond",
"reject",
"withdraw",
"recover",
"apply",
]
CAPABILITY_POLICY_PRIVACY_RETENTION = "policy.privacyRetention"
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY = "policy.schedulingParticipantPrivacy"
CAPABILITY_POLICY_DEFINITION_GOVERNANCE = "policy.definitionGovernance"
CAPABILITY_POLICY_VIEW_GOVERNANCE = "policy.viewGovernance"
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE = "policy.functionAssignmentGovernance"
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign")
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = (
"system",
"tenant",
"user",
"group",
"campaign",
)
def normalize_policy_scope_type(scope_type: str) -> PolicyScopeType:
@@ -30,7 +72,11 @@ class PolicySourceRef:
return policy_source_path(self.scope_type, self.scope_id)
def to_dict(self) -> dict[str, Any]:
return {"scope_type": self.scope_type, "scope_id": self.scope_id, "path": self.path}
return {
"scope_type": self.scope_type,
"scope_id": self.scope_id,
"path": self.path,
}
def policy_source_path(scope_type: str, scope_id: str | None = None) -> str:
@@ -40,7 +86,9 @@ def policy_source_path(scope_type: str, scope_id: str | None = None) -> str:
raise ValueError("System policy sources do not carry a scope_id")
return "system"
if not scope_id:
raise ValueError(f"{clean_scope.capitalize()} policy sources require a scope_id")
raise ValueError(
f"{clean_scope.capitalize()} policy sources require a scope_id"
)
return f"{clean_scope}:{quote(str(scope_id), safe='')}"
@@ -50,7 +98,9 @@ def parse_policy_source_path(path: str) -> PolicySourceRef:
return PolicySourceRef(scope_type="system")
scope_type, separator, encoded_scope_id = clean_path.partition(":")
if not separator:
raise ValueError("Policy source path must be system or <scope_type>:<url-encoded-scope-id>")
raise ValueError(
"Policy source path must be system or <scope_type>:<url-encoded-scope-id>"
)
clean_scope = normalize_policy_scope_type(scope_type)
if clean_scope == "system":
raise ValueError("System policy source path must be exactly system")
@@ -87,9 +137,13 @@ class PolicySourceStep:
policy_value = value.get("policy")
return cls(
scope_type=normalize_policy_scope_type(str(value.get("scope_type", ""))),
scope_id=str(value["scope_id"]) if value.get("scope_id") is not None else None,
scope_id=str(value["scope_id"])
if value.get("scope_id") is not None
else None,
label=str(value.get("label") or ""),
applied_fields=tuple(str(field) for field in (value.get("applied_fields") or ())),
applied_fields=tuple(
str(field) for field in (value.get("applied_fields") or ())
),
policy=policy_value if isinstance(policy_value, Mapping) else {},
)
@@ -128,6 +182,202 @@ class PolicyDecision:
}
@dataclass(frozen=True, slots=True)
class FunctionAssignmentGovernanceRequest:
tenant_id: str
kind: FunctionAssignmentChangeKind
action: FunctionAssignmentGovernanceAction
function_id: str
actor: PrincipalRef
candidate_identity_id: str
candidate_account_id: str | None = None
current_state: str = "draft"
function_settings: Mapping[str, Any] = field(default_factory=dict)
context: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class FunctionAssignmentGovernanceDecision:
allowed: bool
reason: str | None = None
profile: str = "unavailable"
required_steps: tuple[str, ...] = ()
authority_function_id: str | None = None
evidence_required: bool = False
recipient_acceptance_required: bool = False
separation_of_duties: bool = True
quorum: int = 1
maximum_validity_days: int | None = None
request_expiry_hours: int = 336
source_path: tuple[PolicySourceStep, ...] = ()
requirements: tuple[str, ...] = ()
details: Mapping[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"allowed": self.allowed,
"reason": self.reason,
"profile": self.profile,
"required_steps": list(self.required_steps),
"authority_function_id": self.authority_function_id,
"evidence_required": self.evidence_required,
"recipient_acceptance_required": (self.recipient_acceptance_required),
"separation_of_duties": self.separation_of_duties,
"quorum": self.quorum,
"maximum_validity_days": self.maximum_validity_days,
"request_expiry_hours": self.request_expiry_hours,
"source_path": [step.to_dict() for step in self.source_path],
"requirements": list(self.requirements),
"details": dict(self.details),
}
@runtime_checkable
class FunctionAssignmentGovernancePolicy(Protocol):
def resolve_function_assignment_action(
self,
session: object | None = None,
*,
request: FunctionAssignmentGovernanceRequest,
) -> FunctionAssignmentGovernanceDecision: ...
def function_assignment_governance_policy(
registry: object | None,
) -> FunctionAssignmentGovernancePolicy | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE)
):
return None
capability = registry.capability(CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE)
return (
capability
if isinstance(capability, FunctionAssignmentGovernancePolicy)
else None
)
@dataclass(frozen=True, slots=True)
class DefinitionScopeRef:
scope_type: DefinitionScopeType
scope_id: str | None = None
@property
def path(self) -> str:
return policy_source_path(self.scope_type, self.scope_id)
@dataclass(frozen=True, slots=True)
class DefinitionGovernanceRequest:
module_id: str
definition_ref: str
tenant_id: str
definition_scope: DefinitionScopeRef
target_scope: DefinitionScopeRef
definition_kind: DefinitionKind
action: DefinitionGovernanceAction
actor: PrincipalRef
status: str = "draft"
inherit_to_lower_scopes: bool = False
allow_run: bool = True
allow_reuse: bool = False
allow_automation: bool = False
context: Mapping[str, Any] = field(default_factory=dict)
@runtime_checkable
class DefinitionGovernancePolicy(Protocol):
def resolve_definition_action(
self,
session: object | None = None,
*,
request: DefinitionGovernanceRequest,
) -> PolicyDecision: ...
def definition_governance_policy(
registry: object | None,
) -> DefinitionGovernancePolicy | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_POLICY_DEFINITION_GOVERNANCE)
):
return None
capability = registry.capability(CAPABILITY_POLICY_DEFINITION_GOVERNANCE)
return capability if isinstance(capability, DefinitionGovernancePolicy) else None
@dataclass(frozen=True, slots=True)
class ViewGovernanceRequest:
tenant_id: str
action: ViewGovernanceAction
actor: PrincipalRef
target_scope: DefinitionScopeRef
view_id: str | None = None
candidate_view_ids: tuple[str, ...] = ()
candidate_surface_ids: tuple[str, ...] = ()
requested_surface_ids: tuple[str, ...] = ()
context: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ViewGovernanceDecision:
allowed: bool
reason: str | None = None
allowed_view_ids: frozenset[str] | None = None
visible_surface_ids: frozenset[str] | None = None
source_path: tuple[PolicySourceStep, ...] = ()
requirements: tuple[str, ...] = ()
diagnostics: tuple[Mapping[str, Any], ...] = ()
details: Mapping[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"allowed": self.allowed,
"reason": self.reason,
"allowed_view_ids": (
sorted(self.allowed_view_ids)
if self.allowed_view_ids is not None
else None
),
"visible_surface_ids": (
sorted(self.visible_surface_ids)
if self.visible_surface_ids is not None
else None
),
"source_path": [step.to_dict() for step in self.source_path],
"requirements": list(self.requirements),
"diagnostics": [dict(item) for item in self.diagnostics],
"details": dict(self.details),
}
@runtime_checkable
class ViewGovernancePolicy(Protocol):
def resolve_view_action(
self,
session: object | None = None,
*,
request: ViewGovernanceRequest,
) -> ViewGovernanceDecision: ...
def view_governance_policy(
registry: object | None,
) -> ViewGovernancePolicy | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_POLICY_VIEW_GOVERNANCE)
):
return None
capability = registry.capability(CAPABILITY_POLICY_VIEW_GOVERNANCE)
return capability if isinstance(capability, ViewGovernancePolicy) else None
@dataclass(frozen=True, slots=True)
class SchedulingParticipantPrivacyRequest:
"""Context for resolving what one Scheduling participant may see.
@@ -173,44 +423,32 @@ class SchedulingParticipantPrivacyPolicy(Protocol):
session: object,
*,
request: SchedulingParticipantPrivacyRequest,
) -> SchedulingParticipantPrivacyDecision:
...
) -> SchedulingParticipantPrivacyDecision: ...
@runtime_checkable
class PrivacyRetentionService(Protocol):
def privacy_policy_from_settings(self, *args: Any, **kwargs: Any) -> Any:
...
def privacy_policy_from_settings(self, *args: Any, **kwargs: Any) -> Any: ...
def privacy_policy_from_session(self, *args: Any, **kwargs: Any) -> Any:
...
def privacy_policy_from_session(self, *args: Any, **kwargs: Any) -> Any: ...
def set_privacy_policy(self, *args: Any, **kwargs: Any) -> Any:
...
def set_privacy_policy(self, *args: Any, **kwargs: Any) -> Any: ...
def effective_privacy_policy(self, *args: Any, **kwargs: Any) -> Any:
...
def effective_privacy_policy(self, *args: Any, **kwargs: Any) -> Any: ...
def parent_privacy_policy(self, *args: Any, **kwargs: Any) -> Any:
...
def parent_privacy_policy(self, *args: Any, **kwargs: Any) -> Any: ...
def effective_privacy_policy_sources(self, *args: Any, **kwargs: Any) -> Any:
...
def effective_privacy_policy_sources(self, *args: Any, **kwargs: Any) -> Any: ...
def parent_privacy_policy_sources(self, *args: Any, **kwargs: Any) -> Any:
...
def parent_privacy_policy_sources(self, *args: Any, **kwargs: Any) -> Any: ...
def get_privacy_policy_for_scope(self, *args: Any, **kwargs: Any) -> Any:
...
def get_privacy_policy_for_scope(self, *args: Any, **kwargs: Any) -> Any: ...
def set_privacy_policy_for_scope(self, *args: Any, **kwargs: Any) -> Any:
...
def set_privacy_policy_for_scope(self, *args: Any, **kwargs: Any) -> Any: ...
def sanitize_audit_details_for_policy(self, *args: Any, **kwargs: Any) -> Any:
...
def sanitize_audit_details_for_policy(self, *args: Any, **kwargs: Any) -> Any: ...
def apply_retention_policy(self, *args: Any, **kwargs: Any) -> Any:
...
def apply_retention_policy(self, *args: Any, **kwargs: Any) -> Any: ...
def scheduling_participant_privacy_policy(
@@ -223,4 +461,8 @@ def scheduling_participant_privacy_policy(
if not registry.has_capability(CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY):
return None
capability = registry.capability(CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY)
return capability if isinstance(capability, SchedulingParticipantPrivacyPolicy) else None
return (
capability
if isinstance(capability, SchedulingParticipantPrivacyPolicy)
else None
)
+82
View File
@@ -73,6 +73,18 @@ class PollOptionUpdateCommand:
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PollOptionOrderCommand:
"""Complete active option order supplied by an owning module.
Providers must reject partial, duplicate, or unknown option collections.
An exact repeat is an idempotent no-op. Reordering changes positions only;
option identities and their existing answers remain valid.
"""
option_ids: tuple[str, ...]
@dataclass(frozen=True, slots=True)
class PollInvitationCommand:
respondent_id: str | None = None
@@ -98,6 +110,22 @@ class PollSubmitResponseCommand:
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PollResponseRetirementCommand:
"""Retire live responses while preserving their auditable history.
Owning modules provide every server-trusted identity that can refer to the
participant. Providers must soft-delete matching live responses, retain
their answers, and treat an exact ``idempotency_key`` replay as a no-op.
"""
respondent_ids: tuple[str, ...] = ()
invitation_id: str | None = None
reason: str = "participant_removed"
idempotency_key: str = ""
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PollOptionRef:
id: str
@@ -138,6 +166,16 @@ class PollResponseRef:
answers: tuple[PollAnswerRef, ...] = ()
@dataclass(frozen=True, slots=True)
class PollResponseRetirementRef:
"""Outcome of an auditable response-retirement request."""
response_ids: tuple[str, ...] = ()
retired_at: datetime | None = None
newly_retired_count: int = 0
replayed: bool = False
@runtime_checkable
class PollSchedulingProvider(Protocol):
def create_poll(
@@ -183,6 +221,18 @@ class PollSchedulingProvider(Protocol):
...
def reorder_options(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
command: PollOptionOrderCommand,
) -> PollRef:
"""Atomically replace the complete active option order."""
...
def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
...
@@ -251,6 +301,21 @@ class PollResponseSubmissionProvider(Protocol):
...
@runtime_checkable
class PollResponseRetirementProvider(Protocol):
"""Optional extension for owning modules that remove participants."""
def retire_responses(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
command: PollResponseRetirementCommand,
) -> PollResponseRetirementRef:
...
def poll_scheduling_provider(registry: object | None) -> PollSchedulingProvider | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
@@ -265,3 +330,20 @@ def poll_response_submission_provider(
) -> PollResponseSubmissionProvider | None:
provider = poll_scheduling_provider(registry)
return provider if isinstance(provider, PollResponseSubmissionProvider) else None
def poll_response_retirement_provider(
registry: object | None,
) -> PollResponseRetirementProvider | None:
"""Resolve response retirement without making it mandatory for Poll v1 providers."""
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_POLL_SCHEDULING):
return None
capability = registry.capability(CAPABILITY_POLL_SCHEDULING)
return (
capability
if isinstance(capability, PollResponseRetirementProvider)
else None
)
@@ -0,0 +1,272 @@
from __future__ import annotations
import hashlib
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
from govoplan_core.core.poll import (
PollAnswerRequest,
PollInvitationRef,
PollOptionRequest,
PollResponseRef,
)
CAPABILITY_POLL_PARTICIPATION_GATEWAY = "poll.participation_gateway"
# Policy-attestation identifier, not a credential or credential default.
ANONYMOUS_PASSWORD_REQUIREMENT = "anonymous_password" # nosec B105 # noqa: S105
PARTICIPATION_POLICY_VERSION = 1
def participation_token_fingerprint(token: str) -> str:
"""Return a non-reversible identifier suitable for audit/throttle keys."""
return hashlib.sha256(token.encode("utf-8")).hexdigest()
@dataclass(frozen=True, slots=True)
class PollResponseGatewayRef:
"""Stable identity of the module resource governing a participation link."""
module_id: str
resource_type: str
resource_id: str
@dataclass(frozen=True, slots=True)
class PollParticipationPolicy:
"""Generic response rules snapshotted onto one signed invitation.
``single_choice`` treats every non-``unavailable`` availability answer as
a selection. Capacity is reserved only by ``available`` answers (and by
selected answers for non-availability polls), so ``maybe`` never consumes
a place.
"""
version: int = PARTICIPATION_POLICY_VERSION
single_choice: bool = False
allow_maybe: bool = True
max_participants_per_option: int | None = None
allow_comments: bool = False
participant_email_required: bool = False
anonymous_password_required: bool = False
@dataclass(frozen=True, slots=True)
class PollGovernedInvitationCommand:
gateway: PollResponseGatewayRef
policy: PollParticipationPolicy
respondent_id: str | None = None
respondent_label: str | None = None
email: str | None = None
expires_at: datetime | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PollGovernedResponseCommand:
"""Submission already authorized by the named in-process gateway.
Passwords never cross this boundary. A gateway that owns a password
verifier reports the completed check through ``verified_requirements``.
Poll independently re-enforces the remaining snapshotted rules while
holding its Poll-row lock.
"""
respondent_id: str | None = None
respondent_label: str | None = None
participant_email: str | None = None
participant_is_authenticated: bool = False
answers: tuple[PollAnswerRequest, ...] = ()
comment: str | None = None
verified_requirements: frozenset[str] = frozenset()
idempotency_key: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PollGovernedResponseRef:
response: PollResponseRef
participant_email: str | None = None
comment: str | None = None
replayed: bool = False
@dataclass(frozen=True, slots=True)
class PollOptionMutationRef:
id: str
position: int
replayed: bool = False
invalidated_response_count: int = 0
@dataclass(frozen=True, slots=True)
class PollInvitationRevocationRef:
id: str
revoked_at: datetime
replayed: bool = False
@dataclass(frozen=True, slots=True)
class PollInvitationExpiryRef:
id: str
expires_at: datetime | None
replayed: bool = False
@dataclass(frozen=True, slots=True)
class PollParticipationContextRef:
invitation_id: str
tenant_id: str
poll_id: str
gateway: PollResponseGatewayRef
policy: PollParticipationPolicy
respondent_id: str | None = None
respondent_label: str | None = None
email: str | None = None
response: PollGovernedResponseRef | None = None
@runtime_checkable
class PollParticipationGatewayProvider(Protocol):
def create_governed_invitation(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
command: PollGovernedInvitationCommand,
) -> PollInvitationRef:
...
def resolve_participation(
self,
session: object,
*,
token: str,
gateway: PollResponseGatewayRef,
respondent_id: str | None = None,
participant_email: str | None = None,
participant_is_authenticated: bool = False,
verified_requirements: frozenset[str] = frozenset(),
) -> PollParticipationContextRef:
"""Resolve and prefill one valid invitation for the exact gateway."""
...
def submit_governed_response(
self,
session: object,
*,
token: str,
gateway: PollResponseGatewayRef,
command: PollGovernedResponseCommand,
) -> PollGovernedResponseRef:
...
def resolve_authenticated_participation(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
invitation_id: str,
gateway: PollResponseGatewayRef,
respondent_id: str,
) -> PollParticipationContextRef:
"""Resolve one governed invitation without retaining its public token."""
...
def submit_authenticated_response(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
invitation_id: str,
gateway: PollResponseGatewayRef,
respondent_id: str,
command: PollGovernedResponseCommand,
) -> PollGovernedResponseRef:
"""Submit atomically for the exact authenticated invitation identity."""
...
def add_option(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
command: PollOptionRequest,
) -> PollOptionMutationRef:
...
def remove_option(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
option_id: str,
) -> PollOptionMutationRef:
...
def revoke_invitation(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
invitation_id: str,
) -> PollInvitationRevocationRef:
...
def update_invitation_expiry(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
invitation_id: str,
gateway: PollResponseGatewayRef,
expires_at: datetime | None,
) -> PollInvitationExpiryRef:
"""Expire or extend a non-revoked governed invitation in place."""
...
def poll_participation_gateway_provider(
registry: object | None,
) -> PollParticipationGatewayProvider | None:
"""Resolve the governed Poll gateway without importing its implementation."""
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_POLL_PARTICIPATION_GATEWAY):
return None
capability = registry.capability(CAPABILITY_POLL_PARTICIPATION_GATEWAY)
return capability if isinstance(capability, PollParticipationGatewayProvider) else None
__all__ = [
"ANONYMOUS_PASSWORD_REQUIREMENT",
"CAPABILITY_POLL_PARTICIPATION_GATEWAY",
"PARTICIPATION_POLICY_VERSION",
"PollGovernedInvitationCommand",
"PollGovernedResponseCommand",
"PollGovernedResponseRef",
"PollInvitationExpiryRef",
"PollInvitationRevocationRef",
"PollOptionMutationRef",
"PollParticipationContextRef",
"PollParticipationGatewayProvider",
"PollParticipationPolicy",
"PollResponseGatewayRef",
"participation_token_fingerprint",
"poll_participation_gateway_provider",
]
+543
View File
@@ -0,0 +1,543 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.events import EventClassification
POSTBOX_MODULE_ID = "postbox"
CAPABILITY_POSTBOX_DIRECTORY = "postbox.directory"
CAPABILITY_POSTBOX_ACCESS = "postbox.access"
CAPABILITY_POSTBOX_MESSAGES = "postbox.messages"
CAPABILITY_POSTBOX_DELIVERY = "postbox.delivery"
CAPABILITY_POSTBOX_EVIDENCE = "postbox.evidence"
CAPABILITY_POSTBOX_ROUTING = "postbox.routing"
PostboxAction = Literal[
"discover",
"read",
"send",
"reply",
"acknowledge",
"administer",
]
PostboxMessageListState = Literal["all", "unread", "read", "acknowledged"]
PostboxMessageAvailability = Literal["available", "withdrawn", "expired"]
PostboxBindingStatus = Literal[
"active",
"missing",
"not_effective",
"unit_missing",
"unit_inactive",
"unit_tenant_mismatch",
"function_missing",
"function_inactive",
"function_tenant_mismatch",
"function_reassigned",
"directory_unavailable",
]
POSTBOX_CLASSIFICATIONS: tuple[EventClassification, ...] = (
"public",
"internal",
"confidential",
"restricted",
)
_POSTBOX_CLASSIFICATION_RANK = {
value: rank for rank, value in enumerate(POSTBOX_CLASSIFICATIONS)
}
def normalize_postbox_classification(
value: str,
) -> EventClassification | None:
candidate = value.strip().casefold()
if candidate not in _POSTBOX_CLASSIFICATION_RANK:
return None
return candidate # type: ignore[return-value]
def postbox_classification_allows(
ceiling: str,
content: str,
) -> bool:
normalized_ceiling = normalize_postbox_classification(ceiling)
normalized_content = normalize_postbox_classification(content)
if normalized_ceiling is None or normalized_content is None:
return False
return (
_POSTBOX_CLASSIFICATION_RANK[normalized_content]
<= _POSTBOX_CLASSIFICATION_RANK[normalized_ceiling]
)
@dataclass(frozen=True, slots=True)
class PostboxActorRef:
account_id: str
identity_id: str | None = None
selected_assignment_id: str | None = None
acting_for_account_id: str | None = None
authorized_actions: frozenset[PostboxAction] = frozenset()
authorized_classifications: frozenset[EventClassification] = frozenset(
{"public", "internal"}
)
@dataclass(frozen=True, slots=True)
class PostboxTargetRef:
postbox_id: str | None = None
address_key: str | None = None
template_id: str | None = None
organization_unit_id: str | None = None
function_id: str | None = None
context_key: str | None = None
@dataclass(frozen=True, slots=True)
class PostboxDeliveryTemplateRef:
id: str
slug: str
name: str
description: str | None
published_revision_id: str
function_type_id: str | None
scope_kind: str
scope_id: str | None
classification: str
allow_vacant_delivery: bool
@dataclass(frozen=True, slots=True)
class PostboxOrganizationFunctionTargetRef:
id: str
slug: str
name: str
function_type_id: str | None = None
@dataclass(frozen=True, slots=True)
class PostboxOrganizationUnitTargetRef:
id: str
slug: str
name: str
unit_type_id: str | None = None
parent_id: str | None = None
functions: tuple[PostboxOrganizationFunctionTargetRef, ...] = ()
@dataclass(frozen=True, slots=True)
class PostboxDeliveryCatalogRef:
postboxes: tuple["PostboxDirectoryEntryRef", ...] = ()
templates: tuple[PostboxDeliveryTemplateRef, ...] = ()
organization_units: tuple[PostboxOrganizationUnitTargetRef, ...] = ()
@dataclass(frozen=True, slots=True)
class PostboxAccessDecisionRef:
allowed: bool
action: PostboxAction
postbox_id: str
reason_code: str
explanation: str
organization_unit_id: str | None = None
function_id: str | None = None
assignment_ids: tuple[str, ...] = ()
assignment_sources: tuple[str, ...] = ()
selected_assignment_id: str | None = None
holder_count: int = 0
vacant: bool = True
classification: str = "internal"
classification_allowed: bool = True
binding_status: PostboxBindingStatus = "active"
@dataclass(frozen=True, slots=True)
class PostboxDirectoryEntryRef:
id: str
tenant_id: str
address: str
address_key: str
name: str
status: str
classification: str
organization_unit_id: str | None = None
organization_unit_name: str | None = None
function_id: str | None = None
function_name: str | None = None
context_key: str | None = None
template_revision_id: str | None = None
holder_count: int = 0
vacant: bool = True
access: PostboxAccessDecisionRef | None = None
resource_revision: int = 1
etag: str | None = None
@dataclass(frozen=True, slots=True)
class PostboxParticipantRef:
kind: str
reference_type: str
reference_id: str | None = None
label: str | None = None
address: str | None = None
@dataclass(frozen=True, slots=True)
class PostboxAttachmentRef:
reference_type: str
reference_id: str
name: str | None = None
media_type: str | None = None
size_bytes: int | None = None
digest: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxMessageRef:
id: str
tenant_id: str
postbox_id: str
subject: str
body_text: str | None
status: str
availability: PostboxMessageAvailability
classification: str
sender_label: str | None
delivered_at: datetime
read_at: datetime | None = None
acknowledged_at: datetime | None = None
expires_at: datetime | None = None
withdrawn_at: datetime | None = None
producer_module: str | None = None
producer_resource_type: str | None = None
producer_resource_id: str | None = None
in_reply_to_message_id: str | None = None
replaces_message_id: str | None = None
encryption_profile: str = "plaintext_v1"
key_epoch: int = 1
ciphertext_ref: str | None = None
signed_manifest_ref: str | None = None
participants: tuple[PostboxParticipantRef, ...] = ()
attachments: tuple[PostboxAttachmentRef, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxMessageAuthoringRequest:
idempotency_key: str
subject: str
body_text: str | None = None
classification: str = "internal"
participants: tuple[PostboxParticipantRef, ...] = ()
attachments: tuple[PostboxAttachmentRef, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxDeliveryReceiptSummaryRef:
delivery_id: str
message_id: str
postbox_id: str
delivery_status: str
accepted_at: datetime
current_holder_count: int = 0
currently_readable: bool = False
message_count: int = 1
routed_message_count: int = 0
readable_message_count: int = 0
read_receipt_count: int = 0
acknowledged_receipt_count: int = 0
withdrawn_message_count: int = 0
expired_message_count: int = 0
first_read_at: datetime | None = None
last_read_at: datetime | None = None
first_acknowledged_at: datetime | None = None
last_acknowledged_at: datetime | None = None
route_status_counts: Mapping[str, int] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxDeliveryRequest:
tenant_id: str
target: PostboxTargetRef
producer_module: str
producer_resource_type: str
producer_resource_id: str | None
idempotency_key: str
subject: str
body_text: str | None = None
sender_label: str | None = None
classification: str = "internal"
participants: tuple[PostboxParticipantRef, ...] = ()
attachments: tuple[PostboxAttachmentRef, ...] = ()
expires_at: datetime | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxDeliveryResult:
delivery_id: str
postbox_id: str
message_id: str
address: str
status: str
vacant: bool
holder_count: int
duplicate: bool = False
evidence: Mapping[str, object] = field(default_factory=dict)
class PostboxDeliveryRejected(RuntimeError):
"""A delivery was rejected before the provider accepted any effect."""
def __init__(
self,
code: str,
message: str,
*,
temporary: bool = False,
) -> None:
super().__init__(message)
self.code = code
self.temporary = temporary
class PostboxDeliveryOutcomeUnknown(RuntimeError):
"""The provider may have accepted an effect and must not be bypassed."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
@runtime_checkable
class PostboxDirectoryProvider(Protocol):
def list_visible_postboxes(
self,
session: object,
*,
tenant_id: str,
actor: PostboxActorRef,
) -> Sequence[PostboxDirectoryEntryRef]:
...
def resolve_postbox(
self,
session: object,
*,
tenant_id: str,
target: PostboxTargetRef,
materialize: bool = False,
) -> PostboxDirectoryEntryRef | None:
...
def delivery_catalog(
self,
session: object,
*,
tenant_id: str,
) -> PostboxDeliveryCatalogRef:
...
@runtime_checkable
class PostboxAccessProvider(Protocol):
def explain_access(
self,
session: object,
*,
tenant_id: str,
postbox_id: str,
actor: PostboxActorRef,
action: PostboxAction,
) -> PostboxAccessDecisionRef:
...
@runtime_checkable
class PostboxMessagesProvider(Protocol):
def list_messages(
self,
session: object,
*,
tenant_id: str,
postbox_ids: Sequence[str],
actor: PostboxActorRef,
limit: int = 100,
offset: int = 0,
query: str | None = None,
state: PostboxMessageListState = "all",
) -> Sequence[PostboxMessageRef]:
...
def get_message(
self,
session: object,
*,
tenant_id: str,
message_id: str,
actor: PostboxActorRef,
) -> PostboxMessageRef | None:
...
def mark_message(
self,
session: object,
*,
tenant_id: str,
message_id: str,
actor: PostboxActorRef,
state: Literal["read", "acknowledged"],
) -> PostboxMessageRef:
...
def create_message(
self,
session: object,
*,
tenant_id: str,
postbox_id: str,
actor: PostboxActorRef,
request: PostboxMessageAuthoringRequest,
) -> PostboxMessageRef:
...
def reply_to_message(
self,
session: object,
*,
tenant_id: str,
message_id: str,
actor: PostboxActorRef,
request: PostboxMessageAuthoringRequest,
) -> PostboxMessageRef:
...
@runtime_checkable
class PostboxDeliveryProvider(Protocol):
def deliver(
self,
session: object,
request: PostboxDeliveryRequest,
) -> PostboxDeliveryResult:
...
@runtime_checkable
class PostboxEvidenceProvider(Protocol):
def link_evidence(
self,
session: object,
*,
tenant_id: str,
message_id: str,
attachment: PostboxAttachmentRef,
) -> PostboxMessageRef:
...
def delivery_receipt_summaries(
self,
session: object,
*,
tenant_id: str,
producer_module: str,
delivery_ids: Sequence[str],
) -> Mapping[str, PostboxDeliveryReceiptSummaryRef]:
...
@runtime_checkable
class PostboxRoutingProvider(Protocol):
def dispatch_due_routes(
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 50,
) -> Mapping[str, object]:
...
def _postbox_provider(
registry: object | None,
*,
capability_name: str,
provider_type: type,
) -> object | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(capability_name):
return None
capability = registry.capability(capability_name)
return capability if isinstance(capability, provider_type) else None
def postbox_directory_provider(
registry: object | None,
) -> PostboxDirectoryProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_DIRECTORY,
provider_type=PostboxDirectoryProvider,
)
return provider if isinstance(provider, PostboxDirectoryProvider) else None
def postbox_access_provider(
registry: object | None,
) -> PostboxAccessProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_ACCESS,
provider_type=PostboxAccessProvider,
)
return provider if isinstance(provider, PostboxAccessProvider) else None
def postbox_messages_provider(
registry: object | None,
) -> PostboxMessagesProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_MESSAGES,
provider_type=PostboxMessagesProvider,
)
return provider if isinstance(provider, PostboxMessagesProvider) else None
def postbox_delivery_provider(
registry: object | None,
) -> PostboxDeliveryProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_DELIVERY,
provider_type=PostboxDeliveryProvider,
)
return provider if isinstance(provider, PostboxDeliveryProvider) else None
def postbox_evidence_provider(
registry: object | None,
) -> PostboxEvidenceProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_EVIDENCE,
provider_type=PostboxEvidenceProvider,
)
return provider if isinstance(provider, PostboxEvidenceProvider) else None
def postbox_routing_provider(
registry: object | None,
) -> PostboxRoutingProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_ROUTING,
provider_type=PostboxRoutingProvider,
)
return provider if isinstance(provider, PostboxRoutingProvider) else None
+111
View File
@@ -0,0 +1,111 @@
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from govoplan_core.core.change_sequence import (
ChangeSequenceEntry,
retained_sequence_floor,
record_change,
)
AUTH_PRINCIPAL_REVISION_COLLECTION = "core.auth-principal-revisions"
@dataclass(frozen=True, slots=True)
class AuthPrincipalRevision:
system: int
tenant: int
def auth_principal_revision(
session: Session,
*,
tenant_id: str | None,
) -> AuthPrincipalRevision:
"""Return the durable global and tenant authorization revision.
The sequence rows make invalidation visible across processes. Retention
floors keep revisions monotonic if old change rows are pruned.
"""
rows = (
session.query(
ChangeSequenceEntry.tenant_id,
func.max(ChangeSequenceEntry.id),
)
.filter(
ChangeSequenceEntry.module_id == "core",
ChangeSequenceEntry.collection == AUTH_PRINCIPAL_REVISION_COLLECTION,
or_(
ChangeSequenceEntry.tenant_id.is_(None),
ChangeSequenceEntry.tenant_id == tenant_id,
),
)
.group_by(ChangeSequenceEntry.tenant_id)
.all()
)
revisions = {row_tenant_id: int(sequence_id or 0) for row_tenant_id, sequence_id in rows}
system = max(
revisions.get(None, 0),
retained_sequence_floor(
session,
tenant_id=None,
module_id="core",
collections=(AUTH_PRINCIPAL_REVISION_COLLECTION,),
),
)
tenant = 0
if tenant_id is not None:
tenant = max(
revisions.get(tenant_id, 0),
retained_sequence_floor(
session,
tenant_id=tenant_id,
module_id="core",
collections=(AUTH_PRINCIPAL_REVISION_COLLECTION,),
),
)
return AuthPrincipalRevision(system=system, tenant=tenant)
def invalidate_auth_principals(
session: Session,
*,
tenant_id: str | None,
source_module: str,
resource_type: str,
resource_id: str,
actor_type: str | None = None,
actor_id: str | None = None,
reason: str | None = None,
) -> ChangeSequenceEntry:
"""Advance the authorization revision in the caller's transaction."""
return record_change(
session,
tenant_id=tenant_id,
module_id="core",
collection=AUTH_PRINCIPAL_REVISION_COLLECTION,
resource_type="auth_principal_revision",
resource_id=tenant_id or "system",
operation="invalidated",
actor_type=actor_type,
actor_id=actor_id,
payload={
"source_module": source_module,
"resource_type": resource_type,
"resource_id": resource_id,
**({"reason": reason} if reason else {}),
},
)
__all__ = [
"AUTH_PRINCIPAL_REVISION_COLLECTION",
"AuthPrincipalRevision",
"auth_principal_revision",
"invalidate_auth_principals",
]
+499
View File
@@ -0,0 +1,499 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.access import (
CAPABILITY_ACCESS_DIRECTORY,
AccessDirectory,
GroupRef,
UserRef,
)
CAPABILITY_ACCESS_REFERENCE_OPTIONS = "access.reference_options"
ReferenceAvailability = Literal["available", "inactive", "unavailable"]
@dataclass(frozen=True, slots=True)
class ReferenceOption:
"""A stable identifier paired with presentation-only directory metadata."""
value: str
label: str
description: str | None = None
kind: str | None = None
availability: ReferenceAvailability = "available"
disabled: bool = False
source_module: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
def to_dict(self) -> dict[str, object]:
return {
"value": self.value,
"label": self.label,
"description": self.description,
"kind": self.kind,
"availability": self.availability,
"disabled": self.disabled,
"source_module": self.source_module,
"provenance": dict(self.provenance),
}
@dataclass(frozen=True, slots=True)
class ReferenceSearchRequest:
kind: str
tenant_id: str | None
query: str = ""
selected_values: tuple[str, ...] = ()
limit: int = 50
cursor: str | None = None
context: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ReferenceSearchPage:
options: tuple[ReferenceOption, ...] = ()
next_cursor: str | None = None
has_more: bool = False
@runtime_checkable
class ReferenceOptionProvider(Protocol):
"""Optional module-neutral provider for typed reference selectors."""
def search_reference_options(
self,
session: object,
principal: object,
*,
request: ReferenceSearchRequest,
) -> ReferenceSearchPage:
...
def reference_option_provider(
registry: object | None,
capability_name: str,
) -> ReferenceOptionProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(capability_name)
):
return None
provider = registry.capability(capability_name)
return provider if isinstance(provider, ReferenceOptionProvider) else None
def access_scope_reference_options(
registry: object | None,
principal: object,
*,
scope_type: str,
query: str = "",
selected_values: Sequence[str] = (),
limit: int = 50,
administrative: bool = False,
session: object | None = None,
reference_kind: str | None = None,
) -> tuple[ReferenceOption, ...]:
"""Return canonical user-account or group references for definition scopes."""
return access_scope_reference_page(
registry,
principal,
scope_type=scope_type,
query=query,
selected_values=selected_values,
limit=limit,
administrative=administrative,
session=session,
reference_kind=reference_kind,
).options
def access_scope_reference_page(
registry: object | None,
principal: object,
*,
scope_type: str,
query: str = "",
selected_values: Sequence[str] = (),
limit: int = 50,
cursor: str | None = None,
administrative: bool = False,
session: object | None = None,
reference_kind: str | None = None,
) -> ReferenceSearchPage:
"""Search bounded Access references while retaining selected values."""
clean_type = str(scope_type or "").strip().casefold()
if clean_type not in {"user", "group"}:
raise ValueError("Scope reference type must be user or group.")
clean_kind = str(reference_kind or clean_type).strip().casefold()
if clean_kind not in (
{"user", "membership"} if clean_type == "user" else {"group"}
):
raise ValueError("Scope reference kind is incompatible with the scope type.")
normalized_limit = max(1, min(int(limit), 200))
selected = tuple(
dict.fromkeys(
str(value).strip() for value in selected_values if str(value).strip()
)
)[:200]
tenant_id = str(getattr(principal, "tenant_id", "") or "")
provider = reference_option_provider(
registry,
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
)
if provider is not None:
if not tenant_id:
return ReferenceSearchPage(
options=tuple(
_unavailable_option(value, kind=clean_kind)
for value in selected
)
)
page = provider.search_reference_options(
session,
principal,
request=ReferenceSearchRequest(
kind=clean_kind,
tenant_id=tenant_id,
query=str(query or "").strip().casefold(),
selected_values=selected,
limit=normalized_limit,
cursor=str(cursor).strip() if cursor else None,
context={"administrative": administrative},
),
)
return ReferenceSearchPage(
options=_bounded_page_options(
page.options,
selected_values=selected,
limit=normalized_limit,
kind=clean_kind,
),
next_cursor=page.next_cursor,
has_more=page.has_more,
)
directory = _access_directory(registry)
if directory is None:
return ReferenceSearchPage(
options=_fallback_scope_options(
principal,
scope_type=clean_type,
reference_kind=clean_kind,
query=query,
selected_values=selected,
limit=normalized_limit,
)
)
if not tenant_id:
return ReferenceSearchPage(
options=tuple(
_unavailable_option(value, kind=clean_kind) for value in selected
)
)
if clean_type == "user":
candidates = _user_options(
directory.users_for_tenant(tenant_id),
principal=principal,
administrative=administrative,
value_kind=clean_kind,
)
else:
candidates = _group_options(
directory.groups_for_tenant(tenant_id),
principal=principal,
administrative=administrative,
)
return ReferenceSearchPage(
options=_filter_and_retain_options(
candidates,
query=query,
selected_values=selected,
limit=normalized_limit,
kind=clean_kind,
)
)
def access_scope_reference_provider_available(registry: object | None) -> bool:
return (
reference_option_provider(
registry,
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
)
is not None
or _access_directory(registry) is not None
)
def validate_access_scope_reference(
registry: object | None,
*,
tenant_id: str | None,
scope_type: str,
scope_id: str | None,
preserve_existing: str | None = None,
) -> str | None:
"""Validate new directory-backed scope references while preserving history."""
clean_type = str(scope_type or "").strip().casefold()
clean_id = str(scope_id or "").strip() or None
if clean_type not in {"user", "group"}:
return clean_id
if not clean_id:
raise ValueError(f"{clean_type.capitalize()} definitions require a scope ID.")
if clean_id == preserve_existing:
return clean_id
directory = _access_directory(registry)
if directory is None:
return clean_id
if not tenant_id:
raise ValueError("Directory-backed scopes require an active tenant.")
if clean_type == "group":
group = directory.get_group(clean_id)
if group is None or group.tenant_id != tenant_id:
raise ValueError("The selected group is not available in the active tenant.")
if group.status != "active":
raise ValueError("Inactive groups cannot be selected for new definitions.")
return group.id
matching_user = next(
(
user
for user in directory.users_for_tenant(tenant_id)
if user.account_id == clean_id
),
None,
)
if matching_user is None:
raise ValueError("The selected user account is not available in the active tenant.")
if matching_user.status != "active":
raise ValueError("Inactive users cannot be selected for new definitions.")
account = directory.get_account(matching_user.account_id)
if account is not None and account.status != "active":
raise ValueError("Inactive user accounts cannot be selected for new definitions.")
return matching_user.account_id
def _access_directory(registry: object | None) -> AccessDirectory | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY)
):
return None
provider = registry.capability(CAPABILITY_ACCESS_DIRECTORY)
return provider if isinstance(provider, AccessDirectory) else None
def _user_options(
users: Sequence[UserRef],
*,
principal: object,
administrative: bool,
value_kind: str = "user",
) -> tuple[ReferenceOption, ...]:
principal_account_id = str(getattr(principal, "account_id", "") or "")
options: dict[str, ReferenceOption] = {}
for user in users:
if not administrative and user.account_id != principal_account_id:
continue
inactive = user.status != "active"
label = user.display_name or user.email or user.account_id
detail_parts = [part for part in (user.email, "Inactive" if inactive else None) if part]
value = user.id if value_kind == "membership" else user.account_id
options[value] = ReferenceOption(
value=value,
label=label,
description=" · ".join(detail_parts) or None,
kind=value_kind,
availability="inactive" if inactive else "available",
disabled=inactive,
source_module="access",
provenance={
"membership_id": user.id,
"tenant_id": user.tenant_id,
"account_id": user.account_id,
},
)
return tuple(options.values())
def _group_options(
groups: Sequence[GroupRef],
*,
principal: object,
administrative: bool,
) -> tuple[ReferenceOption, ...]:
permitted = {
str(value)
for value in getattr(principal, "group_ids", ())
if str(value)
}
return tuple(
ReferenceOption(
value=group.id,
label=group.name or group.id,
description="Inactive" if group.status != "active" else None,
kind="group",
availability=(
"inactive" if group.status != "active" else "available"
),
disabled=group.status != "active",
source_module="access",
provenance={"tenant_id": group.tenant_id, "group_id": group.id},
)
for group in groups
if administrative or group.id in permitted
)
def _filter_and_retain_options(
options: Sequence[ReferenceOption],
*,
query: str,
selected_values: Sequence[str],
limit: int,
kind: str,
) -> tuple[ReferenceOption, ...]:
by_value = {option.value: option for option in options}
needle = str(query or "").strip().casefold()
matches = [
option
for option in options
if not needle
or needle
in " ".join(
(
option.value,
option.label,
option.description or "",
)
).casefold()
][:limit]
returned = {option.value for option in matches}
for value in selected_values:
if value in returned:
continue
matches.append(by_value.get(value) or _unavailable_option(value, kind=kind))
returned.add(value)
return tuple(matches)
def _bounded_page_options(
options: Sequence[ReferenceOption],
*,
selected_values: Sequence[str],
limit: int,
kind: str,
) -> tuple[ReferenceOption, ...]:
by_value = {option.value: option for option in options}
selected = set(selected_values)
bounded = [
option for option in options if option.value not in selected
][:limit]
returned = {option.value for option in bounded}
for value in selected_values:
if value in returned:
continue
bounded.append(by_value.get(value) or _unavailable_option(value, kind=kind))
returned.add(value)
return tuple(bounded)
def _fallback_scope_options(
principal: object,
*,
scope_type: str,
reference_kind: str | None = None,
query: str,
selected_values: Sequence[str],
limit: int,
) -> tuple[ReferenceOption, ...]:
candidates: list[ReferenceOption] = []
if scope_type == "user":
kind = str(reference_kind or "user")
value = str(
(
getattr(principal, "membership_id", "")
if kind == "membership"
else getattr(principal, "account_id", "")
)
or ""
)
if value:
candidates.append(
ReferenceOption(
value=value,
label=(
str(getattr(principal, "display_name", "") or "")
or str(getattr(principal, "email", "") or "")
or value
),
description="Current user · Access directory unavailable",
kind=kind,
source_module="core",
provenance={"fallback": True},
)
)
else:
candidates.extend(
ReferenceOption(
value=str(group_id),
label=str(group_id),
description="Permitted group · Access directory unavailable",
kind="group",
source_module="core",
provenance={"fallback": True},
)
for group_id in getattr(principal, "group_ids", ())
if str(group_id)
)
return _filter_and_retain_options(
candidates,
query=query,
selected_values=selected_values,
limit=limit,
kind=scope_type,
)
def _unavailable_option(value: str, *, kind: str) -> ReferenceOption:
return ReferenceOption(
value=value,
label=f"Unavailable {kind}",
description=value,
kind=kind,
availability="unavailable",
disabled=True,
source_module=None,
provenance={"retained_reference": True},
)
__all__ = [
"CAPABILITY_ACCESS_REFERENCE_OPTIONS",
"ReferenceAvailability",
"ReferenceOption",
"ReferenceOptionProvider",
"ReferenceSearchPage",
"ReferenceSearchRequest",
"access_scope_reference_page",
"access_scope_reference_options",
"access_scope_reference_provider_available",
"reference_option_provider",
"validate_access_scope_reference",
]
+548 -3
View File
@@ -16,13 +16,36 @@ from govoplan_core.core.modules import (
ModuleManifest,
NavItem,
PermissionDefinition,
PublicFrontendRoute,
ResourceAclProvider,
RoleTemplate,
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
SUPPORTED_MANIFEST_CONTRACT_VERSION,
TenantSummaryBatchProvider,
TenantSummaryProvider,
user_workflow_scope_condition_issues,
)
from govoplan_core.core.ownership import (
OwnershipProviderRegistration,
ResourceOwnershipProvider,
)
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
from govoplan_core.core.search import (
RegisteredSearchProvider,
RegisteredSearchSourceProvider,
SearchProvider,
SearchSourceProvider,
)
from govoplan_core.core.views import (
ViewSurface,
module_view_surface_id,
navigation_view_surface_id,
route_view_surface_id,
validate_view_surface_id,
)
from govoplan_core.core.workflows import (
workflow_definition_contribution_hash,
)
_MODULE_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
_NPM_PACKAGE_RE = re.compile(r"^(?:@[a-z0-9][a-z0-9_.-]*/)?[a-z0-9][a-z0-9_.-]*$")
@@ -47,10 +70,18 @@ class PlatformRegistry:
def __init__(self) -> None:
self._manifests: dict[str, ModuleManifest] = {}
self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {}
self._tenant_summary_batch_providers: dict[str, TenantSummaryBatchProvider] = {}
self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = defaultdict(list)
self._ownership_providers: dict[str, OwnershipProviderRegistration] = {}
self._capability_factories: dict[str, CapabilityFactory] = {}
self._capabilities: dict[str, object] = {}
self._capability_context: ModuleContext | None = None
self._search_provider_registrations: list[RegisteredSearchProvider] = []
self._search_providers: dict[str, SearchProvider] = {}
self._search_source_registrations: list[
RegisteredSearchSourceProvider
] = []
self._search_sources: dict[str, SearchSourceProvider] = {}
def register(self, manifest: ModuleManifest) -> ModuleManifest:
if manifest.id in self._manifests:
@@ -58,11 +89,29 @@ class PlatformRegistry:
self._manifests[manifest.id] = manifest
for provider in manifest.tenant_summary_providers:
self.register_tenant_summary_provider(manifest.id, provider)
for provider in manifest.tenant_summary_batch_providers:
self.register_tenant_summary_batch_provider(manifest.id, provider)
for resource_type, providers in manifest.delete_veto_providers.items():
for provider in providers:
self.register_delete_veto(manifest.id, resource_type, provider)
for registration in manifest.ownership_providers:
self.register_ownership_provider(manifest.id, registration)
for name, factory in manifest.capability_factories.items():
self.register_capability_factory(manifest.id, name, factory)
for registration in manifest.search_providers:
self._search_provider_registrations.append(
RegisteredSearchProvider(
module_id=manifest.id,
registration=registration,
)
)
for registration in manifest.search_sources:
self._search_source_registrations.append(
RegisteredSearchSourceProvider(
module_id=manifest.id,
registration=registration,
)
)
return manifest
def replace(self, manifests: Iterable[ModuleManifest]) -> RegistrySnapshot:
@@ -75,12 +124,24 @@ class PlatformRegistry:
self._manifests = dict(replacement._manifests)
self._tenant_summary_providers = dict(replacement._tenant_summary_providers)
self._tenant_summary_batch_providers = dict(
replacement._tenant_summary_batch_providers
)
self._delete_veto_providers = defaultdict(list, {
resource_type: list(providers)
for resource_type, providers in replacement._delete_veto_providers.items()
})
self._ownership_providers = dict(replacement._ownership_providers)
self._capability_factories = dict(replacement._capability_factories)
self._search_provider_registrations = list(
replacement._search_provider_registrations
)
self._search_source_registrations = list(
replacement._search_source_registrations
)
self._capabilities.clear()
self._search_providers.clear()
self._search_sources.clear()
return snapshot
def get(self, module_id: str) -> ModuleManifest | None:
@@ -111,12 +172,59 @@ class PlatformRegistry:
def nav_items(self) -> tuple[NavItem, ...]:
return tuple(sorted((item for manifest in self.manifests() for item in manifest.nav_items), key=lambda item: item.order))
def view_surfaces(self) -> tuple[ViewSurface, ...]:
return tuple(
surface
for manifest in self.manifests()
for surface in manifest_view_surfaces(manifest)
)
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers)
def register_ownership_provider(
self,
module_id: str,
registration: OwnershipProviderRegistration,
) -> None:
resource_type = registration.resource_type.strip().lower()
if not resource_type:
raise RegistryError(
f"Ownership provider in {module_id} has no resource type"
)
if resource_type in self._ownership_providers:
raise RegistryError(
f"Duplicate ownership provider for resource type: {resource_type}"
)
if not isinstance(registration.provider, ResourceOwnershipProvider):
raise RegistryError(
f"Ownership provider for {resource_type} does not implement "
"ResourceOwnershipProvider"
)
self._ownership_providers[resource_type] = OwnershipProviderRegistration(
resource_type=resource_type,
provider=registration.provider,
module_id=module_id,
)
def ownership_provider(
self,
resource_type: str,
) -> ResourceOwnershipProvider | None:
registration = self.ownership_provider_registration(resource_type)
return registration.provider if registration else None
def ownership_provider_registration(
self,
resource_type: str,
) -> OwnershipProviderRegistration | None:
return self._ownership_providers.get(resource_type.strip().lower())
def configure_capability_context(self, context: ModuleContext) -> None:
self._capability_context = context
self._capabilities.clear()
self._search_providers.clear()
self._search_sources.clear()
def register_capability_factory(self, module_id: str, name: str, factory: CapabilityFactory) -> None:
if name in self._capability_factories:
@@ -126,6 +234,9 @@ class PlatformRegistry:
def has_capability(self, name: str) -> bool:
return name in self._capability_factories
def capability_names(self) -> tuple[str, ...]:
return tuple(sorted(self._capability_factories))
def capability(self, name: str) -> object | None:
if name in self._capabilities:
return self._capabilities[name]
@@ -144,12 +255,95 @@ class PlatformRegistry:
raise RegistryError(f"Required capability is not available: {name}")
return capability
def search_provider_registrations(
self,
) -> tuple[RegisteredSearchProvider, ...]:
return tuple(
sorted(
self._search_provider_registrations,
key=lambda item: (
item.registration.order,
item.module_id,
item.registration.id,
),
)
)
def search_providers(
self,
) -> tuple[tuple[RegisteredSearchProvider, SearchProvider], ...]:
if self._capability_context is None:
if self._search_provider_registrations:
raise RegistryError("Search provider context is not configured.")
return ()
providers: list[tuple[RegisteredSearchProvider, SearchProvider]] = []
for registered in self.search_provider_registrations():
key = f"{registered.module_id}:{registered.registration.id}"
provider = self._search_providers.get(key)
if provider is None:
provider = registered.registration.create(self._capability_context)
self._search_providers[key] = provider
providers.append((registered, provider))
return tuple(providers)
def search_source_registrations(
self,
) -> tuple[RegisteredSearchSourceProvider, ...]:
return tuple(
sorted(
self._search_source_registrations,
key=lambda item: (
item.registration.order,
item.module_id,
item.registration.id,
),
)
)
def search_sources(
self,
) -> tuple[
tuple[RegisteredSearchSourceProvider, SearchSourceProvider],
...,
]:
if self._capability_context is None:
if self._search_source_registrations:
raise RegistryError(
"Search source context is not configured."
)
return ()
providers: list[
tuple[RegisteredSearchSourceProvider, SearchSourceProvider]
] = []
for registered in self.search_source_registrations():
key = f"{registered.module_id}:{registered.registration.id}"
provider = self._search_sources.get(key)
if provider is None:
provider = registered.registration.create(
self._capability_context
)
self._search_sources[key] = provider
providers.append((registered, provider))
return tuple(providers)
def register_tenant_summary_provider(self, module_id: str, provider: TenantSummaryProvider) -> None:
self._tenant_summary_providers[module_id] = provider
def tenant_summary_providers(self) -> Mapping[str, TenantSummaryProvider]:
return dict(self._tenant_summary_providers)
def register_tenant_summary_batch_provider(
self,
module_id: str,
provider: TenantSummaryBatchProvider,
) -> None:
self._tenant_summary_batch_providers[module_id] = provider
def tenant_summary_batch_providers(
self,
) -> Mapping[str, TenantSummaryBatchProvider]:
return dict(self._tenant_summary_batch_providers)
def register_delete_veto(self, module_id: str, resource_type: str, provider: DeleteVetoProvider) -> None:
self._delete_veto_providers[resource_type].append(DeleteVetoProviderRegistration(
module_id=module_id,
@@ -194,6 +388,7 @@ class PlatformRegistry:
available_capabilities=available_capabilities,
)
permissions = _collect_manifest_permissions(ordered)
_validate_public_frontend_route_uniqueness(ordered)
_validate_interface_closure(ordered)
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
@@ -227,6 +422,53 @@ class PlatformRegistry:
return (self._manifests[module_id] for module_id in ordered)
def manifest_view_surfaces(manifest: ModuleManifest) -> tuple[ViewSurface, ...]:
frontend = manifest.frontend
if frontend is None:
return ()
root_id = module_view_surface_id(manifest.id)
surfaces = [
ViewSurface(
id=root_id,
module_id=manifest.id,
kind="module",
label=manifest.name,
order=min((item.order for item in frontend.nav_items), default=100),
)
]
surfaces.extend(
ViewSurface(
id=item.surface_id or navigation_view_surface_id(manifest.id, item.path),
module_id=manifest.id,
kind="navigation",
label=item.label,
parent_id=root_id,
order=item.order,
)
for item in frontend.nav_items
)
surfaces.extend(
ViewSurface(
id=route.surface_id or route_view_surface_id(manifest.id, route.path),
module_id=manifest.id,
kind="route",
label=route.component,
parent_id=root_id,
description=route.path,
order=route.order,
)
for route in (*frontend.routes, *frontend.settings_routes)
)
surfaces.extend(
replace(
surface,
parent_id=surface.parent_id or root_id,
)
for surface in frontend.view_surfaces
)
return tuple(surfaces)
def _normalize_delete_veto_result(
result: object,
*,
@@ -304,8 +546,12 @@ def _validate_manifest_permission(
permission: PermissionDefinition,
seen_permissions: Mapping[str, PermissionDefinition],
) -> None:
if permission.module_id != manifest.id:
raise RegistryError(f"Permission {permission.scope!r} has mismatched module id {permission.module_id!r}")
permission_namespace = manifest.permission_namespace or manifest.id
if permission.module_id != permission_namespace:
raise RegistryError(
f"Permission {permission.scope!r} has mismatched module id "
f"{permission.module_id!r}; expected {permission_namespace!r}"
)
if not _SCOPE_RE.match(permission.scope):
raise RegistryError(f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}")
expected_prefix = f"{permission.module_id}:{permission.resource}:"
@@ -320,9 +566,34 @@ def _validate_role_template_scopes(
*,
known_scopes: set[str],
) -> None:
seen_templates: dict[tuple[str, str], str] = {}
for manifest in manifests:
for template in manifest.role_templates:
template_key = (template.level, template.slug)
previous_module = seen_templates.get(template_key)
if previous_module is not None:
raise RegistryError(
f"Duplicate {template.level} role template slug {template.slug!r} "
f"in modules {previous_module!r} and {manifest.id!r}"
)
seen_templates[template_key] = manifest.id
if template.default_authenticated and template.level != "tenant":
raise RegistryError(
f"Default authenticated role template {template.slug!r} must be tenant-level"
)
if template.default_authenticated and not template.managed:
raise RegistryError(
f"Default authenticated role template {template.slug!r} must be managed"
)
for scope in template.permissions:
if template.default_authenticated and (
scope in {"*", "tenant:*", "system:*"}
or _WILDCARD_RE.match(scope)
):
raise RegistryError(
f"Default authenticated role template {template.slug!r} "
"must use explicit permissions, not wildcard scopes"
)
if _role_template_scope_known(scope, known_scopes):
continue
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
@@ -344,6 +615,119 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
_validate_manifest_frontend(manifest)
for item in manifest.nav_items:
_validate_nav_item(manifest.id, item)
for topic in manifest.documentation:
for issue in user_workflow_scope_condition_issues(topic):
raise RegistryError(
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
)
_validate_documentation_extensions(manifest)
_validate_workflow_definition_contributions(manifest)
def _validate_workflow_definition_contributions(
manifest: ModuleManifest,
) -> None:
seen_keys: set[str] = set()
for contribution in manifest.workflow_definitions:
if contribution.origin_module_id != manifest.id:
raise RegistryError(
f"Workflow contribution {contribution.definition_key!r} in "
f"module {manifest.id!r} declares origin module "
f"{contribution.origin_module_id!r}"
)
if contribution.origin_module_version != manifest.version:
raise RegistryError(
f"Workflow contribution {contribution.definition_key!r} in "
f"module {manifest.id!r} declares origin version "
f"{contribution.origin_module_version!r}, expected "
f"{manifest.version!r}"
)
if not re.match(
r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,119}$",
contribution.definition_key,
):
raise RegistryError(
f"Workflow contribution key is invalid: "
f"{contribution.definition_key!r}"
)
if contribution.definition_key in seen_keys:
raise RegistryError(
f"Module {manifest.id!r} declares duplicate Workflow "
f"contribution {contribution.definition_key!r}"
)
seen_keys.add(contribution.definition_key)
if contribution.scope_type not in {"system", "tenant"}:
raise RegistryError(
f"Workflow contribution {contribution.definition_key!r} has "
f"unsupported scope {contribution.scope_type!r}"
)
expected_hash = workflow_definition_contribution_hash(contribution)
if (
contribution.content_hash is not None
and contribution.content_hash != expected_hash
):
raise RegistryError(
f"Workflow contribution {contribution.definition_key!r} has "
"a content hash that does not match its canonical content"
)
def _validate_documentation_extensions(manifest: ModuleManifest) -> None:
for capability, metadata in manifest.capability_documentation.items():
if capability not in manifest.capability_factories:
raise RegistryError(
f"Module {manifest.id!r} documents capability {capability!r} "
"but does not provide it"
)
if not metadata.label.strip():
raise RegistryError(
f"Module {manifest.id!r} capability {capability!r} "
"documentation must have a label"
)
if not metadata.summary.strip():
raise RegistryError(
f"Module {manifest.id!r} capability {capability!r} "
"documentation must have a summary"
)
if metadata.contract_version is not None and not metadata.contract_version.strip():
raise RegistryError(
f"Module {manifest.id!r} capability {capability!r} "
"documentation contract version must not be empty"
)
provider_keys: set[str] = set()
for registration in manifest.documentation_configuration_providers:
if not registration.keys:
raise RegistryError(
f"Module {manifest.id!r} documentation configuration provider must declare keys"
)
for raw_key in registration.keys:
key = raw_key.strip()
if not key:
raise RegistryError(
f"Module {manifest.id!r} documentation configuration provider declares an empty key"
)
if key in provider_keys:
raise RegistryError(
f"Module {manifest.id!r} declares duplicate documentation configuration key {key!r}"
)
provider_keys.add(key)
source_ids: set[str] = set()
for source in manifest.documentation_sources:
if not _INTERFACE_NAME_RE.match(source.id):
raise RegistryError(
f"Module {manifest.id!r} documentation source id must be namespaced: {source.id!r}"
)
if source.id in source_ids:
raise RegistryError(
f"Module {manifest.id!r} declares duplicate documentation source {source.id!r}"
)
source_ids.add(source.id)
if not source.label.strip():
raise RegistryError(
f"Module {manifest.id!r} documentation source {source.id!r} must have a label"
)
def _validate_manifest_identity(manifest: ModuleManifest) -> None:
@@ -353,6 +737,14 @@ def _validate_manifest_identity(manifest: ModuleManifest) -> None:
raise RegistryError(f"Module {manifest.id!r} must declare a non-empty name")
if not manifest.version.strip():
raise RegistryError(f"Module {manifest.id!r} must declare a non-empty version")
if (
manifest.permission_namespace is not None
and not _MODULE_ID_RE.match(manifest.permission_namespace)
):
raise RegistryError(
f"Module {manifest.id!r} has invalid permission namespace "
f"{manifest.permission_namespace!r}"
)
if manifest.compatibility.manifest_contract_version != SUPPORTED_MANIFEST_CONTRACT_VERSION:
raise RegistryError(
f"Module {manifest.id!r} uses unsupported manifest contract version "
@@ -368,6 +760,32 @@ def _validate_manifest_contract_lists(manifest: ModuleManifest) -> None:
_validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities)
_validate_interface_providers(manifest.id, manifest.provides_interfaces)
_validate_interface_requirements(manifest.id, manifest.requires_interfaces)
provider_ids: set[str] = set()
for registration in manifest.search_providers:
if not _INTERFACE_NAME_RE.match(registration.id):
raise RegistryError(
f"Module {manifest.id!r} search provider id must be namespaced: "
f"{registration.id!r}"
)
if registration.id in provider_ids:
raise RegistryError(
f"Module {manifest.id!r} declares duplicate search provider "
f"{registration.id!r}"
)
provider_ids.add(registration.id)
source_ids: set[str] = set()
for registration in manifest.search_sources:
if not _INTERFACE_NAME_RE.match(registration.id):
raise RegistryError(
f"Module {manifest.id!r} search source id must be "
f"namespaced: {registration.id!r}"
)
if registration.id in source_ids:
raise RegistryError(
f"Module {manifest.id!r} declares duplicate search source "
f"{registration.id!r}"
)
source_ids.add(registration.id)
def _validate_manifest_overlaps(manifest: ModuleManifest) -> None:
@@ -403,10 +821,119 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
)
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
for route in (*frontend.routes, *frontend.settings_routes):
for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes):
_validate_frontend_route(manifest.id, route.path, route.component)
for route in (*frontend.routes, *frontend.settings_routes):
if route.surface_id is not None:
_validate_view_surface_id(manifest.id, route.surface_id)
for item in frontend.nav_items:
_validate_nav_item(manifest.id, item)
if item.surface_id is not None:
_validate_view_surface_id(manifest.id, item.surface_id)
_validate_view_surfaces(manifest)
def _validate_view_surfaces(manifest: ModuleManifest) -> None:
frontend = manifest.frontend
if frontend is None:
return
root_id = module_view_surface_id(manifest.id)
known_ids = {root_id}
_validate_view_surface_id(manifest.id, root_id)
for item in frontend.nav_items:
surface_id = item.surface_id or navigation_view_surface_id(manifest.id, item.path)
_register_view_surface_id(manifest.id, surface_id, known_ids)
for route in (*frontend.routes, *frontend.settings_routes):
surface_id = route.surface_id or route_view_surface_id(manifest.id, route.path)
_register_view_surface_id(manifest.id, surface_id, known_ids)
for surface in frontend.view_surfaces:
_validate_declared_view_surface(manifest.id, surface)
_register_view_surface_id(manifest.id, surface.id, known_ids)
for surface in frontend.view_surfaces:
_validate_view_surface_definition(
manifest.id,
surface,
root_id=root_id,
known_ids=known_ids,
)
parent_by_id = {
surface.id: surface.parent_id or root_id
for surface in frontend.view_surfaces
}
_validate_view_surface_hierarchy(manifest.id, parent_by_id)
def _register_view_surface_id(
module_id: str,
surface_id: str,
known_ids: set[str],
) -> None:
_validate_view_surface_id(module_id, surface_id)
if surface_id in known_ids:
raise RegistryError(
f"Duplicate view surface id {surface_id!r} in module {module_id!r}"
)
known_ids.add(surface_id)
def _validate_declared_view_surface(
module_id: str,
surface: ViewSurface,
) -> None:
if surface.module_id != module_id:
raise RegistryError(
f"View surface {surface.id!r} belongs to {surface.module_id!r}, "
f"not module {module_id!r}"
)
def _validate_view_surface_definition(
module_id: str,
surface: ViewSurface,
*,
root_id: str,
known_ids: set[str],
) -> None:
parent_id = surface.parent_id or root_id
if parent_id not in known_ids:
raise RegistryError(
f"View surface {surface.id!r} in module {module_id!r} "
f"references unknown parent {parent_id!r}"
)
if surface.required and not surface.default_visible:
raise RegistryError(
f"Required view surface {surface.id!r} in module {module_id!r} "
"must be visible by default"
)
def _validate_view_surface_hierarchy(
module_id: str,
parent_by_id: Mapping[str, str],
) -> None:
for surface_id in parent_by_id:
path: set[str] = set()
current_id: str | None = surface_id
while current_id in parent_by_id:
if current_id in path:
raise RegistryError(
f"View surface hierarchy in module {module_id!r} "
f"contains a cycle at {current_id!r}"
)
path.add(current_id)
current_id = parent_by_id[current_id]
def _validate_view_surface_id(module_id: str, surface_id: str) -> None:
if not validate_view_surface_id(surface_id):
raise RegistryError(
f"View surface id for module {module_id!r} is invalid: {surface_id!r}"
)
if not surface_id.startswith(f"{module_id}."):
raise RegistryError(
f"View surface id for module {module_id!r} must start with "
f"{module_id!r} followed by '.': {surface_id!r}"
)
def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
@@ -416,6 +943,24 @@ def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component")
def _validate_public_frontend_route_uniqueness(
manifests: tuple[ModuleManifest, ...],
) -> None:
owners: dict[str, tuple[str, PublicFrontendRoute]] = {}
for manifest in manifests:
if manifest.frontend is None:
continue
for route in manifest.frontend.public_routes:
previous = owners.get(route.path)
if previous is not None:
previous_module, _previous_route = previous
raise RegistryError(
f"Duplicate public frontend route {route.path!r} in modules "
f"{previous_module!r} and {manifest.id!r}"
)
owners[route.path] = (manifest.id, route)
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list)
for manifest in manifests:
+383
View File
@@ -0,0 +1,383 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
SANCTIONS_SNAPSHOT_CONTRACT_VERSION = "1"
SANCTIONS_SCREENING_CONTRACT_VERSION = "1"
CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS = (
"connectors.sanctionsSnapshotProvider"
)
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING = (
"riskCompliance.sanctionsScreeningProvider"
)
@dataclass(frozen=True, slots=True)
class SanctionsSnapshotReference:
ref: str
tenant_id: str
provider_id: str
publisher: str
jurisdiction: str
list_type: str
source_id: str
source_version: str
publication_at: datetime | None
effective_at: datetime | None
acquired_at: datetime
content_type: str
byte_count: int
sha256: str
parser_version: str
raw_evidence_ref: str
connector_run_id: str
signature_evidence: Mapping[str, object] = field(
default_factory=dict
)
licence_notes: str | None = None
trust_notes: str | None = None
transport_evidence: Mapping[str, object] = field(
default_factory=dict
)
contract_version: str = SANCTIONS_SNAPSHOT_CONTRACT_VERSION
def __post_init__(self) -> None:
if self.contract_version != SANCTIONS_SNAPSHOT_CONTRACT_VERSION:
raise ValueError(
"Unsupported sanctions snapshot contract version."
)
required = (
self.ref,
self.tenant_id,
self.provider_id,
self.publisher,
self.jurisdiction,
self.list_type,
self.source_id,
self.source_version,
self.content_type,
self.sha256,
self.parser_version,
self.raw_evidence_ref,
self.connector_run_id,
)
if not all(value.strip() for value in required):
raise ValueError(
"Sanctions snapshot references require complete provenance."
)
if self.byte_count < 1:
raise ValueError(
"Sanctions snapshot evidence must not be empty."
)
if (
len(self.sha256) != 64
or any(character not in "0123456789abcdef" for character in self.sha256)
):
raise ValueError(
"Sanctions snapshot SHA-256 evidence is invalid."
)
@dataclass(frozen=True, slots=True)
class SanctionsSnapshotPayload:
snapshot: SanctionsSnapshotReference
content: bytes
def __post_init__(self) -> None:
if len(self.content) != self.snapshot.byte_count:
raise ValueError(
"Sanctions snapshot content length does not match metadata."
)
@dataclass(frozen=True, slots=True)
class SanctionsScreeningSubject:
subject_type: Literal["person", "entity"]
primary_name: str | None = None
subject_ref: str | None = None
aliases: tuple[str, ...] = ()
identifiers: tuple[Mapping[str, str], ...] = ()
dates: tuple[str, ...] = ()
addresses: tuple[Mapping[str, str], ...] = ()
def __post_init__(self) -> None:
if self.subject_type not in {"person", "entity"}:
raise ValueError(
"Sanctions screening subjects must be a person or entity."
)
if not str(self.primary_name or "").strip() and not any(
str(value.get("value") or "").strip()
for value in self.identifiers
):
raise ValueError(
"A sanctions screening subject needs a name or identifier."
)
@dataclass(frozen=True, slots=True)
class SanctionsScreeningPolicy:
fuzzy_threshold: float = 0.88
max_snapshot_age_days: int = 7
max_candidates: int = 100
failure_policy: Literal["block", "review", "degraded"] = "block"
def __post_init__(self) -> None:
if not 0.8 <= self.fuzzy_threshold <= 1:
raise ValueError(
"Sanctions screening fuzzy threshold must be between 0.8 and 1."
)
if not 1 <= self.max_snapshot_age_days <= 365:
raise ValueError(
"Sanctions snapshot age must be between 1 and 365 days."
)
if not 1 <= self.max_candidates <= 100:
raise ValueError(
"Sanctions screening candidate limit must be between 1 and 100."
)
if self.failure_policy not in {"block", "review", "degraded"}:
raise ValueError(
"Sanctions screening failure policy is not supported."
)
@dataclass(frozen=True, slots=True)
class SanctionsScreeningRequest:
list_snapshot_id: str
idempotency_key: str
subject: SanctionsScreeningSubject
policy: SanctionsScreeningPolicy = field(
default_factory=SanctionsScreeningPolicy
)
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
def __post_init__(self) -> None:
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
raise ValueError(
"Unsupported sanctions screening contract version."
)
if not self.list_snapshot_id.strip():
raise ValueError(
"A sanctions list snapshot is required for screening."
)
if not self.idempotency_key.strip():
raise ValueError(
"A sanctions screening idempotency key is required."
)
@dataclass(frozen=True, slots=True)
class SanctionsScreeningEvidence:
ref: str
run_id: str
outcome: str
candidate_count: int
list_snapshot_id: str
source_version: str
subject_fingerprint: str
matcher_version: str
normalization_version: str
policy_version: str
completed_at: datetime | None
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
def __post_init__(self) -> None:
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
raise ValueError(
"Unsupported sanctions screening evidence version."
)
if self.ref != f"risk-screening:{self.run_id}":
raise ValueError(
"Sanctions screening evidence reference is invalid."
)
if self.candidate_count < 0:
raise ValueError(
"Sanctions screening candidate count cannot be negative."
)
@dataclass(frozen=True, slots=True)
class SanctionsScreeningFreshnessRequest:
evidence_ref: str
current_subject: SanctionsScreeningSubject | None = None
expected_list_snapshot_id: str | None = None
policy: SanctionsScreeningPolicy = field(
default_factory=SanctionsScreeningPolicy
)
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
def __post_init__(self) -> None:
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
raise ValueError(
"Unsupported sanctions screening freshness contract version."
)
if not self.evidence_ref.startswith("risk-screening:"):
raise ValueError(
"Sanctions screening evidence reference is invalid."
)
@dataclass(frozen=True, slots=True)
class SanctionsScreeningFreshness:
evidence: SanctionsScreeningEvidence
fresh: bool
reasons: tuple[str, ...]
checked_at: datetime
current_list_snapshot_id: str | None
gate_decision: Literal["allow", "block", "review", "degraded"]
gate_reasons: tuple[str, ...]
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
def __post_init__(self) -> None:
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
raise ValueError(
"Unsupported sanctions screening freshness version."
)
if self.fresh != (not self.reasons):
raise ValueError(
"Sanctions screening freshness reasons are inconsistent."
)
if self.gate_decision not in {
"allow",
"block",
"review",
"degraded",
}:
raise ValueError(
"Sanctions screening gate decision is invalid."
)
@dataclass(frozen=True, slots=True)
class SanctionsScreeningResult:
evidence: SanctionsScreeningEvidence
freshness: SanctionsScreeningFreshness
created: bool
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
def __post_init__(self) -> None:
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
raise ValueError(
"Unsupported sanctions screening result version."
)
if self.evidence != self.freshness.evidence:
raise ValueError(
"Sanctions screening result evidence is inconsistent."
)
@runtime_checkable
class SanctionsSnapshotProvider(Protocol):
def list_snapshots(
self,
session: object,
principal: object,
*,
limit: int = 100,
) -> Sequence[SanctionsSnapshotReference]:
...
def get_snapshot(
self,
session: object,
principal: object,
*,
snapshot_ref: str,
) -> SanctionsSnapshotReference | None:
...
def read_snapshot(
self,
session: object,
principal: object,
*,
snapshot_ref: str,
) -> SanctionsSnapshotPayload:
...
@runtime_checkable
class SanctionsScreeningProvider(Protocol):
def request_screening(
self,
session: object,
principal: object,
request: SanctionsScreeningRequest,
) -> SanctionsScreeningResult:
...
def check_freshness(
self,
session: object,
principal: object,
request: SanctionsScreeningFreshnessRequest,
) -> SanctionsScreeningFreshness:
...
def sanctions_snapshot_provider(
registry: object | None,
) -> SanctionsSnapshotProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(
CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS
)
):
return None
provider = registry.capability(
CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS
)
return (
provider
if isinstance(provider, SanctionsSnapshotProvider)
else None
)
def sanctions_screening_provider(
registry: object | None,
) -> SanctionsScreeningProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING
)
):
return None
provider = registry.capability(
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING
)
return (
provider
if isinstance(provider, SanctionsScreeningProvider)
else None
)
__all__ = [
"CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS",
"CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING",
"SANCTIONS_SCREENING_CONTRACT_VERSION",
"SANCTIONS_SNAPSHOT_CONTRACT_VERSION",
"SanctionsScreeningEvidence",
"SanctionsScreeningFreshness",
"SanctionsScreeningFreshnessRequest",
"SanctionsScreeningPolicy",
"SanctionsScreeningProvider",
"SanctionsScreeningRequest",
"SanctionsScreeningResult",
"SanctionsScreeningSubject",
"SanctionsSnapshotPayload",
"SanctionsSnapshotProvider",
"SanctionsSnapshotReference",
"sanctions_screening_provider",
"sanctions_snapshot_provider",
]
+473
View File
@@ -0,0 +1,473 @@
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.external_references import ExternalObjectReference
from govoplan_core.core.modules import ModuleContext
SearchContextKind = Literal["global", "module", "resource"]
SearchVisibility = Literal["tenant", "restricted"]
SearchIndexChangeKind = Literal["upsert", "delete"]
SEARCH_SOURCE_CONTRACT_VERSION = "1"
@dataclass(frozen=True, slots=True)
class SearchQuery:
text: str
tenant_id: str
module_ids: tuple[str, ...] = ()
resource_types: tuple[str, ...] = ()
context_kind: SearchContextKind = "global"
context_id: str | None = None
limit: int = 25
offset: int = 0
cursor: str | None = None
language: str = "simple"
def __post_init__(self) -> None:
normalized_text = self.text.strip()
if len(normalized_text) > 500:
raise ValueError("Search text is limited to 500 characters.")
if not self.tenant_id.strip():
raise ValueError("Search queries require a tenant.")
if not 1 <= self.limit <= 200:
raise ValueError("Search result limits must be between 1 and 200.")
if self.offset < 0:
raise ValueError("Search offsets cannot be negative.")
if self.cursor and self.offset:
raise ValueError(
"Search cursor and offset pagination cannot be combined."
)
if not self.language.replace("_", "").isalnum():
raise ValueError("Search language configuration is invalid.")
if len(self.module_ids) > 50 or len(self.resource_types) > 50:
raise ValueError(
"Search queries support at most 50 module and type filters."
)
object.__setattr__(self, "text", normalized_text)
@dataclass(frozen=True, slots=True)
class SearchResult:
provider_id: str
module_id: str
resource_type: str
resource_id: str
title: str
url: str
summary: str | None = None
score: float = 0.0
highlights: tuple[str, ...] = ()
breadcrumbs: tuple[str, ...] = ()
external_reference: ExternalObjectReference | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
source_revision: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class SearchResourceReference:
tenant_id: str
module_id: str
resource_type: str
resource_id: str
def __post_init__(self) -> None:
for field_name in (
"tenant_id",
"module_id",
"resource_type",
"resource_id",
):
if not str(getattr(self, field_name) or "").strip():
raise ValueError(
f"Search resource reference {field_name} is required."
)
@property
def key(self) -> str:
values = (
self.tenant_id,
self.module_id,
self.resource_type,
self.resource_id,
)
return "|".join(
f"{len(value)}:{value}"
for value in values
)
@dataclass(frozen=True, slots=True)
class SearchResourceType:
provider_id: str
module_id: str
resource_type: str
label: str
index_version: int = 1
language: str = "simple"
requires_authorization_recheck: bool = True
def __post_init__(self) -> None:
if not all(
value.strip()
for value in (
self.provider_id,
self.module_id,
self.resource_type,
self.label,
)
):
raise ValueError(
"Search resource type identifiers and label are required."
)
if self.index_version < 1:
raise ValueError("Search index versions start at one.")
@dataclass(frozen=True, slots=True)
class SearchDocument:
tenant_id: str
module_id: str
resource_type: str
resource_id: str
title: str
url: str
summary: str | None = None
body: str | None = None
keywords: tuple[str, ...] = ()
visibility: SearchVisibility = "restricted"
acl_tokens: tuple[str, ...] = ()
external_reference: ExternalObjectReference | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
provider_id: str | None = None
source_revision: str = "1"
change_cursor: str | None = None
source_updated_at: datetime | None = None
language: str = "simple"
index_version: int = 1
requires_authorization_recheck: bool = False
def __post_init__(self) -> None:
required = {
"tenant_id": self.tenant_id,
"module_id": self.module_id,
"resource_type": self.resource_type,
"resource_id": self.resource_id,
"title": self.title,
"url": self.url,
}
for field_name, value in required.items():
if not str(value or "").strip():
raise ValueError(f"Search document {field_name} is required.")
limits = {
"module_id": 100,
"resource_type": 100,
"resource_id": 255,
"title": 500,
"url": 1500,
"summary": 4_000,
"body": 200_000,
"source_revision": 255,
"change_cursor": 500,
"language": 32,
}
for field_name, limit in limits.items():
value = getattr(self, field_name)
if value is not None and len(str(value)) > limit:
raise ValueError(
f"Search document {field_name} is limited to "
f"{limit} characters."
)
if len(self.keywords) > 100 or any(
len(keyword) > 200
for keyword in self.keywords
):
raise ValueError(
"Search documents support at most 100 bounded keywords."
)
if len(self.acl_tokens) > 500 or any(
len(token) > 500
for token in self.acl_tokens
):
raise ValueError(
"Search documents support at most 500 bounded ACL tokens."
)
if self.visibility == "restricted" and not self.acl_tokens:
raise ValueError(
"Restricted search documents require at least one ACL token."
)
if not self.source_revision.strip():
raise ValueError(
"Search documents require a stable source revision."
)
if self.index_version < 1:
raise ValueError("Search document index versions start at one.")
if (
self.requires_authorization_recheck
and not str(self.provider_id or "").strip()
):
raise ValueError(
"Search documents requiring authorization rechecks must "
"name their source provider."
)
if self.provider_id is not None and len(self.provider_id) > 200:
raise ValueError(
"Search document provider_id is limited to 200 characters."
)
@property
def reference(self) -> SearchResourceReference:
return SearchResourceReference(
tenant_id=self.tenant_id,
module_id=self.module_id,
resource_type=self.resource_type,
resource_id=self.resource_id,
)
@dataclass(frozen=True, slots=True)
class SearchAuthorizationRequest:
reference: SearchResourceReference
source_revision: str
@dataclass(frozen=True, slots=True)
class SearchBackfillRequest:
tenant_id: str
provider_id: str
resource_type: str
rebuild_id: str
cursor: str | None = None
limit: int = 100
def __post_init__(self) -> None:
if not 1 <= self.limit <= 500:
raise ValueError(
"Search backfill page size must be between 1 and 500."
)
@dataclass(frozen=True, slots=True)
class SearchBackfillPage:
documents: tuple[SearchDocument, ...]
next_cursor: str | None
complete: bool
high_watermark: str | None = None
def __post_init__(self) -> None:
if len(self.documents) > 500:
raise ValueError(
"Search backfill providers returned more than 500 documents."
)
if not self.complete and not self.next_cursor:
raise ValueError(
"Incomplete search backfill pages require a next cursor."
)
@dataclass(frozen=True, slots=True)
class SearchIndexChange:
change_id: str
provider_id: str
kind: SearchIndexChangeKind
reference: SearchResourceReference
source_revision: str
cursor: str
document: SearchDocument | None = None
occurred_at: datetime | None = None
def __post_init__(self) -> None:
if not all(
value.strip()
for value in (
self.change_id,
self.provider_id,
self.source_revision,
self.cursor,
)
):
raise ValueError(
"Search index changes require stable identity and revision."
)
if self.kind == "upsert":
if (
self.document is None
or self.document.reference != self.reference
or self.document.provider_id != self.provider_id
or self.document.source_revision
!= self.source_revision
or self.document.change_cursor != self.cursor
):
raise ValueError(
"Search upserts require a matching document."
)
elif self.document is not None:
raise ValueError(
"Search deletes must not contain a document."
)
@runtime_checkable
class SearchProvider(Protocol):
def search(
self,
session: object,
principal: object,
*,
query: SearchQuery,
) -> Sequence[SearchResult]:
"""Return only results the principal may currently read."""
@runtime_checkable
class SearchIndexWriter(Protocol):
def upsert_document(
self,
session: object,
principal: object,
*,
document: SearchDocument,
) -> None:
...
def delete_document(
self,
session: object,
principal: object,
*,
tenant_id: str,
module_id: str,
resource_type: str,
resource_id: str,
) -> bool:
...
def enqueue_change(
self,
session: object,
*,
change: SearchIndexChange,
) -> bool:
...
@runtime_checkable
class SearchSourceProvider(Protocol):
def resource_types(self) -> Sequence[SearchResourceType]:
...
def backfill(
self,
session: object,
*,
request: SearchBackfillRequest,
) -> SearchBackfillPage:
...
def authorize(
self,
session: object,
principal: object,
*,
requests: Sequence[SearchAuthorizationRequest],
) -> Mapping[str, bool]:
"""Return an explicit decision for every requested reference key."""
SearchProviderFactory = Callable[[ModuleContext], SearchProvider]
SearchSourceProviderFactory = Callable[
[ModuleContext],
SearchSourceProvider,
]
@dataclass(frozen=True, slots=True)
class SearchProviderRegistration:
id: str
factory: SearchProviderFactory
resource_types: tuple[str, ...] = ()
order: int = 100
def create(self, context: ModuleContext) -> SearchProvider:
provider = self.factory(context)
if not isinstance(provider, SearchProvider):
raise TypeError(
f"Search provider {self.id!r} does not implement SearchProvider."
)
return provider
@dataclass(frozen=True, slots=True)
class RegisteredSearchProvider:
module_id: str
registration: SearchProviderRegistration
@dataclass(frozen=True, slots=True)
class SearchSourceProviderRegistration:
id: str
factory: SearchSourceProviderFactory
order: int = 100
def create(self, context: ModuleContext) -> SearchSourceProvider:
provider = self.factory(context)
if not isinstance(provider, SearchSourceProvider):
raise TypeError(
f"Search source {self.id!r} does not implement "
"SearchSourceProvider."
)
return provider
@dataclass(frozen=True, slots=True)
class RegisteredSearchSourceProvider:
module_id: str
registration: SearchSourceProviderRegistration
CAPABILITY_SEARCH_INDEX_WRITER = "search.index_writer"
def search_index_writer(registry: object | None) -> SearchIndexWriter | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(CAPABILITY_SEARCH_INDEX_WRITER)
):
return None
writer = registry.capability(CAPABILITY_SEARCH_INDEX_WRITER)
return writer if isinstance(writer, SearchIndexWriter) else None
__all__ = [
"CAPABILITY_SEARCH_INDEX_WRITER",
"SEARCH_SOURCE_CONTRACT_VERSION",
"RegisteredSearchProvider",
"RegisteredSearchSourceProvider",
"SearchAuthorizationRequest",
"SearchBackfillPage",
"SearchBackfillRequest",
"SearchContextKind",
"SearchDocument",
"SearchIndexChange",
"SearchIndexChangeKind",
"SearchIndexWriter",
"SearchProvider",
"SearchProviderFactory",
"SearchProviderRegistration",
"SearchQuery",
"SearchResourceReference",
"SearchResourceType",
"SearchResult",
"SearchSourceProvider",
"SearchSourceProviderFactory",
"SearchSourceProviderRegistration",
"SearchVisibility",
"search_index_writer",
]
+264
View File
@@ -0,0 +1,264 @@
from __future__ import annotations
import csv
import io
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
CAPABILITY_CONNECTORS_TABULAR_SOURCES = "connectors.tabularSources"
CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER = "connectors.tabularSnapshotWriter"
class TabularSourceError(ValueError):
"""Stable base error for provider-neutral tabular source operations."""
class TabularSourceNotFoundError(TabularSourceError):
pass
class TabularSourceAccessError(TabularSourceError):
pass
class TabularSourceValidationError(TabularSourceError):
pass
def parse_tabular_csv(
csv_text: str,
*,
delimiter: str = ",",
max_rows: int = 10_000,
) -> tuple[Mapping[str, object], ...]:
"""Parse a bounded CSV document into JSON-compatible tabular rows."""
if len(delimiter) != 1:
raise TabularSourceValidationError("CSV delimiter must be one character.")
try:
reader = csv.DictReader(io.StringIO(csv_text), delimiter=delimiter)
original_headers, normalized_headers = _csv_headers(reader.fieldnames)
rows: list[dict[str, object]] = []
for row in reader:
_validate_csv_row_shape(row)
if _csv_row_is_empty(row, original_headers):
continue
if len(rows) >= max_rows:
raise TabularSourceValidationError(
f"CSV snapshots are limited to {max_rows:,} rows."
)
rows.append(_csv_row(row, original_headers, normalized_headers))
return tuple(rows)
except csv.Error as exc:
raise TabularSourceValidationError(f"CSV input could not be parsed: {exc}") from exc
def _csv_headers(
fieldnames: Sequence[str | None] | None,
) -> tuple[tuple[str, ...], tuple[str, ...]]:
if not fieldnames:
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
original = tuple(str(name or "") for name in fieldnames)
normalized = tuple(
name.strip().lstrip("\ufeff") if position == 0 else name.strip()
for position, name in enumerate(original)
)
if any(not name for name in normalized):
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
if len(set(normalized)) != len(normalized):
raise TabularSourceValidationError("CSV column names must be unique.")
return original, normalized
def _validate_csv_row_shape(row: Mapping[str | None, object]) -> None:
extras = row.get(None)
if isinstance(extras, list) and any(str(value or "").strip() for value in extras):
raise TabularSourceValidationError(
"A CSV row contains more values than the header defines."
)
def _csv_row_is_empty(
row: Mapping[str | None, str | list[str] | None],
headers: Sequence[str],
) -> bool:
return all(
value is None or isinstance(value, list) or not value.strip()
for value in (row.get(header) for header in headers)
)
def _csv_row(
row: Mapping[str | None, str | list[str] | None],
original_headers: Sequence[str],
normalized_headers: Sequence[str],
) -> dict[str, object]:
return {
normalized: _csv_scalar(value if isinstance(value, str) else None)
for original, normalized in zip(
original_headers,
normalized_headers,
strict=True,
)
for value in (row.get(original),)
}
def _csv_scalar(value: str | None) -> object:
if value is None:
return None
text = value.strip()
if not text:
return None
lowered = text.casefold()
if lowered in {"true", "false"}:
return lowered == "true"
if re.fullmatch(r"-?(?:0|[1-9][0-9]*)", text):
return int(text)
if re.fullmatch(r"-?(?:0|[1-9][0-9]*)\.[0-9]+", text):
return float(text)
return text
@dataclass(frozen=True, slots=True)
class TabularColumn:
name: str
data_type: str
nullable: bool = True
@dataclass(frozen=True, slots=True)
class TabularSource:
"""Opaque, policy-filtered source reference exposed to consuming modules."""
ref: str
provider: str
source_name: str
name: str
description: str | None = None
schema: tuple[TabularColumn, ...] = ()
schema_version: str = "1"
fingerprint: str = ""
row_count: int | None = None
byte_count: int | None = None
updated_at: datetime | None = None
capabilities: tuple[str, ...] = ("read",)
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class TabularReadRequest:
source_ref: str
limit: int = 250
offset: int = 0
columns: tuple[str, ...] = ()
expected_fingerprint: str | None = None
@dataclass(frozen=True, slots=True)
class TabularReadResult:
source: TabularSource
rows: tuple[Mapping[str, object], ...]
total_rows: int
truncated: bool
@dataclass(frozen=True, slots=True)
class TabularSnapshotInput:
name: str
source_name: str
rows: tuple[Mapping[str, object], ...]
description: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class TabularSourceProvider(Protocol):
"""Principal-aware catalogue and bounded reader for tabular sources."""
def list_sources(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
) -> Sequence[TabularSource]:
...
def get_source(
self,
session: object,
principal: object,
*,
source_ref: str,
) -> TabularSource | None:
...
def read_source(
self,
session: object,
principal: object,
*,
request: TabularReadRequest,
) -> TabularReadResult:
...
@runtime_checkable
class TabularSnapshotWriter(Protocol):
"""Optional capability for importing bounded static tabular snapshots."""
def create_snapshot(
self,
session: object,
principal: object,
*,
snapshot: TabularSnapshotInput,
) -> TabularSource:
...
def tabular_source_provider(registry: object | None) -> TabularSourceProvider | None:
capability = _capability(registry, CAPABILITY_CONNECTORS_TABULAR_SOURCES)
return capability if isinstance(capability, TabularSourceProvider) else None
def tabular_snapshot_writer(registry: object | None) -> TabularSnapshotWriter | None:
capability = _capability(registry, CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER)
return capability if isinstance(capability, TabularSnapshotWriter) else None
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
__all__ = [
"CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER",
"CAPABILITY_CONNECTORS_TABULAR_SOURCES",
"TabularColumn",
"TabularReadRequest",
"TabularReadResult",
"TabularSnapshotInput",
"TabularSnapshotWriter",
"TabularSource",
"TabularSourceAccessError",
"TabularSourceError",
"TabularSourceNotFoundError",
"TabularSourceProvider",
"TabularSourceValidationError",
"parse_tabular_csv",
"tabular_snapshot_writer",
"tabular_source_provider",
]
+349
View File
@@ -0,0 +1,349 @@
from __future__ import annotations
import hashlib
import logging
import re
import threading
import time
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from typing import Protocol
from redis import Redis
from redis.exceptions import RedisError
logger = logging.getLogger(__name__)
_NAMESPACE_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]{0,99}$")
_REDIS_INCREMENT_SCRIPT = """
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
local ttl = redis.call('TTL', KEYS[1])
return {count, ttl}
"""
@dataclass(frozen=True, slots=True)
class FixedWindowBucket:
count: int = 0
retry_after_seconds: int = 0
@dataclass(frozen=True, slots=True)
class ThrottleDimension:
"""One independently limited dimension without putting its value in keys."""
namespace: str
subject: str
limit: int
@dataclass(frozen=True, slots=True)
class ThrottleDecision:
allowed: bool
retry_after_seconds: int = 0
class FixedWindowStore(Protocol):
def read(self, key: str) -> FixedWindowBucket: ...
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket: ...
def delete(self, key: str) -> None: ...
class InMemoryFixedWindowStore:
"""Bounded process-local store for development and distributed-store loss."""
def __init__(
self,
*,
max_entries: int = 10_000,
clock: Callable[[], float] = time.monotonic,
) -> None:
self._entries: dict[str, tuple[int, float]] = {}
self._lock = threading.Lock()
self._max_entries = max(2, max_entries)
self._clock = clock
def read(self, key: str) -> FixedWindowBucket:
now = self._clock()
with self._lock:
entry = self._active_entry(key, now=now)
if entry is None:
return FixedWindowBucket()
count, expires_at = entry
return FixedWindowBucket(
count=count,
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
)
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
now = self._clock()
with self._lock:
entry = self._active_entry(key, now=now)
if entry is None:
self._make_room(now=now, incoming_key=key)
count = 1
expires_at = now + window_seconds
else:
count = entry[0] + 1
expires_at = entry[1]
self._entries[key] = (count, expires_at)
return FixedWindowBucket(
count=count,
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
)
def delete(self, key: str) -> None:
with self._lock:
self._entries.pop(key, None)
def _active_entry(self, key: str, *, now: float) -> tuple[int, float] | None:
entry = self._entries.get(key)
if entry is None:
return None
if entry[1] <= now:
self._entries.pop(key, None)
return None
return entry
def _make_room(self, *, now: float, incoming_key: str) -> None:
if incoming_key in self._entries or len(self._entries) < self._max_entries:
return
for key, (_count, expires_at) in tuple(self._entries.items()):
if expires_at <= now:
self._entries.pop(key, None)
while len(self._entries) >= self._max_entries:
self._entries.pop(next(iter(self._entries)))
class RedisFixedWindowStore:
"""Atomic Redis fixed-window counters shared across API workers."""
def __init__(self, redis_url: str) -> None:
self._client = Redis.from_url(
redis_url,
decode_responses=True,
socket_connect_timeout=0.25,
socket_timeout=0.25,
health_check_interval=30,
)
def read(self, key: str) -> FixedWindowBucket:
pipeline = self._client.pipeline(transaction=False)
pipeline.get(key)
pipeline.ttl(key)
raw_count, raw_ttl = pipeline.execute()
return FixedWindowBucket(
count=int(raw_count or 0),
retry_after_seconds=max(0, int(raw_ttl or 0)),
)
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
result = self._client.eval(
_REDIS_INCREMENT_SCRIPT,
1,
key,
window_seconds,
)
if not isinstance(result, (list, tuple)) or len(result) != 2:
raise RedisError("Unexpected fixed-window throttle response from Redis")
return FixedWindowBucket(
count=int(result[0]),
retry_after_seconds=max(1, int(result[1])),
)
def delete(self, key: str) -> None:
self._client.delete(key)
class ResilientFixedWindowStore:
"""Mirror locally, prefer Redis, and fall back safely during an outage."""
def __init__(
self,
primary: FixedWindowStore | None,
fallback: FixedWindowStore,
*,
retry_seconds: int = 30,
clock: Callable[[], float] = time.monotonic,
) -> None:
self._primary = primary
self._fallback = fallback
self._retry_seconds = max(1, retry_seconds)
self._clock = clock
self._primary_unavailable_until = 0.0
self._state_lock = threading.Lock()
def read(self, key: str) -> FixedWindowBucket:
fallback_result = self._fallback.read(key)
primary = self._available_primary()
if primary is None:
return fallback_result
try:
return _stricter_bucket(primary.read(key), fallback_result)
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
self._mark_primary_unavailable(exc)
return fallback_result
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
fallback_result = self._fallback.increment(
key,
window_seconds=window_seconds,
)
primary = self._available_primary()
if primary is None:
return fallback_result
try:
primary_result = primary.increment(
key,
window_seconds=window_seconds,
)
return _stricter_bucket(primary_result, fallback_result)
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
self._mark_primary_unavailable(exc)
return fallback_result
def delete(self, key: str) -> None:
self._fallback.delete(key)
primary = self._available_primary()
if primary is None:
return
try:
primary.delete(key)
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
self._mark_primary_unavailable(exc)
def _available_primary(self) -> FixedWindowStore | None:
if self._primary is None:
return None
with self._state_lock:
if self._clock() < self._primary_unavailable_until:
return None
return self._primary
def _mark_primary_unavailable(self, exc: Exception) -> None:
should_log = False
with self._state_lock:
now = self._clock()
if now >= self._primary_unavailable_until:
should_log = True
self._primary_unavailable_until = now + self._retry_seconds
if should_log:
logger.warning(
"Distributed throttling is unavailable; using the process-local fallback (%s)",
type(exc).__name__,
)
def _stricter_bucket(
first: FixedWindowBucket,
second: FixedWindowBucket,
) -> FixedWindowBucket:
return FixedWindowBucket(
count=max(first.count, second.count),
retry_after_seconds=max(
first.retry_after_seconds,
second.retry_after_seconds,
),
)
class FixedWindowThrottle:
def __init__(
self,
store: FixedWindowStore,
*,
window_seconds: int,
key_prefix: str = "govoplan:throttle:v1",
) -> None:
self._store = store
self._window_seconds = max(1, window_seconds)
self._key_prefix = key_prefix.rstrip(":")
def check(self, dimensions: Iterable[ThrottleDimension]) -> ThrottleDecision:
return self._decision(tuple(dimensions), increment=False)
def record(self, dimensions: Iterable[ThrottleDimension]) -> ThrottleDecision:
return self._decision(tuple(dimensions), increment=True)
def reset(self, dimensions: Iterable[ThrottleDimension]) -> None:
for dimension in tuple(dimensions):
self._store.delete(self._key(dimension))
def _decision(
self,
dimensions: tuple[ThrottleDimension, ...],
*,
increment: bool,
) -> ThrottleDecision:
if not dimensions:
raise ValueError("At least one throttle dimension is required")
blocked_retry_after = 0
for dimension in dimensions:
if dimension.limit < 1:
raise ValueError("Throttle limits must be positive")
key = self._key(dimension)
state = (
self._store.increment(key, window_seconds=self._window_seconds)
if increment
else self._store.read(key)
)
if state.count >= dimension.limit:
blocked_retry_after = max(
blocked_retry_after,
max(1, state.retry_after_seconds),
)
return ThrottleDecision(
allowed=blocked_retry_after == 0,
retry_after_seconds=blocked_retry_after,
)
def _key(self, dimension: ThrottleDimension) -> str:
namespace = dimension.namespace.strip().casefold()
if not _NAMESPACE_PATTERN.fullmatch(namespace):
raise ValueError("Throttle namespaces must use lowercase letters, digits, '.', '_' or '-'")
subject_digest = hashlib.sha256(dimension.subject.encode("utf-8")).hexdigest()
return f"{self._key_prefix}:{namespace}:{subject_digest}"
def build_fixed_window_throttle(
*,
redis_url: str | None,
window_seconds: int,
redis_retry_seconds: int = 30,
max_local_entries: int = 10_000,
key_prefix: str = "govoplan:throttle:v1",
) -> FixedWindowThrottle:
primary = (
RedisFixedWindowStore(redis_url)
if redis_url is not None and redis_url.strip()
else None
)
store = ResilientFixedWindowStore(
primary,
InMemoryFixedWindowStore(max_entries=max_local_entries),
retry_seconds=redis_retry_seconds,
)
return FixedWindowThrottle(
store,
window_seconds=window_seconds,
key_prefix=key_prefix,
)
__all__ = [
"FixedWindowBucket",
"FixedWindowStore",
"FixedWindowThrottle",
"InMemoryFixedWindowStore",
"RedisFixedWindowStore",
"ResilientFixedWindowStore",
"ThrottleDecision",
"ThrottleDimension",
"build_fixed_window_throttle",
]
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
import re
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Literal, Protocol, runtime_checkable
VIEWS_MODULE_ID = "views"
CAPABILITY_VIEWS_RESOLVER = "views.resolver"
VIEW_SURFACE_CONTRACT_VERSION = "1"
ViewSurfaceKind = Literal[
"module",
"navigation",
"route",
"section",
"action",
"selector",
]
_SURFACE_ID_RE = re.compile(r"^[a-z][a-z0-9_.-]{2,159}$")
_SURFACE_SLUG_RE = re.compile(r"[^a-z0-9]+")
@dataclass(frozen=True, slots=True)
class ViewSurface:
id: str
module_id: str
kind: ViewSurfaceKind
label: str
parent_id: str | None = None
description: str | None = None
order: int = 100
default_visible: bool = True
required: bool = False
@dataclass(frozen=True, slots=True)
class EffectiveView:
view_id: str | None
revision_id: str | None
name: str | None
visible_surface_ids: frozenset[str]
locked: bool = False
projection_active: bool = False
provenance: tuple[dict[str, object], ...] = ()
@runtime_checkable
class ViewResolver(Protocol):
def resolve_effective_view(
self,
session: object,
*,
tenant_id: str,
account_id: str,
group_ids: Iterable[str] = (),
workflow_view_id: str | None = None,
workflow_revision_id: str | None = None,
workflow_surface_ids: Iterable[str] = (),
) -> EffectiveView: ...
def views_resolver(registry: object | None) -> ViewResolver | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_VIEWS_RESOLVER)
):
return None
capability = registry.capability(CAPABILITY_VIEWS_RESOLVER)
return capability if isinstance(capability, ViewResolver) else None
def module_view_surface_id(module_id: str) -> str:
return f"{module_id}.module"
def navigation_view_surface_id(module_id: str, path: str) -> str:
return f"{module_id}.nav.{_surface_slug(path)}"
def route_view_surface_id(module_id: str, path: str) -> str:
return f"{module_id}.route.{_surface_slug(path)}"
def validate_view_surface_id(value: str) -> bool:
return bool(_SURFACE_ID_RE.fullmatch(value))
def _surface_slug(value: str) -> str:
normalized = _SURFACE_SLUG_RE.sub(".", value.strip().lower()).strip(".")
return normalized or "root"
__all__ = [
"CAPABILITY_VIEWS_RESOLVER",
"EffectiveView",
"VIEWS_MODULE_ID",
"VIEW_SURFACE_CONTRACT_VERSION",
"ViewResolver",
"ViewSurface",
"ViewSurfaceKind",
"module_view_surface_id",
"navigation_view_surface_id",
"route_view_surface_id",
"validate_view_surface_id",
"views_resolver",
]
+239
View File
@@ -0,0 +1,239 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
import hashlib
import json
from typing import Protocol, runtime_checkable
CAPABILITY_WORKFLOW_RUNTIME_WORKER = "workflow.runtimeWorker"
CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS = "workflow.definitionContributions"
CAPABILITY_WORKFLOW_ORCHESTRATION = "workflow.orchestration"
@dataclass(frozen=True, slots=True)
class WorkflowDefinitionContribution:
"""Versioned, module-owned Workflow Engine baseline definition."""
origin_module_id: str
origin_module_version: str
definition_key: str
name: str
graph: Mapping[str, object]
contribution_schema_version: str = "1"
description: str | None = None
definition_kind: str = "flow"
scope_type: str = "system"
inherit_to_lower_scopes: bool = True
allow_start: bool = True
allow_reuse: bool = True
allow_automation: bool = False
execution_mode: str = "hybrid"
view_id: str | None = None
view_revision_id: str | None = None
bpmn_xml: str | None = None
bpmn_adapter_id: str = "govoplan.native.bpmn"
bpmn_adapter_version: str | None = None
activate_on_install: bool = True
required_capabilities: tuple[str, ...] = ()
required_interfaces: tuple[str, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
policy_metadata: Mapping[str, object] = field(default_factory=dict)
content_hash: str | None = None
@dataclass(frozen=True, slots=True)
class WorkflowStandardStartRequest:
tenant_id: str
origin_module_id: str
definition_key: str
idempotency_key: str
input: Mapping[str, object] = field(default_factory=dict)
actor_id: str | None = None
correlation_id: str | None = None
start_origin: str = "user"
@dataclass(frozen=True, slots=True)
class WorkflowCurrentStepResolution:
action: str
expected_step_id: str | None = None
actor_id: str | None = None
output: Mapping[str, object] = field(default_factory=dict)
evidence: tuple[str, ...] = ()
comment: str | None = None
@dataclass(frozen=True, slots=True)
class WorkflowInstanceRef:
id: str
tenant_id: str
definition_id: str
definition_revision_id: str
definition_revision: int
definition_hash: str
status: str
current_step_id: str | None = None
current_node_id: str | None = None
replayed: bool = False
def workflow_definition_contribution_hash(
contribution: WorkflowDefinitionContribution,
) -> str:
"""Return the stable content hash, excluding package release provenance."""
payload = {
"schema_version": contribution.contribution_schema_version,
"definition_key": contribution.definition_key,
"name": contribution.name,
"description": contribution.description,
"graph": contribution.graph,
"definition_kind": contribution.definition_kind,
"scope_type": contribution.scope_type,
"inherit_to_lower_scopes": contribution.inherit_to_lower_scopes,
"allow_start": contribution.allow_start,
"allow_reuse": contribution.allow_reuse,
"allow_automation": contribution.allow_automation,
"execution_mode": contribution.execution_mode,
"view_id": contribution.view_id,
"view_revision_id": contribution.view_revision_id,
"bpmn_xml": contribution.bpmn_xml,
"bpmn_adapter_id": contribution.bpmn_adapter_id,
"bpmn_adapter_version": contribution.bpmn_adapter_version,
"activate_on_install": contribution.activate_on_install,
"required_capabilities": contribution.required_capabilities,
"required_interfaces": contribution.required_interfaces,
"metadata": contribution.metadata,
"policy_metadata": contribution.policy_metadata,
}
encoded = json.dumps(
payload,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
)
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
@runtime_checkable
class WorkflowRuntimeWorker(Protocol):
def reconcile_pending(
self,
session: object,
*,
now: datetime | None = None,
limit: int = 50,
) -> Mapping[str, object]:
...
@runtime_checkable
class WorkflowDefinitionContributionProvider(Protocol):
def reconcile(
self,
session: object,
*,
tenant_ids: Sequence[str] = (),
) -> Mapping[str, object]:
...
@runtime_checkable
class WorkflowOrchestrationProvider(Protocol):
def start_standard(
self,
session: object,
principal: object,
*,
request: WorkflowStandardStartRequest,
) -> WorkflowInstanceRef: ...
def resolve_current_step(
self,
session: object,
principal: object,
*,
tenant_id: str,
instance_id: str,
resolution: WorkflowCurrentStepResolution,
) -> WorkflowInstanceRef: ...
def get_instance(
self,
session: object,
*,
tenant_id: str,
instance_id: str,
) -> WorkflowInstanceRef: ...
def workflow_runtime_worker(
registry: object | None,
) -> WorkflowRuntimeWorker | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_WORKFLOW_RUNTIME_WORKER)
):
return None
capability = registry.capability(CAPABILITY_WORKFLOW_RUNTIME_WORKER)
return (
capability
if isinstance(capability, WorkflowRuntimeWorker)
else None
)
def workflow_definition_contribution_provider(
registry: object | None,
) -> WorkflowDefinitionContributionProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(
CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS
)
):
return None
capability = registry.capability(
CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS
)
return (
capability
if isinstance(capability, WorkflowDefinitionContributionProvider)
else None
)
def workflow_orchestration_provider(
registry: object | None,
) -> WorkflowOrchestrationProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_WORKFLOW_ORCHESTRATION)
):
return None
capability = registry.capability(CAPABILITY_WORKFLOW_ORCHESTRATION)
return capability if isinstance(capability, WorkflowOrchestrationProvider) else None
__all__ = [
"CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS",
"CAPABILITY_WORKFLOW_ORCHESTRATION",
"CAPABILITY_WORKFLOW_RUNTIME_WORKER",
"WorkflowCurrentStepResolution",
"WorkflowDefinitionContribution",
"WorkflowDefinitionContributionProvider",
"WorkflowInstanceRef",
"WorkflowOrchestrationProvider",
"WorkflowRuntimeWorker",
"WorkflowStandardStartRequest",
"workflow_definition_contribution_hash",
"workflow_definition_contribution_provider",
"workflow_orchestration_provider",
"workflow_runtime_worker",
]
+1
View File
@@ -32,6 +32,7 @@ def create_all_tables() -> None:
# model metadata with the shared SQLAlchemy base before create_all runs.
from govoplan_core.admin import models as core_admin_models # noqa: F401
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules)
+4
View File
@@ -7,6 +7,7 @@ import logging
import os
from pathlib import Path
import re
import sysconfig
from typing import Any
from alembic import command
@@ -17,6 +18,7 @@ from sqlalchemy import create_engine, inspect, text
from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.modules import ModuleMigrationTaskContext, ModuleMigrationTaskResult
@@ -462,11 +464,13 @@ def _jsonable_migration_task_details(value: Mapping[str, Any]) -> Mapping[str, A
def _repo_root() -> Path:
packaged_root = Path(__file__).resolve().parents[3]
installed_runtime_root = Path(sysconfig.get_path("data")) / "govoplan_core_runtime"
configured = os.environ.get("GOVOPLAN_CORE_SOURCE_ROOT")
candidates = [
Path(configured).expanduser() if configured else None,
Path.cwd(),
packaged_root,
installed_runtime_root,
]
for candidate in candidates:
if candidate is None:
+42
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from collections.abc import Generator, Mapping
import os
from typing import Any
from sqlalchemy import create_engine
@@ -10,6 +11,14 @@ from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.db.query_metrics import instrument_engine
_POOL_SETTING_BOUNDS: dict[str, tuple[int, int]] = {
"GOVOPLAN_DB_POOL_SIZE": (1, 100),
"GOVOPLAN_DB_MAX_OVERFLOW": (0, 200),
"GOVOPLAN_DB_POOL_TIMEOUT_SECONDS": (1, 300),
"GOVOPLAN_DB_POOL_RECYCLE_SECONDS": (0, 86_400),
}
def default_connect_args(database_url: str) -> dict[str, Any]:
return {"check_same_thread": False} if database_url.startswith("sqlite") else {}
@@ -24,9 +33,42 @@ def create_database_engine(
merged_connect_args = dict(default_connect_args(database_url))
if connect_args:
merged_connect_args.update(connect_args)
if not database_url.startswith("sqlite"):
kwargs.setdefault(
"pool_size",
_pool_setting("GOVOPLAN_DB_POOL_SIZE", 5),
)
kwargs.setdefault(
"max_overflow",
_pool_setting("GOVOPLAN_DB_MAX_OVERFLOW", 10),
)
kwargs.setdefault(
"pool_timeout",
_pool_setting("GOVOPLAN_DB_POOL_TIMEOUT_SECONDS", 30),
)
kwargs.setdefault(
"pool_recycle",
_pool_setting("GOVOPLAN_DB_POOL_RECYCLE_SECONDS", 1800),
)
return instrument_engine(create_engine(database_url, pool_pre_ping=pool_pre_ping, connect_args=merged_connect_args, **kwargs))
def _pool_setting(name: str, default: int) -> int:
raw = os.environ.get(name)
if raw is None:
return default
try:
value = int(raw)
except ValueError as exc:
raise ValueError(f"{name} must be an integer") from exc
minimum, maximum = _POOL_SETTING_BOUNDS[name]
if not minimum <= value <= maximum:
raise ValueError(
f"{name} must be between {minimum} and {maximum}",
)
return value
class DatabaseHandle:
def __init__(self, database_url: str, *, engine: Engine | None = None) -> None:
self.database_url = database_url
+80 -4
View File
@@ -37,6 +37,7 @@ class DevserverState:
config: GovoplanServerConfig
registry: PlatformRegistry
reload_dirs: list[str]
reload_module_ids: tuple[str, ...] | None
def _config_module_runtime_root(config_path: str | None) -> Path | None:
@@ -190,6 +191,9 @@ def _manifest_source_roots(manifest: ModuleManifest) -> tuple[Path, ...]:
for provider in manifest.tenant_summary_providers:
roots.extend(_source_roots_for_object(provider))
for provider in manifest.tenant_summary_batch_providers:
roots.extend(_source_roots_for_object(provider))
for providers in manifest.delete_veto_providers.values():
for provider in providers:
roots.extend(_source_roots_for_object(provider))
@@ -237,20 +241,44 @@ def build_reload_dirs(
config_path: str | None = None,
registry: PlatformRegistry | None = None,
extra_dirs: Sequence[str] = (),
module_ids: Sequence[str] | None = None,
) -> list[str]:
active_registry = registry or build_platform_registry(config.enabled_modules, manifest_factories=config.manifest_factories)
manifests = active_registry.manifests()
enabled_module_ids = {manifest.id for manifest in manifests}
selected_module_ids = (
enabled_module_ids
if module_ids is None
else {module_id.strip() for module_id in module_ids if module_id.strip()}
)
unknown_module_ids = selected_module_ids - enabled_module_ids
if unknown_module_ids:
raise SystemExit(
"Reload modules are not enabled: "
+ ", ".join(sorted(unknown_module_ids))
)
roots: list[Path | str] = []
roots.extend(_config_source_roots(config_path))
for factory in config.manifest_factories:
try:
manifest = factory()
except TypeError:
manifest = None
if (
module_ids is not None
and isinstance(manifest, ModuleManifest)
and manifest.id not in selected_module_ids
):
continue
roots.extend(_source_roots_for_object(factory))
roots.extend(_entry_point_source_roots(enabled_module_ids))
roots.extend(_entry_point_source_roots(selected_module_ids))
for manifest in manifests:
if manifest.id not in selected_module_ids:
continue
roots.extend(_manifest_source_roots(manifest))
roots.extend(extra_dirs)
@@ -265,11 +293,32 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser.add_argument("--port", type=int, default=8000, help="Port to bind. Default: 8000.")
parser.add_argument("--no-reload", action="store_true", help="Disable uvicorn reload.")
parser.add_argument("--reload-dir", action="append", default=[], help="Additional directory to watch. May be passed multiple times.")
reload_scope = parser.add_mutually_exclusive_group()
reload_scope.add_argument(
"--reload-module",
action="append",
default=None,
metavar="MODULE_ID",
help=(
"Watch only this enabled module in addition to core/config sources. "
"May be passed multiple times."
),
)
reload_scope.add_argument(
"--reload-core-only",
action="store_true",
help="Watch core/config sources but no optional module source trees.",
)
parser.add_argument("--smoke", action="store_true", help="Prepare runtime paths, run app startup, print effective paths, and exit without uvicorn.")
return parser.parse_args(argv)
def prepare_devserver(config_path: str | None, *, extra_reload_dirs: Sequence[str] = ()) -> DevserverState:
def prepare_devserver(
config_path: str | None,
*,
extra_reload_dirs: Sequence[str] = (),
reload_module_ids: Sequence[str] | None = None,
) -> DevserverState:
runtime_root = apply_runtime_defaults(config_path)
database_url = os.getenv("DATABASE_URL", "")
validate_sqlite_database_url(database_url)
@@ -291,7 +340,13 @@ def prepare_devserver(config_path: str | None, *, extra_reload_dirs: Sequence[st
)
enabled_modules = load_startup_enabled_modules(config.enabled_modules, available=available_modules)
registry = build_platform_registry(enabled_modules, manifest_factories=config.manifest_factories)
reload_dirs = build_reload_dirs(config, config_path=config_path, registry=registry, extra_dirs=extra_reload_dirs)
reload_dirs = build_reload_dirs(
config,
config_path=config_path,
registry=registry,
extra_dirs=extra_reload_dirs,
module_ids=reload_module_ids,
)
return DevserverState(
config_path=config_path,
runtime_root=runtime_root,
@@ -300,6 +355,11 @@ def prepare_devserver(config_path: str | None, *, extra_reload_dirs: Sequence[st
config=config,
registry=registry,
reload_dirs=reload_dirs,
reload_module_ids=(
None
if reload_module_ids is None
else tuple(sorted(set(reload_module_ids)))
),
)
@@ -320,6 +380,15 @@ def print_devserver_summary(state: DevserverState, *, app: str, no_reload: bool)
if no_reload:
print("Reload: disabled")
else:
if state.reload_module_ids is None:
print("Reload scope: core/config plus all enabled modules")
elif state.reload_module_ids:
print(
"Reload scope: core/config plus "
+ ", ".join(state.reload_module_ids)
)
else:
print("Reload scope: core/config only")
print("Reload dirs:")
for directory in state.reload_dirs:
print(f" - {directory}")
@@ -353,7 +422,14 @@ def main(argv: Sequence[str] | None = None) -> int:
os.environ["GOVOPLAN_SERVER_CONFIG"] = args.config
config_path = args.config or os.getenv("GOVOPLAN_SERVER_CONFIG")
state = prepare_devserver(config_path, extra_reload_dirs=args.reload_dir)
reload_module_ids: Sequence[str] | None = args.reload_module
if args.reload_core_only:
reload_module_ids = ()
state = prepare_devserver(
config_path,
extra_reload_dirs=args.reload_dir,
reload_module_ids=reload_module_ids,
)
print_devserver_summary(state, app=args.app, no_reload=args.no_reload)
if args.smoke:
@@ -0,0 +1,563 @@
from __future__ import annotations
import json
import re
import uuid
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Mapping
from sqlalchemy import Boolean, DateTime, Index, JSON, String, Text, select
from sqlalchemy.orm import Mapped, Session, mapped_column
from govoplan_core.audit.logging import audit_event
from govoplan_core.core.change_sequence import record_change
from govoplan_core.db.base import Base, TimestampMixin, utcnow
from govoplan_core.security.redaction import is_sensitive_key, redact_secret_values
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
CREDENTIAL_SCOPE_TYPES = frozenset({"system", "tenant", "group", "user", "campaign"})
CREDENTIAL_KINDS = frozenset(
{
"username_password",
"token",
"oauth2",
"api_key",
"client_secret",
"aws",
"custom",
"external_secret",
}
)
_SECRET_PUBLIC_DATA_KEYS = frozenset(
{
"access_token",
"api_key",
"authorization",
"bearer_token",
"client_secret",
"password",
"passphrase",
"private_key",
"refresh_token",
"secret",
"secret_access_key",
"session_token",
"token",
}
)
class CredentialEnvelopeError(RuntimeError):
pass
class CredentialEnvelope(Base, TimestampMixin):
__tablename__ = "core_credential_envelopes"
__table_args__ = (
Index("ix_core_credential_envelopes_scope", "tenant_id", "scope_type", "scope_id"),
Index("ix_core_credential_envelopes_active", "tenant_id", "is_active", "deleted_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
scope_type: Mapped[str] = mapped_column(String(20), nullable=False, default="tenant", index=True)
scope_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
credential_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
public_data: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
secret_data_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
secret_keys: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
allowed_modules: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
allowed_server_refs: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
inherit_to_lower_scopes: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
revision: Mapped[str] = mapped_column(String(36), default=lambda: str(uuid.uuid4()), nullable=False)
created_by_user_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
updated_by_user_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
@dataclass(frozen=True, slots=True)
class CredentialAccessContext:
tenant_id: str | None
user_id: str | None = None
group_ids: frozenset[str] = frozenset()
target_scope_type: str = "tenant"
target_scope_id: str | None = None
module_id: str | None = None
server_ref: str | None = None
administrative: bool = False
@dataclass(frozen=True, slots=True)
class ResolvedCredentialEnvelope:
id: str
name: str
credential_kind: str
public_data: Mapping[str, Any]
secret_data: Mapping[str, Any]
revision: str
def normalize_credential_scope(
*,
tenant_id: str | None,
scope_type: str,
scope_id: str | None,
) -> tuple[str | None, str, str | None]:
normalized_type = str(scope_type or "tenant").strip().casefold()
if normalized_type not in CREDENTIAL_SCOPE_TYPES:
raise CredentialEnvelopeError(f"Unsupported credential scope: {scope_type!r}")
normalized_id = _optional_text(scope_id)
if normalized_type == "system":
return None, "system", None
normalized_tenant = _required_text(tenant_id, "Credential tenant_id is required outside system scope")
if normalized_type == "tenant":
return normalized_tenant, "tenant", normalized_id or normalized_tenant
if not normalized_id:
raise CredentialEnvelopeError(f"{normalized_type.capitalize()} credentials require scope_id")
return normalized_tenant, normalized_type, normalized_id
def create_credential_envelope(
session: Session,
*,
tenant_id: str | None,
scope_type: str,
scope_id: str | None,
name: str,
credential_kind: str,
public_data: Mapping[str, Any] | None = None,
secret_data: Mapping[str, Any] | None = None,
description: str | None = None,
allowed_modules: list[str] | tuple[str, ...] = (),
allowed_server_refs: list[str] | tuple[str, ...] = (),
inherit_to_lower_scopes: bool = False,
is_active: bool = True,
user_id: str | None = None,
metadata: Mapping[str, Any] | None = None,
) -> CredentialEnvelope:
row_tenant_id, row_scope_type, row_scope_id = normalize_credential_scope(
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
)
clean_kind = _normalize_kind(credential_kind)
clean_public = _safe_public_mapping(public_data)
clean_secret = _json_mapping(secret_data)
row = CredentialEnvelope(
tenant_id=row_tenant_id,
scope_type=row_scope_type,
scope_id=row_scope_id,
name=_required_text(name, "Credential name is required"),
description=_optional_text(description),
credential_kind=clean_kind,
public_data=clean_public,
secret_data_encrypted=_encrypt_secret_mapping(clean_secret),
secret_keys=sorted(clean_secret),
allowed_modules=_normalized_values(allowed_modules),
allowed_server_refs=_normalized_values(allowed_server_refs),
inherit_to_lower_scopes=bool(inherit_to_lower_scopes),
is_active=bool(is_active),
created_by_user_id=_optional_text(user_id),
updated_by_user_id=_optional_text(user_id),
metadata_=_json_mapping(metadata) or None,
)
session.add(row)
session.flush()
_record_credential_change(session, row=row, operation="created", user_id=user_id)
return row
def update_credential_envelope(
session: Session,
row: CredentialEnvelope,
*,
name: str | None = None,
description: str | None = None,
description_supplied: bool = False,
credential_kind: str | None = None,
public_data: Mapping[str, Any] | None = None,
secret_data: Mapping[str, Any] | None = None,
clear_secret: bool = False,
allowed_modules: list[str] | tuple[str, ...] | None = None,
allowed_server_refs: list[str] | tuple[str, ...] | None = None,
inherit_to_lower_scopes: bool | None = None,
is_active: bool | None = None,
user_id: str | None = None,
metadata: Mapping[str, Any] | None = None,
) -> CredentialEnvelope:
if row.deleted_at is not None:
raise CredentialEnvelopeError("Deleted credentials cannot be updated")
if name is not None:
row.name = _required_text(name, "Credential name is required")
if description_supplied or description is not None:
row.description = _optional_text(description)
if credential_kind is not None:
clean_kind = _normalize_kind(credential_kind)
if clean_kind != row.credential_kind and secret_data is None and not clear_secret:
raise CredentialEnvelopeError(
"Changing credential kind requires replacing or clearing its secret"
)
row.credential_kind = clean_kind
if public_data is not None:
row.public_data = _safe_public_mapping(public_data)
if secret_data is not None:
clean_secret = _json_mapping(secret_data)
row.secret_data_encrypted = _encrypt_secret_mapping(clean_secret)
row.secret_keys = sorted(clean_secret)
elif clear_secret:
row.secret_data_encrypted = None
row.secret_keys = []
if allowed_modules is not None:
row.allowed_modules = _normalized_values(allowed_modules)
if allowed_server_refs is not None:
row.allowed_server_refs = _normalized_values(allowed_server_refs)
if inherit_to_lower_scopes is not None:
row.inherit_to_lower_scopes = bool(inherit_to_lower_scopes)
if is_active is not None:
row.is_active = bool(is_active)
if metadata is not None:
row.metadata_ = _json_mapping(metadata) or None
row.updated_by_user_id = _optional_text(user_id)
row.revision = str(uuid.uuid4())
session.flush()
_record_credential_change(session, row=row, operation="updated", user_id=user_id)
return row
def retire_credential_envelope(
session: Session,
row: CredentialEnvelope,
*,
user_id: str | None = None,
) -> CredentialEnvelope:
if row.deleted_at is not None:
return row
row.secret_data_encrypted = None
row.secret_keys = []
row.is_active = False
row.deleted_at = utcnow()
row.updated_by_user_id = _optional_text(user_id)
row.revision = str(uuid.uuid4())
session.flush()
_record_credential_change(session, row=row, operation="deleted", user_id=user_id)
return row
def get_credential_envelope(
session: Session,
*,
credential_id: str,
context: CredentialAccessContext,
require_active: bool = True,
for_update: bool = False,
) -> CredentialEnvelope:
statement = select(CredentialEnvelope).where(CredentialEnvelope.id == _required_text(credential_id, "Credential id is required"))
if for_update:
statement = statement.with_for_update()
row = session.execute(statement).scalar_one_or_none()
if row is None or row.deleted_at is not None or not credential_visible_to_context(row, context):
raise CredentialEnvelopeError("Credential envelope not found")
if require_active and not row.is_active:
raise CredentialEnvelopeError("Credential envelope is inactive")
return row
def list_credential_envelopes(
session: Session,
*,
context: CredentialAccessContext,
include_inactive: bool = False,
) -> list[CredentialEnvelope]:
statement = select(CredentialEnvelope).where(CredentialEnvelope.deleted_at.is_(None))
if context.tenant_id is None:
statement = statement.where(CredentialEnvelope.tenant_id.is_(None))
else:
statement = statement.where(
(CredentialEnvelope.tenant_id == context.tenant_id)
| (CredentialEnvelope.tenant_id.is_(None))
)
if not include_inactive:
statement = statement.where(CredentialEnvelope.is_active.is_(True))
rows = session.execute(statement.order_by(CredentialEnvelope.name, CredentialEnvelope.id)).scalars()
return [row for row in rows if credential_visible_to_context(row, context)]
def list_managed_credential_envelopes(
session: Session,
*,
tenant_id: str | None,
include_inactive: bool = False,
) -> list[CredentialEnvelope]:
"""List envelopes for an administrative surface without applying use-site limits."""
statement = select(CredentialEnvelope).where(CredentialEnvelope.deleted_at.is_(None))
if tenant_id is None:
statement = statement.where(CredentialEnvelope.tenant_id.is_(None))
else:
statement = statement.where(CredentialEnvelope.tenant_id == tenant_id)
if not include_inactive:
statement = statement.where(CredentialEnvelope.is_active.is_(True))
return list(
session.execute(
statement.order_by(
CredentialEnvelope.scope_type,
CredentialEnvelope.name,
CredentialEnvelope.id,
)
).scalars()
)
def get_managed_credential_envelope(
session: Session,
*,
credential_id: str,
tenant_id: str | None,
for_update: bool = False,
) -> CredentialEnvelope:
statement = select(CredentialEnvelope).where(
CredentialEnvelope.id
== _required_text(credential_id, "Credential id is required"),
CredentialEnvelope.deleted_at.is_(None),
)
if tenant_id is None:
statement = statement.where(CredentialEnvelope.tenant_id.is_(None))
else:
statement = statement.where(CredentialEnvelope.tenant_id == tenant_id)
if for_update:
statement = statement.with_for_update()
row = session.execute(statement).scalar_one_or_none()
if row is None:
raise CredentialEnvelopeError("Credential envelope not found")
return row
def credential_visible_to_context(row: CredentialEnvelope, context: CredentialAccessContext) -> bool:
if row.deleted_at is not None:
return False
if not _scope_visible(row, context):
return False
if row.allowed_modules and (not context.module_id or context.module_id not in row.allowed_modules):
return False
if row.allowed_server_refs and (not context.server_ref or context.server_ref not in row.allowed_server_refs):
return False
return True
def resolve_credential_envelope(
session: Session,
*,
credential_id: str,
context: CredentialAccessContext,
) -> ResolvedCredentialEnvelope:
row = get_credential_envelope(
session,
credential_id=credential_id,
context=context,
require_active=True,
)
return ResolvedCredentialEnvelope(
id=row.id,
name=row.name,
credential_kind=row.credential_kind,
public_data=dict(row.public_data or {}),
secret_data=_decrypt_secret_mapping(row.secret_data_encrypted),
revision=row.revision,
)
def credential_envelope_summary(row: CredentialEnvelope) -> dict[str, Any]:
public_data = redact_secret_values(dict(row.public_data or {}))
return {
"id": row.id,
"tenant_id": row.tenant_id,
"scope_type": row.scope_type,
"scope_id": row.scope_id,
"name": row.name,
"description": row.description,
"credential_kind": row.credential_kind,
"public_data": public_data,
"secret_keys": sorted(str(key) for key in (row.secret_keys or [])),
"secret_configured": bool(row.secret_data_encrypted),
"allowed_modules": sorted(str(item) for item in (row.allowed_modules or [])),
"allowed_server_refs": sorted(str(item) for item in (row.allowed_server_refs or [])),
"inherit_to_lower_scopes": bool(row.inherit_to_lower_scopes),
"is_active": bool(row.is_active),
"revision": row.revision,
"created_at": row.created_at,
"updated_at": row.updated_at,
"deleted_at": row.deleted_at,
}
def _scope_visible(row: CredentialEnvelope, context: CredentialAccessContext) -> bool:
target_type = str(context.target_scope_type or "tenant").strip().casefold()
target_id = _optional_text(context.target_scope_id)
if context.administrative:
return row.scope_type == "system" or row.tenant_id == context.tenant_id
if row.scope_type == "system":
return target_type == "system" or bool(row.inherit_to_lower_scopes)
if row.tenant_id != context.tenant_id:
return False
if row.scope_type == "tenant":
if target_type == "tenant":
return row.scope_id in {None, context.tenant_id, target_id}
return bool(row.inherit_to_lower_scopes)
if row.scope_type == "user":
return row.scope_id == context.user_id or (target_type == "user" and row.scope_id == target_id)
if row.scope_type == "group":
exact = row.scope_id in context.group_ids or (target_type == "group" and row.scope_id == target_id)
return exact and (target_type == "group" or bool(row.inherit_to_lower_scopes))
if row.scope_type == "campaign":
return target_type == "campaign" and row.scope_id == target_id
return False
def _record_credential_change(
session: Session,
*,
row: CredentialEnvelope,
operation: str,
user_id: str | None,
) -> None:
scope = "system" if row.scope_type == "system" else "tenant"
details = {
"scope_type": row.scope_type,
"scope_id": row.scope_id,
"credential_kind": row.credential_kind,
"allowed_modules": list(row.allowed_modules or []),
"allowed_server_refs": list(row.allowed_server_refs or []),
"secret_configured": bool(row.secret_data_encrypted),
}
record_change(
session,
module_id="core",
collection="core.security.credentials",
resource_type="credential_envelope",
resource_id=row.id,
operation=operation,
tenant_id=row.tenant_id,
actor_type="user" if user_id else None,
actor_id=user_id,
payload={"scope_type": row.scope_type, "credential_kind": row.credential_kind},
)
audit_event(
session,
tenant_id=row.tenant_id,
action=f"credential.{operation}",
scope=scope,
user_id=user_id,
object_type="credential_envelope",
object_id=row.id,
details=details,
)
def _encrypt_secret_mapping(value: Mapping[str, Any]) -> str | None:
if not value:
return None
return encrypt_secret(json.dumps(dict(value), separators=(",", ":"), sort_keys=True))
def _decrypt_secret_mapping(value: str | None) -> dict[str, Any]:
decrypted = decrypt_secret(value)
if not decrypted:
return {}
try:
parsed = json.loads(decrypted)
except json.JSONDecodeError as exc:
raise CredentialEnvelopeError("Stored credential payload is invalid") from exc
if not isinstance(parsed, dict):
raise CredentialEnvelopeError("Stored credential payload is invalid")
return parsed
def _normalize_kind(value: str) -> str:
normalized = _required_text(value, "Credential kind is required").casefold()
if normalized not in CREDENTIAL_KINDS:
raise CredentialEnvelopeError(f"Unsupported credential kind: {value!r}")
return normalized
def _normalized_values(values: list[str] | tuple[str, ...]) -> list[str]:
return sorted({_required_text(value, "Credential policy values cannot be blank") for value in values})
def _json_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
return dict(value or {})
def _safe_public_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
payload = _json_mapping(value)
unsafe_path = _secret_public_data_path(payload)
if unsafe_path:
raise CredentialEnvelopeError(
f"Credential public_data must not contain secret field {unsafe_path!r}"
)
return payload
def _secret_public_data_path(value: object, path: str = "public_data") -> str | None:
if isinstance(value, Mapping):
for key, item in value.items():
item_path = f"{path}.{key}"
if _is_secret_public_key(key):
return item_path
nested_path = _secret_public_data_path(item, item_path)
if nested_path:
return nested_path
elif isinstance(value, list):
for index, item in enumerate(value):
nested_path = _secret_public_data_path(item, f"{path}[{index}]")
if nested_path:
return nested_path
return None
def _is_secret_public_key(key: object) -> bool:
clean_key = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", str(key).strip())
clean_key = re.sub(r"[^A-Za-z0-9]+", "_", clean_key).strip("_").casefold()
if clean_key.endswith(("_id", "_ref", "_reference", "_type")):
return False
return clean_key in _SECRET_PUBLIC_DATA_KEYS or is_sensitive_key(key)
def _required_text(value: object | None, message: str) -> str:
clean = _optional_text(value)
if not clean:
raise CredentialEnvelopeError(message)
return clean
def _optional_text(value: object | None) -> str | None:
if value is None:
return None
clean = str(value).strip()
return clean or None
__all__ = [
"CREDENTIAL_KINDS",
"CREDENTIAL_SCOPE_TYPES",
"CredentialAccessContext",
"CredentialEnvelope",
"CredentialEnvelopeError",
"ResolvedCredentialEnvelope",
"create_credential_envelope",
"credential_envelope_summary",
"credential_visible_to_context",
"get_credential_envelope",
"get_managed_credential_envelope",
"list_credential_envelopes",
"list_managed_credential_envelopes",
"normalize_credential_scope",
"resolve_credential_envelope",
"retire_credential_envelope",
"update_credential_envelope",
]
+13 -10
View File
@@ -30,18 +30,21 @@ def redact_secret_values(value: object, *, placeholder: str = "<redacted>") -> o
return result
if isinstance(value, list):
return [redact_secret_values(item, placeholder=placeholder) for item in value]
if isinstance(value, tuple):
return tuple(redact_secret_values(item, placeholder=placeholder) for item in value)
return value
def contains_plain_secret(value: object) -> bool:
if not isinstance(value, Mapping):
return False
for key, item in value.items():
normalized_key = str(key).strip().casefold().replace("-", "_")
if normalized_key in {"credential_ref", "secret_ref", "secret_reference"}:
continue
if is_sensitive_key(key) and item not in (None, "", {"secret_ref": ""}): # nosec B105 - empty redaction sentinels.
return True
if isinstance(item, Mapping) and contains_plain_secret(item):
return True
if isinstance(value, Mapping):
for key, item in value.items():
normalized_key = str(key).strip().casefold().replace("-", "_")
if normalized_key in {"credential_ref", "secret_ref", "secret_reference"}:
continue
if is_sensitive_key(key) and item not in (None, "", {"secret_ref": ""}): # nosec B105 - empty redaction sentinels.
return True
if contains_plain_secret(item):
return True
elif isinstance(value, (list, tuple)):
return any(contains_plain_secret(item) for item in value)
return False
+32 -1
View File
@@ -2,8 +2,9 @@ from __future__ import annotations
import base64
import hashlib
import json
from functools import lru_cache
from typing import Protocol, runtime_checkable
from typing import Any, Mapping, Protocol, runtime_checkable
from cryptography.fernet import Fernet, InvalidToken
@@ -18,6 +19,10 @@ class SecretDecryptionError(RuntimeError):
pass
class TransientPayloadError(RuntimeError):
pass
CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider" # noqa: S105 # nosec B105 - capability identifier.
@@ -72,3 +77,29 @@ def decrypt_secret(value: str | None) -> str | None:
return _fernet().decrypt(value.encode("utf-8")).decode("utf-8")
except InvalidToken as exc:
raise SecretDecryptionError("Stored secret cannot be decrypted with the configured master key") from exc
def seal_transient_payload(payload: Mapping[str, Any]) -> str:
"""Seal a short-lived JSON object without persisting server-side state."""
encoded = json.dumps(
dict(payload),
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
return _fernet().encrypt(encoded).decode("utf-8")
def open_transient_payload(token: str, *, ttl_seconds: int) -> dict[str, Any]:
"""Open a sealed JSON object and enforce its maximum age."""
if ttl_seconds <= 0:
raise ValueError("Transient payload TTL must be positive")
try:
encoded = _fernet().decrypt(token.encode("utf-8"), ttl=ttl_seconds)
payload = json.loads(encoded.decode("utf-8"))
except (InvalidToken, UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError) as exc:
raise TransientPayloadError("Transient payload is invalid or expired") from exc
if not isinstance(payload, dict):
raise TransientPayloadError("Transient payload must contain a JSON object")
return payload
+4
View File
@@ -8,6 +8,8 @@ from govoplan_core.db.session import configure_database
from govoplan_core.server.config import GovoplanServerConfig, load_server_config
from govoplan_core.server.fastapi import create_govoplan_app
from govoplan_core.server.platform import create_platform_router
from govoplan_core.server.credentials import router as credential_router
from govoplan_core.server.ownership import router as ownership_router
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.server.route_validation import validate_no_route_collisions
@@ -67,6 +69,8 @@ def _server_api_router(server_config: GovoplanServerConfig, registry) -> APIRout
for router in server_config.base_routers:
api_router.include_router(router)
api_router.include_router(create_platform_router(settings=server_config.settings))
api_router.include_router(credential_router)
api_router.include_router(ownership_router)
for router in server_config.post_module_routers:
api_router.include_router(router)
for contribution in server_config.extra_routers:
+380
View File
@@ -0,0 +1,380 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field, SecretStr
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.db.session import get_session
from govoplan_core.security.credential_envelopes import (
CredentialEnvelope,
CredentialEnvelopeError,
create_credential_envelope,
credential_envelope_summary,
get_managed_credential_envelope,
list_managed_credential_envelopes,
normalize_credential_scope,
retire_credential_envelope,
update_credential_envelope,
)
CredentialScopeType = Literal["system", "tenant", "group", "user", "campaign"]
class CredentialEnvelopeResponse(BaseModel):
id: str
tenant_id: str | None = None
scope_type: CredentialScopeType
scope_id: str | None = None
name: str
description: str | None = None
credential_kind: str
public_data: dict[str, Any] = Field(default_factory=dict)
secret_keys: list[str] = Field(default_factory=list)
secret_configured: bool = False
allowed_modules: list[str] = Field(default_factory=list)
allowed_server_refs: list[str] = Field(default_factory=list)
inherit_to_lower_scopes: bool = False
is_active: bool = True
revision: str
created_at: datetime | None = None
updated_at: datetime | None = None
deleted_at: datetime | None = None
class CredentialEnvelopeListResponse(BaseModel):
credentials: list[CredentialEnvelopeResponse] = Field(default_factory=list)
class CredentialEnvelopeCreateRequest(BaseModel):
scope_type: CredentialScopeType = "tenant"
scope_id: str | None = Field(default=None, max_length=255)
name: str = Field(min_length=1, max_length=255)
description: str | None = None
credential_kind: str = Field(default="username_password", max_length=40)
public_data: dict[str, Any] = Field(default_factory=dict)
secret_data: dict[str, SecretStr] = Field(default_factory=dict)
allowed_modules: list[str] = Field(default_factory=list)
allowed_server_refs: list[str] = Field(default_factory=list)
inherit_to_lower_scopes: bool = False
is_active: bool = True
class CredentialEnvelopeUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = None
credential_kind: str | None = Field(default=None, max_length=40)
public_data: dict[str, Any] | None = None
secret_data: dict[str, SecretStr] | None = None
clear_secret: bool = False
allowed_modules: list[str] | None = None
allowed_server_refs: list[str] | None = None
inherit_to_lower_scopes: bool | None = None
is_active: bool | None = None
router = APIRouter(prefix="/credentials", tags=["credentials"])
@router.get("", response_model=CredentialEnvelopeListResponse)
def list_credentials(
scope_type: CredentialScopeType = Query(default="tenant"),
scope_id: str | None = Query(default=None),
include_inactive: bool = Query(default=False),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> CredentialEnvelopeListResponse:
tenant_id, normalized_scope_type, normalized_scope_id = _target_scope(
principal,
scope_type=scope_type,
scope_id=scope_id,
write=False,
)
rows = list_managed_credential_envelopes(
session,
tenant_id=tenant_id,
include_inactive=include_inactive,
)
return CredentialEnvelopeListResponse(
credentials=[
_response(row)
for row in rows
if row.scope_type == normalized_scope_type
and row.scope_id == normalized_scope_id
]
)
@router.post(
"",
response_model=CredentialEnvelopeResponse,
status_code=status.HTTP_201_CREATED,
)
def create_credential(
payload: CredentialEnvelopeCreateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> CredentialEnvelopeResponse:
tenant_id, scope_type, scope_id = _target_scope(
principal,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
write=True,
)
try:
row = create_credential_envelope(
session,
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
name=payload.name,
description=payload.description,
credential_kind=payload.credential_kind,
public_data=payload.public_data,
secret_data=_secret_values(payload.secret_data),
allowed_modules=payload.allowed_modules,
allowed_server_refs=payload.allowed_server_refs,
inherit_to_lower_scopes=payload.inherit_to_lower_scopes,
is_active=payload.is_active,
user_id=principal.membership_id,
metadata={"created_by_module": "core"},
)
session.commit()
session.refresh(row)
return _response(row)
except CredentialEnvelopeError as exc:
session.rollback()
raise _credential_error(exc) from exc
@router.patch("/{credential_id}", response_model=CredentialEnvelopeResponse)
def update_credential(
credential_id: str,
payload: CredentialEnvelopeUpdateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> CredentialEnvelopeResponse:
try:
row = _managed_row_for_write(session, principal, credential_id)
row = update_credential_envelope(
session,
row,
name=payload.name,
description=payload.description,
description_supplied="description" in payload.model_fields_set,
credential_kind=payload.credential_kind,
public_data=payload.public_data,
secret_data=(
_secret_values(payload.secret_data)
if payload.secret_data is not None
else None
),
clear_secret=payload.clear_secret,
allowed_modules=payload.allowed_modules,
allowed_server_refs=payload.allowed_server_refs,
inherit_to_lower_scopes=payload.inherit_to_lower_scopes,
is_active=payload.is_active,
user_id=principal.membership_id,
)
session.commit()
session.refresh(row)
return _response(row)
except CredentialEnvelopeError as exc:
session.rollback()
raise _credential_error(exc) from exc
@router.delete("/{credential_id}", response_model=CredentialEnvelopeResponse)
def delete_credential(
credential_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> CredentialEnvelopeResponse:
try:
row = _managed_row_for_write(session, principal, credential_id)
retire_credential_envelope(
session,
row,
user_id=principal.membership_id,
)
response = _response(row)
session.commit()
return response
except CredentialEnvelopeError as exc:
session.rollback()
raise _credential_error(exc) from exc
def _managed_row_for_write(
session: Session,
principal: ApiPrincipal,
credential_id: str,
) -> CredentialEnvelope:
system_row = None
if _can_manage_system_credentials(principal):
try:
system_row = get_managed_credential_envelope(
session,
credential_id=credential_id,
tenant_id=None,
for_update=True,
)
except CredentialEnvelopeError:
pass
row = system_row or get_managed_credential_envelope(
session,
credential_id=credential_id,
tenant_id=principal.tenant_id,
for_update=True,
)
_target_scope(
principal,
scope_type=row.scope_type,
scope_id=row.scope_id,
write=True,
)
return row
def _target_scope(
principal: ApiPrincipal,
*,
scope_type: str,
scope_id: str | None,
write: bool,
) -> tuple[str | None, str, str | None]:
requested_type = str(scope_type or "tenant").strip().casefold()
requested_id = str(scope_id).strip() if scope_id else None
if requested_type == "system":
if not (
_can_manage_system_credentials(principal)
if write
else _can_read_system_credentials(principal)
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="System credential permission is required.",
)
return _normalize_target_scope(
tenant_id=None,
scope_type="system",
scope_id=None,
)
if requested_type == "user" and requested_id == principal.membership_id:
own_scope = (
has_scope(principal, "access:credential:manage_own")
or has_scope(principal, "mail:secret:manage_own")
)
if own_scope:
return _normalize_target_scope(
tenant_id=principal.tenant_id,
scope_type=requested_type,
scope_id=requested_id,
)
allowed = (
_can_manage_tenant_credentials(principal)
if write
else _can_read_tenant_credentials(principal)
)
if not allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Tenant credential permission is required.",
)
return _normalize_target_scope(
tenant_id=principal.tenant_id,
scope_type=requested_type,
scope_id=requested_id,
)
def _normalize_target_scope(
*,
tenant_id: str | None,
scope_type: str,
scope_id: str | None,
) -> tuple[str | None, str, str | None]:
try:
return normalize_credential_scope(
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
)
except CredentialEnvelopeError as exc:
raise _credential_error(exc) from exc
def _can_read_system_credentials(principal: ApiPrincipal) -> bool:
return any(
has_scope(principal, scope)
for scope in (
"access:system_credential:read",
"access:system_setting:read",
"system:settings:read",
)
)
def _can_manage_system_credentials(principal: ApiPrincipal) -> bool:
return any(
has_scope(principal, scope)
for scope in (
"access:system_credential:write",
"access:system_setting:write",
"system:settings:write",
)
)
def _can_read_tenant_credentials(principal: ApiPrincipal) -> bool:
return any(
has_scope(principal, scope)
for scope in (
"access:credential:read",
"access:setting:read",
"admin:settings:read",
"mail_servers:read",
)
)
def _can_manage_tenant_credentials(principal: ApiPrincipal) -> bool:
return any(
has_scope(principal, scope)
for scope in (
"access:credential:write",
"access:setting:write",
"admin:settings:write",
"mail_servers:manage_credentials",
)
)
def _secret_values(values: dict[str, SecretStr]) -> dict[str, str]:
return {
str(key): value.get_secret_value()
for key, value in values.items()
if str(key).strip() and value.get_secret_value()
}
def _response(row: CredentialEnvelope) -> CredentialEnvelopeResponse:
return CredentialEnvelopeResponse.model_validate(
credential_envelope_summary(row)
)
def _credential_error(exc: CredentialEnvelopeError) -> HTTPException:
return HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
__all__ = ["router"]
@@ -35,6 +35,12 @@ async def lifespan(app: FastAPI):
api_key_secret=settings.dev_bootstrap_api_key,
user_password=settings.dev_bootstrap_password,
)
lifecycle = getattr(app.state, "govoplan_lifecycle", None)
if lifecycle is not None and hasattr(
lifecycle,
"reconcile_workflow_definitions",
):
lifecycle.reconcile_workflow_definitions()
yield
+12 -1
View File
@@ -72,8 +72,19 @@ def _validate_production_startup() -> None:
"off",
}
if throttle_enabled and not os.getenv("REDIS_URL", "").strip():
allow_process_local = os.getenv(
"GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE",
"false",
).strip().lower() in {"1", "true", "yes", "on"}
if not allow_process_local:
raise RuntimeError(
"Production login throttling requires REDIS_URL. "
"Set GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE=true only "
"for an explicitly single-process deployment."
)
logger.warning(
"Redis is not configured; login throttling is process-local and will not coordinate horizontally"
"Redis is not configured; login throttling is explicitly limited "
"to this process"
)
+621
View File
@@ -0,0 +1,621 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.ownership import (
OwnershipAuthorizationError,
OwnershipIdempotencyConflict,
OwnershipResourceRef,
OwnershipSubjectRef,
OwnershipTransfer,
OwnershipTransferError,
OwnershipTransferExpired,
OwnershipTransferPolicy,
accept_ownership_transfer,
approve_administrative_recovery,
approve_ownership_request,
cancel_ownership_transfer,
decline_ownership_transfer,
execute_administrative_recovery,
request_ownership,
start_administrative_recovery,
start_owner_initiated_transfer,
)
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.session import get_session
RECENT_AUTHENTICATION_WINDOW = timedelta(minutes=15)
class OwnershipSubjectRequest(BaseModel):
type: str = Field(min_length=1, max_length=40, pattern=r"^[a-z][a-z0-9_-]*$")
id: str = Field(min_length=1, max_length=255)
class OwnershipResourceRequest(BaseModel):
module_id: str = Field(
min_length=1,
max_length=100,
pattern=r"^[a-z][a-z0-9_-]*$",
)
resource_type: str = Field(
min_length=1,
max_length=100,
pattern=r"^[a-z][a-z0-9_-]*$",
)
resource_id: str = Field(min_length=1, max_length=255)
class OwnershipTransferStartRequest(BaseModel):
resource: OwnershipResourceRequest
target_owner: OwnershipSubjectRequest
idempotency_key: str = Field(min_length=1, max_length=200)
reason: str | None = Field(default=None, max_length=4000)
expiry_days: int | None = Field(default=None, ge=1, le=30)
class OwnershipRequestStartRequest(BaseModel):
resource: OwnershipResourceRequest
target_owner: OwnershipSubjectRequest | None = None
idempotency_key: str = Field(min_length=1, max_length=200)
reason: str | None = Field(default=None, max_length=4000)
expiry_days: int | None = Field(default=None, ge=1, le=30)
class OwnershipRecoveryStartRequest(BaseModel):
resource: OwnershipResourceRequest
target_owner: OwnershipSubjectRequest
idempotency_key: str = Field(min_length=1, max_length=200)
reason: str = Field(min_length=1, max_length=4000)
class OwnershipTransferResponse(BaseModel):
id: str
tenant_id: str
resource_module: str
resource_type: str
resource_id: str
kind: str
status: str
current_owner: OwnershipSubjectRequest
target_owner: OwnershipSubjectRequest
initiated_by: OwnershipSubjectRequest
owner_approved_by: OwnershipSubjectRequest | None = None
target_accepted_by: OwnershipSubjectRequest | None = None
reason: str | None = None
assurance_profile: str | None = None
required_approvals: int
approvals: list[dict[str, Any]] = Field(default_factory=list)
decisions: list[dict[str, Any]] = Field(default_factory=list)
expires_at: datetime
execute_after: datetime | None = None
completed_at: datetime | None = None
declined_at: datetime | None = None
cancelled_at: datetime | None = None
expired_at: datetime | None = None
revision: int
metadata: dict[str, Any] = Field(default_factory=dict)
created_at: datetime
updated_at: datetime
class OwnershipTransferListResponse(BaseModel):
transfers: list[OwnershipTransferResponse] = Field(default_factory=list)
router = APIRouter(prefix="/ownership/transfers", tags=["ownership"])
@router.get("", response_model=OwnershipTransferListResponse)
def list_ownership_transfers(
request: Request,
resource_type: str | None = Query(default=None, max_length=100),
resource_id: str | None = Query(default=None, max_length=255),
transfer_status: str | None = Query(default=None, alias="status", max_length=50),
limit: int = Query(default=50, ge=1, le=200),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> OwnershipTransferListResponse:
actor = _actor(principal)
query = session.query(OwnershipTransfer).filter(
OwnershipTransfer.tenant_id == principal.tenant_id
)
if resource_type:
query = query.filter(
OwnershipTransfer.resource_type == resource_type.strip().lower()
)
if resource_id:
query = query.filter(OwnershipTransfer.resource_id == resource_id)
if transfer_status:
query = query.filter(OwnershipTransfer.status == transfer_status)
rows = (
query.order_by(
OwnershipTransfer.created_at.desc(),
OwnershipTransfer.id.desc(),
)
.limit(min(limit * 5, 1000))
.all()
)
visible = [
row
for row in rows
if _can_view_transfer(request, session, row, actor)
]
return OwnershipTransferListResponse(
transfers=[_response(row) for row in visible[:limit]]
)
@router.get("/{transfer_id}", response_model=OwnershipTransferResponse)
def get_ownership_transfer(
transfer_id: str,
request: Request,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> OwnershipTransferResponse:
transfer = _visible_transfer(
request,
session,
principal,
transfer_id,
)
return _response(transfer)
@router.post(
"",
response_model=OwnershipTransferResponse,
status_code=status.HTTP_201_CREATED,
)
def create_owner_initiated_transfer(
payload: OwnershipTransferStartRequest,
request: Request,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> OwnershipTransferResponse:
return _write(
session,
lambda: start_owner_initiated_transfer(
session,
tenant_id=principal.tenant_id,
resource=_resource(payload.resource),
provider=_provider(request, payload.resource),
actor=_actor(principal),
target_owner=_subject(payload.target_owner),
idempotency_key=payload.idempotency_key,
reason=payload.reason,
expiry_days=payload.expiry_days,
),
)
@router.post(
"/requests",
response_model=OwnershipTransferResponse,
status_code=status.HTTP_201_CREATED,
)
def create_ownership_request(
payload: OwnershipRequestStartRequest,
request: Request,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> OwnershipTransferResponse:
return _write(
session,
lambda: request_ownership(
session,
tenant_id=principal.tenant_id,
resource=_resource(payload.resource),
provider=_provider(request, payload.resource),
actor=_actor(principal),
target_owner=(
_subject(payload.target_owner) if payload.target_owner else None
),
idempotency_key=payload.idempotency_key,
reason=payload.reason,
expiry_days=payload.expiry_days,
),
)
@router.post(
"/recoveries",
response_model=OwnershipTransferResponse,
status_code=status.HTTP_201_CREATED,
)
def create_administrative_recovery(
payload: OwnershipRecoveryStartRequest,
request: Request,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> OwnershipTransferResponse:
_require_recovery_authority(principal)
return _write(
session,
lambda: start_administrative_recovery(
session,
tenant_id=principal.tenant_id,
resource=_resource(payload.resource),
provider=_provider(request, payload.resource),
actor=_actor(principal),
target_owner=_subject(payload.target_owner),
idempotency_key=payload.idempotency_key,
reason=payload.reason,
policy=OwnershipTransferPolicy(),
),
)
@router.post("/{transfer_id}/owner-approval", response_model=OwnershipTransferResponse)
def approve_requested_transfer(
transfer_id: str,
request: Request,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> OwnershipTransferResponse:
return _mutate_existing(
request,
session,
principal,
transfer_id,
approve_ownership_request,
)
@router.post("/{transfer_id}/acceptance", response_model=OwnershipTransferResponse)
def accept_transfer(
transfer_id: str,
request: Request,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> OwnershipTransferResponse:
return _mutate_existing(
request,
session,
principal,
transfer_id,
accept_ownership_transfer,
)
@router.post("/{transfer_id}/decline", response_model=OwnershipTransferResponse)
def decline_transfer(
transfer_id: str,
request: Request,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> OwnershipTransferResponse:
return _mutate_existing(
request,
session,
principal,
transfer_id,
decline_ownership_transfer,
)
@router.post("/{transfer_id}/cancel", response_model=OwnershipTransferResponse)
def cancel_transfer(
transfer_id: str,
request: Request,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> OwnershipTransferResponse:
return _mutate_existing(
request,
session,
principal,
transfer_id,
cancel_ownership_transfer,
)
@router.post(
"/{transfer_id}/recovery-approval",
response_model=OwnershipTransferResponse,
)
def approve_recovery(
transfer_id: str,
request: Request,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> OwnershipTransferResponse:
_require_recovery_authority(principal)
return _mutate_existing(
request,
session,
principal,
transfer_id,
approve_administrative_recovery,
)
@router.post(
"/{transfer_id}/recovery-execution",
response_model=OwnershipTransferResponse,
)
def execute_recovery(
transfer_id: str,
request: Request,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> OwnershipTransferResponse:
_require_recovery_authority(principal)
return _mutate_existing(
request,
session,
principal,
transfer_id,
execute_administrative_recovery,
)
def _mutate_existing(
request: Request,
session: Session,
principal: ApiPrincipal,
transfer_id: str,
operation,
) -> OwnershipTransferResponse:
transfer = _tenant_transfer(
session,
principal,
transfer_id,
for_update=True,
)
provider = _provider_for_transfer(request, transfer)
return _write(
session,
lambda: operation(
session,
transfer=transfer,
provider=provider,
actor=_actor(principal),
),
)
def _write(session: Session, operation) -> OwnershipTransferResponse:
try:
transfer = operation()
session.commit()
session.refresh(transfer)
return _response(transfer)
except OwnershipAuthorizationError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(exc),
) from exc
except OwnershipIdempotencyConflict as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except OwnershipTransferExpired as exc:
session.commit()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except OwnershipTransferError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
def _tenant_transfer(
session: Session,
principal: ApiPrincipal,
transfer_id: str,
*,
for_update: bool = False,
) -> OwnershipTransfer:
query = session.query(OwnershipTransfer).filter(
OwnershipTransfer.id == transfer_id,
OwnershipTransfer.tenant_id == principal.tenant_id,
)
if for_update:
query = query.with_for_update()
transfer = query.one_or_none()
if transfer is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ownership transfer not found",
)
return transfer
def _visible_transfer(
request: Request,
session: Session,
principal: ApiPrincipal,
transfer_id: str,
) -> OwnershipTransfer:
transfer = _tenant_transfer(session, principal, transfer_id)
if not _can_view_transfer(request, session, transfer, _actor(principal)):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Ownership transfer not found",
)
return transfer
def _can_view_transfer(
request: Request,
session: Session,
transfer: OwnershipTransfer,
actor: OwnershipSubjectRef,
) -> bool:
provider = _provider_for_transfer(request, transfer)
decision = provider.authorize_ownership_action(
session,
tenant_id=transfer.tenant_id,
resource_id=transfer.resource_id,
action="view_transfer",
actor=actor,
current_owner=OwnershipSubjectRef(
type=transfer.current_owner_type,
id=transfer.current_owner_id,
),
target_owner=OwnershipSubjectRef(
type=transfer.target_owner_type,
id=transfer.target_owner_id,
),
)
return decision.allowed
def _provider(request: Request, resource: OwnershipResourceRequest):
registry = _registry(request)
registration = registry.ownership_provider_registration(resource.resource_type)
if (
registration is None
or registration.module_id != resource.module_id
):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Owned resource type is not available",
)
return registration.provider
def _provider_for_transfer(request: Request, transfer: OwnershipTransfer):
return _provider(
request,
OwnershipResourceRequest(
module_id=transfer.resource_module,
resource_type=transfer.resource_type,
resource_id=transfer.resource_id,
),
)
def _registry(request: Request) -> PlatformRegistry:
registry = getattr(request.app.state, "govoplan_registry", None)
if not isinstance(registry, PlatformRegistry):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Platform registry is not available",
)
return registry
def _actor(principal: ApiPrincipal) -> OwnershipSubjectRef:
actor_id = principal.membership_id
if not actor_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Ownership actions require a tenant membership",
)
return OwnershipSubjectRef(
type="user",
id=actor_id,
label=principal.display_name,
scopes=principal.scopes,
group_ids=principal.group_ids,
recently_authenticated=_recently_authenticated(principal),
)
def _recently_authenticated(principal: ApiPrincipal) -> bool:
auth_session = principal.auth_session
created_at = getattr(auth_session, "created_at", None)
if not isinstance(created_at, datetime):
return False
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
elapsed = datetime.now(timezone.utc) - created_at
return timedelta(0) <= elapsed <= RECENT_AUTHENTICATION_WINDOW
def _require_recovery_authority(principal: ApiPrincipal) -> None:
if not (
principal.has("admin:settings:write")
or principal.has("system:settings:write")
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Administrative ownership recovery authority is required",
)
def _resource(payload: OwnershipResourceRequest) -> OwnershipResourceRef:
return OwnershipResourceRef(
module_id=payload.module_id,
resource_type=payload.resource_type,
resource_id=payload.resource_id,
)
def _subject(payload: OwnershipSubjectRequest) -> OwnershipSubjectRef:
return OwnershipSubjectRef(type=payload.type, id=payload.id)
def _optional_subject(
subject_type: str | None,
subject_id: str | None,
) -> OwnershipSubjectRequest | None:
if not subject_type or not subject_id:
return None
return OwnershipSubjectRequest(type=subject_type, id=subject_id)
def _response(transfer: OwnershipTransfer) -> OwnershipTransferResponse:
return OwnershipTransferResponse(
id=transfer.id,
tenant_id=transfer.tenant_id,
resource_module=transfer.resource_module,
resource_type=transfer.resource_type,
resource_id=transfer.resource_id,
kind=transfer.kind,
status=transfer.status,
current_owner=OwnershipSubjectRequest(
type=transfer.current_owner_type,
id=transfer.current_owner_id,
),
target_owner=OwnershipSubjectRequest(
type=transfer.target_owner_type,
id=transfer.target_owner_id,
),
initiated_by=OwnershipSubjectRequest(
type=transfer.initiated_by_type,
id=transfer.initiated_by_id,
),
owner_approved_by=_optional_subject(
transfer.owner_approved_by_type,
transfer.owner_approved_by_id,
),
target_accepted_by=_optional_subject(
transfer.target_accepted_by_type,
transfer.target_accepted_by_id,
),
reason=transfer.reason,
assurance_profile=transfer.assurance_profile,
required_approvals=transfer.required_approvals,
approvals=list(transfer.approvals or []),
decisions=list(transfer.decisions or []),
expires_at=transfer.expires_at,
execute_after=transfer.execute_after,
completed_at=transfer.completed_at,
declined_at=transfer.declined_at,
cancelled_at=transfer.cancelled_at,
expired_at=transfer.expired_at,
revision=transfer.revision,
metadata=dict(transfer.metadata_ or {}),
created_at=transfer.created_at,
updated_at=transfer.updated_at,
)
__all__ = ["router"]
+109 -14
View File
@@ -1,13 +1,20 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.exc import SQLAlchemyError
from govoplan_core.admin.models import SystemSettings
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.maintenance import saved_maintenance_mode
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleManifest, NavItem, PublicFrontendRoute
from govoplan_core.core.registry import PlatformRegistry, manifest_view_surfaces
from govoplan_core.core.views import (
VIEW_SURFACE_CONTRACT_VERSION,
ViewSurface,
navigation_view_surface_id,
route_view_surface_id,
)
from govoplan_core.db.session import get_database
from govoplan_core.i18n import system_i18n_payload
@@ -19,7 +26,7 @@ def _registry(request: Request) -> PlatformRegistry:
return registry
def _nav_item_payload(item: NavItem) -> dict[str, object]:
def _nav_item_payload(item: NavItem, module_id: str | None = None) -> dict[str, object]:
return {
"path": item.path,
"label": item.label,
@@ -28,20 +35,60 @@ def _nav_item_payload(item: NavItem) -> dict[str, object]:
"required_all": list(item.required_all),
"required_any": list(item.required_any),
"order": item.order,
"surface_id": (
item.surface_id or navigation_view_surface_id(module_id, item.path)
if module_id
else item.surface_id
),
}
def _frontend_route_payload(route: FrontendRoute) -> dict[str, object]:
def _frontend_route_payload(route: FrontendRoute, module_id: str | None = None) -> dict[str, object]:
return {
"path": route.path,
"component": route.component,
"required_all": list(route.required_all),
"required_any": list(route.required_any),
"order": route.order,
"surface_id": (
route.surface_id or route_view_surface_id(module_id, route.path)
if module_id
else route.surface_id
),
}
def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | None:
def _public_frontend_route_payload(route: PublicFrontendRoute) -> dict[str, object]:
return {
"path": route.path,
"component": route.component,
"order": route.order,
}
def _view_surface_payload(surface: ViewSurface) -> dict[str, object]:
return {
"id": surface.id,
"module_id": surface.module_id,
"kind": surface.kind,
"label": surface.label,
"parent_id": surface.parent_id,
"description": surface.description,
"order": surface.order,
"default_visible": surface.default_visible,
"required": surface.required,
}
def _frontend_view_surfaces(manifest: ModuleManifest) -> list[dict[str, object]]:
return [
_view_surface_payload(surface)
for surface in manifest_view_surfaces(manifest)
]
def _frontend_payload(manifest: ModuleManifest) -> dict[str, object] | None:
frontend = manifest.frontend
if frontend is None:
return None
return {
@@ -52,9 +99,31 @@ def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | No
"asset_manifest_public_key_id": frontend.asset_manifest_public_key_id,
"asset_manifest_integrity": frontend.asset_manifest_integrity,
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
"routes": [_frontend_route_payload(route) for route in frontend.routes],
"nav": [_nav_item_payload(item) for item in frontend.nav_items],
"settings_routes": [_frontend_route_payload(route) for route in frontend.settings_routes],
"routes": [_frontend_route_payload(route, manifest.id) for route in frontend.routes],
"public_routes": [
_public_frontend_route_payload(route) for route in frontend.public_routes
],
"nav": [_nav_item_payload(item, manifest.id) for item in frontend.nav_items],
"settings_routes": [_frontend_route_payload(route, manifest.id) for route in frontend.settings_routes],
"view_surface_contract_version": VIEW_SURFACE_CONTRACT_VERSION,
"view_surfaces": _frontend_view_surfaces(manifest),
}
def _public_frontend_payload(frontend: FrontendModule) -> dict[str, object]:
"""Return only assets and routes explicitly approved for signed-out use."""
return {
"module_id": frontend.module_id,
"package_name": frontend.package_name,
"asset_manifest": frontend.asset_manifest,
"asset_manifest_signature": frontend.asset_manifest_signature,
"asset_manifest_public_key_id": frontend.asset_manifest_public_key_id,
"asset_manifest_integrity": frontend.asset_manifest_integrity,
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
"public_routes": [
_public_frontend_route_payload(route) for route in frontend.public_routes
],
}
@@ -85,7 +154,10 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
}
@router.get("/modules")
def modules(request: Request):
def modules(
request: Request,
_principal: ApiPrincipal = Depends(get_api_principal),
):
registry = _registry(request)
return {
"modules": [
@@ -97,15 +169,35 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
"optional_dependencies": list(manifest.optional_dependencies),
"enabled": True,
"runtime_ui_capabilities": _runtime_ui_capabilities(manifest.id, settings, registry),
"nav": [_nav_item_payload(item) for item in manifest.nav_items],
"frontend": _frontend_payload(manifest.frontend),
"nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items],
"frontend": _frontend_payload(manifest),
}
for manifest in registry.manifests()
]
}
@router.get("/public-modules")
def public_modules(request: Request):
registry = _registry(request)
return {
"modules": [
{
"id": manifest.id,
"name": manifest.name,
"version": manifest.version,
"frontend": _public_frontend_payload(manifest.frontend),
}
for manifest in registry.manifests()
if manifest.frontend is not None
and manifest.frontend.public_routes
]
}
@router.get("/permissions")
def permissions(request: Request):
def permissions(
request: Request,
_principal: ApiPrincipal = Depends(get_api_principal),
):
registry = _registry(request)
return {
"permissions": [
@@ -125,7 +217,10 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
}
@router.get("/navigation")
def navigation(request: Request):
def navigation(
request: Request,
_principal: ApiPrincipal = Depends(get_api_principal),
):
registry = _registry(request)
return {
"items": [_nav_item_payload(item) for item in registry.nav_items()]
+58 -10
View File
@@ -3,6 +3,9 @@ from __future__ import annotations
from collections.abc import Callable, Iterable, Sequence
import importlib
from govoplan_core.core.access import (
DEFAULT_CAPABILITY_PROVIDERS,
)
from govoplan_core.core.discovery import discover_module_manifests
from govoplan_core.core.modules import ModuleManifest
from govoplan_core.core.registry import PlatformRegistry, RegistryError
@@ -15,7 +18,6 @@ _BUILTIN_MANIFESTS = {
"tenancy": "govoplan_tenancy.backend.manifest:get_manifest",
}
def parse_enabled_modules(value: str | Iterable[str]) -> list[str]:
if isinstance(value, str):
items = [item.strip() for item in value.split(",")]
@@ -57,22 +59,68 @@ def available_module_manifests(
return manifests
def build_platform_registry(enabled_modules: str | Iterable[str], *, manifest_factories: Sequence[ManifestFactory] = ()) -> PlatformRegistry:
def build_platform_registry(
enabled_modules: str | Iterable[str],
*,
manifest_factories: Sequence[ManifestFactory] = (),
) -> PlatformRegistry:
requested = parse_enabled_modules(enabled_modules)
if "access" not in requested:
requested.insert(0, "access")
available = available_module_manifests(manifest_factories, enabled_modules=requested)
available = _resolve_required_manifests(
requested,
manifest_factories=manifest_factories,
)
registry = PlatformRegistry()
for module_id in requested:
manifest = available.get(module_id)
if manifest is None:
raise RegistryError(f"Enabled module is not available: {module_id}")
for module_id, manifest in available.items():
registry.register(manifest)
registry.validate()
return registry
def _resolve_required_manifests(
requested: Sequence[str],
*,
manifest_factories: Sequence[ManifestFactory],
) -> dict[str, ModuleManifest]:
manifests: dict[str, ModuleManifest] = {}
pending = list(dict.fromkeys(requested))
while pending:
module_ids = [module_id for module_id in pending if module_id not in manifests]
pending = []
if not module_ids:
break
available = available_module_manifests(
manifest_factories,
enabled_modules=module_ids,
)
for module_id in module_ids:
manifest = available.get(module_id)
if manifest is None:
raise RegistryError(f"Enabled module is not available: {module_id}")
manifests[module_id] = manifest
provided_capabilities = {
capability
for manifest in manifests.values()
for capability in manifest.capability_factories
}
for manifest in tuple(manifests.values()):
pending.extend(
dependency_id
for dependency_id in manifest.dependencies
if dependency_id not in manifests
)
for capability in manifest.required_capabilities:
if capability in provided_capabilities:
continue
provider_id = DEFAULT_CAPABILITY_PROVIDERS.get(capability)
if provider_id is not None and provider_id not in manifests:
pending.append(provider_id)
return manifests
def _load_builtin_manifest(module_id: str) -> ModuleManifest:
target = _BUILTIN_MANIFESTS[module_id]
module_name, function_name = validate_object_path(target)
+108 -2
View File
@@ -6,18 +6,54 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=None, extra="ignore")
app_env: str = Field(default="dev", alias="APP_ENV")
module_live_apply_enabled: bool | None = Field(
default=None,
alias="GOVOPLAN_MODULE_LIVE_APPLY_ENABLED",
)
database_url: str = Field(
default="postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev",
alias="DATABASE_URL",
)
database_pool_size: int = Field(
default=5,
alias="GOVOPLAN_DB_POOL_SIZE",
ge=1,
le=100,
)
database_max_overflow: int = Field(
default=10,
alias="GOVOPLAN_DB_MAX_OVERFLOW",
ge=0,
le=200,
)
database_pool_timeout_seconds: int = Field(
default=30,
alias="GOVOPLAN_DB_POOL_TIMEOUT_SECONDS",
ge=1,
le=300,
)
database_pool_recycle_seconds: int = Field(
default=1800,
alias="GOVOPLAN_DB_POOL_RECYCLE_SECONDS",
ge=0,
le=86_400,
)
access_database_url: str | None = Field(default=None, alias="ACCESS_DATABASE_URL")
access_db_schema: str | None = Field(default=None, alias="ACCESS_DB_SCHEMA")
access_table_prefix: str = Field(default="access_", alias="ACCESS_TABLE_PREFIX")
tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE")
tenant_db_url_template: str | None = Field(default=None, alias="TENANT_DB_URL_TEMPLATE")
tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE")
enabled_modules: str = Field(default="tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,notifications,docs,ops", alias="ENABLED_MODULES")
enabled_modules: str = Field(
default=(
"tenancy,organizations,identity,idm,access,admin,dashboard,policy,"
"audit,campaigns,files,mail,calendar,poll,scheduling,connectors,"
"datasources,dataflow,dist_lists,workflow_engine,workflow,views,search,risk_compliance,"
"postbox,notifications,docs,ops"
),
alias="ENABLED_MODULES",
)
migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK")
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
@@ -38,8 +74,28 @@ class Settings(BaseSettings):
file_storage_s3_access_key_id: str | None = Field(default=None, alias="FILE_STORAGE_S3_ACCESS_KEY_ID")
file_storage_s3_secret_access_key: str | None = Field(default=None, alias="FILE_STORAGE_S3_SECRET_ACCESS_KEY")
file_storage_s3_bucket: str | None = Field(default="files", alias="FILE_STORAGE_S3_BUCKET")
file_storage_s3_deployment_managed: bool = Field(
default=False,
alias="FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
)
file_upload_max_bytes: int = Field(default=50 * 1024 * 1024, alias="FILE_UPLOAD_MAX_BYTES")
file_upload_zip_max_bytes: int = Field(default=250 * 1024 * 1024, alias="FILE_UPLOAD_ZIP_MAX_BYTES")
file_archive_max_entries: int = Field(default=10_000, ge=1, alias="FILE_ARCHIVE_MAX_ENTRIES")
file_archive_max_expanded_bytes: int = Field(
default=2 * 1024 * 1024 * 1024,
ge=1,
alias="FILE_ARCHIVE_MAX_EXPANDED_BYTES",
)
file_archive_max_expansion_ratio: int = Field(
default=100,
ge=1,
alias="FILE_ARCHIVE_MAX_EXPANSION_RATIO",
)
file_archive_preview_ttl_seconds: int = Field(
default=30 * 60,
ge=60,
alias="FILE_ARCHIVE_PREVIEW_TTL_SECONDS",
)
auth_session_cookie_name: str = Field(default="govoplan_session", alias="AUTH_SESSION_COOKIE_NAME")
auth_csrf_cookie_name: str = Field(default="govoplan_csrf", alias="AUTH_CSRF_COOKIE_NAME")
@@ -47,6 +103,33 @@ class Settings(BaseSettings):
auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE")
auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN")
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS")
auth_activity_touch_interval_seconds: int = Field(
default=5 * 60,
ge=0,
alias="AUTH_ACTIVITY_TOUCH_INTERVAL_SECONDS",
)
auth_principal_cache_enabled: bool = Field(
default=True,
alias="AUTH_PRINCIPAL_CACHE_ENABLED",
)
auth_principal_cache_session_ttl_seconds: int = Field(
default=30,
ge=0,
le=300,
alias="AUTH_PRINCIPAL_CACHE_SESSION_TTL_SECONDS",
)
auth_principal_cache_api_key_ttl_seconds: int = Field(
default=10,
ge=0,
le=300,
alias="AUTH_PRINCIPAL_CACHE_API_KEY_TTL_SECONDS",
)
auth_principal_cache_max_entries: int = Field(
default=2048,
ge=1,
le=100_000,
alias="AUTH_PRINCIPAL_CACHE_MAX_ENTRIES",
)
auth_login_throttle_enabled: bool = Field(default=True, alias="AUTH_LOGIN_THROTTLE_ENABLED")
auth_login_throttle_identity_limit: int = Field(
default=10,
@@ -70,12 +153,35 @@ class Settings(BaseSettings):
)
master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64")
celery_queues: str = Field(default="send_email,append_sent,notifications,calendar,default", alias="CELERY_QUEUES")
celery_queues: str = Field(
default=(
"send_email,append_sent,notifications,calendar,"
"dataflow,workflow,events,default"
),
alias="CELERY_QUEUES",
)
calendar_outbox_terminal_retention_days: int = Field(
default=90,
ge=0,
alias="CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS",
)
platform_event_outbox_max_attempts: int = Field(
default=8,
ge=1,
le=100,
alias="PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS",
)
platform_event_outbox_terminal_retention_days: int = Field(
default=90,
ge=0,
alias="PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS",
)
scheduling_cancellation_notice_days: int = Field(
default=30,
ge=1,
le=90,
alias="SCHEDULING_CANCELLATION_NOTICE_DAYS",
)
mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR")
# Development bootstrap only. Do not use this in production.
+143 -6
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
from dataclasses import dataclass
from collections.abc import Mapping, Sequence
from typing import Protocol, runtime_checkable
from sqlalchemy.orm import Session
@@ -18,6 +20,16 @@ class EffectiveTenantGovernance:
allow_api_keys: bool
@runtime_checkable
class _AccessTenantCountsBatchProvider(Protocol):
def tenant_counts_many(
self,
session: object,
tenant_ids: Sequence[str],
) -> Mapping[str, Mapping[str, int]]:
...
def _narrowing_bool(system_allows: bool, tenant_override: bool | None) -> bool:
if not system_allows:
return False
@@ -46,12 +58,24 @@ def assert_tenant_governance_override_allowed(session: Session, *, field: str, v
raise AdminValidationError("Tenant governance cannot explicitly allow a capability denied by system settings.")
def _tenant_module_counts(session: Session, tenant_id: str) -> dict[str, int]:
def _tenant_module_counts(
session: Session,
tenant_id: str,
*,
module_ids: tuple[str, ...] | None = None,
) -> dict[str, int]:
registry = get_registry()
if registry is None or not hasattr(registry, "tenant_summary_providers"):
return {}
counts: dict[str, int] = {}
for provider in registry.tenant_summary_providers().values():
providers = registry.tenant_summary_providers()
if module_ids is not None:
providers = {
module_id: provider
for module_id, provider in providers.items()
if module_id in module_ids
}
for provider in providers.values():
provided = provider(session, tenant_id)
counts.update({str(key): int(value) for key, value in provided.items()})
return counts
@@ -67,17 +91,130 @@ def _access_administration() -> AccessAdministration | None:
return capability
def tenant_counts(session: Session, tenant_id: str) -> dict[str, int]:
module_counts = _tenant_module_counts(session, tenant_id)
def tenant_counts(
session: Session,
tenant_id: str,
*,
module_ids: tuple[str, ...] | None = None,
) -> dict[str, int]:
module_counts = _tenant_module_counts(
session,
tenant_id,
module_ids=module_ids,
)
access_administration = _access_administration()
access_counts = access_administration.tenant_counts(session, tenant_id) if access_administration is not None else {}
return {
**module_counts,
"users": int(access_counts.get("users", 0)),
"active_users": int(access_counts.get("active_users", 0)),
"groups": int(access_counts.get("groups", 0)),
"campaigns": module_counts.get("campaigns", 0),
"files": module_counts.get("files", 0),
"campaigns": int(module_counts.get("campaigns", 0)),
"files": int(module_counts.get("files", 0)),
"api_keys": int(access_counts.get("api_keys", 0)),
"active_api_keys": int(access_counts.get("active_api_keys", access_counts.get("api_keys", 0))),
}
def tenant_counts_many(
session: Session,
tenant_ids: Sequence[str],
*,
module_ids: tuple[str, ...] | None = None,
) -> dict[str, dict[str, int]]:
"""Collect list-page summaries while preserving legacy provider support."""
normalized_ids = tuple(
dict.fromkeys(
str(tenant_id).strip()
for tenant_id in tenant_ids
if str(tenant_id).strip()
)
)
counts_by_tenant: dict[str, dict[str, int]] = {
tenant_id: {} for tenant_id in normalized_ids
}
if not normalized_ids:
return counts_by_tenant
registry = get_registry()
if registry is not None and hasattr(registry, "tenant_summary_providers"):
providers = dict(registry.tenant_summary_providers())
if module_ids is not None:
providers = {
module_id: provider
for module_id, provider in providers.items()
if module_id in module_ids
}
batch_providers = (
dict(registry.tenant_summary_batch_providers())
if hasattr(registry, "tenant_summary_batch_providers")
else {}
)
for module_id, provider in providers.items():
batch_provider = batch_providers.get(module_id)
if batch_provider is None:
provided_by_tenant = {
tenant_id: provider(session, tenant_id)
for tenant_id in normalized_ids
}
else:
provided_by_tenant = batch_provider(session, normalized_ids)
_merge_tenant_counts(
counts_by_tenant,
provided_by_tenant,
tenant_ids=normalized_ids,
)
access_administration = _access_administration()
if isinstance(access_administration, _AccessTenantCountsBatchProvider):
access_counts = access_administration.tenant_counts_many(
session,
normalized_ids,
)
elif access_administration is not None:
access_counts = {
tenant_id: access_administration.tenant_counts(session, tenant_id)
for tenant_id in normalized_ids
}
else:
access_counts = {}
_merge_tenant_counts(
counts_by_tenant,
access_counts,
tenant_ids=normalized_ids,
)
return {
tenant_id: _normalized_tenant_counts(counts_by_tenant[tenant_id])
for tenant_id in normalized_ids
}
def _merge_tenant_counts(
target: dict[str, dict[str, int]],
provided_by_tenant: Mapping[str, Mapping[str, int]],
*,
tenant_ids: Sequence[str],
) -> None:
for tenant_id in tenant_ids:
provided = provided_by_tenant.get(tenant_id, {})
target[tenant_id].update(
{str(key): int(value) for key, value in provided.items()}
)
def _normalized_tenant_counts(counts: Mapping[str, int]) -> dict[str, int]:
return {
**{str(key): int(value) for key, value in counts.items()},
"users": int(counts.get("users", 0)),
"active_users": int(counts.get("active_users", 0)),
"groups": int(counts.get("groups", 0)),
"campaigns": int(counts.get("campaigns", 0)),
"files": int(counts.get("files", 0)),
"api_keys": int(counts.get("api_keys", 0)),
"active_api_keys": int(
counts.get("active_api_keys", counts.get("api_keys", 0))
),
}
+77 -4
View File
@@ -25,6 +25,7 @@ from govoplan_core.core.access import (
CAPABILITY_AUDIT_RECORDER,
CAPABILITY_AUDIT_RETENTION,
CAPABILITY_AUDIT_SINK,
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
CAPABILITY_TENANCY_TENANT_RESOLVER,
AccessDecision,
AccessAdministration,
@@ -1082,16 +1083,62 @@ class AccessContractTests(unittest.TestCase):
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):
def organization_function_assignments_for_identity(
self,
requested_identity_id: str,
*,
tenant_id: str | None = None,
effective_at=None,
):
del effective_at
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):
def organization_function_assignments_for_account(
self,
requested_account_id: str,
*,
tenant_id: str | None = None,
effective_at=None,
):
del effective_at
if requested_account_id == account_id and tenant_id in (None, assignment.tenant_id):
return (assignment,)
return ()
def organization_function_assignments_for_identities(
self,
identity_ids,
*,
tenant_id: str | None = None,
effective_at=None,
):
return {
item_id: self.organization_function_assignments_for_identity(
item_id,
tenant_id=tenant_id,
effective_at=effective_at,
)
for item_id in identity_ids
}
def organization_function_assignments_for_accounts(
self,
account_ids,
*,
tenant_id: str | None = None,
effective_at=None,
):
return {
item_id: self.organization_function_assignments_for_account(
item_id,
tenant_id=tenant_id,
effective_at=effective_at,
)
for item_id in account_ids
}
class FakeOrganizationDirectory(CoreOrganizationDirectory):
def get_organization_unit(self, requested_organization_unit_id: str):
if requested_organization_unit_id != organization_unit_id:
@@ -1317,7 +1364,7 @@ class AccessContractTests(unittest.TestCase):
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_auth_provider_capability(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-access-contract-"))
try:
account_id = "account-capability"
@@ -1346,6 +1393,31 @@ class AccessContractTests(unittest.TestCase):
def explain_scope(self, principal: PrincipalRef, required_scope: str) -> AccessDecision:
return AccessDecision(allowed=self.has_scope(principal, required_scope), requirements=(required_scope,))
class CapabilityApiPrincipalProvider:
def resolve_api_principal(
self,
request,
session,
*,
authorization=None,
x_api_key=None,
):
del authorization, x_api_key
principal_ref = CapabilityPrincipalResolver().resolve_request(
request,
session=session,
)
account = session.get(Account, principal_ref.account_id)
user = session.get(User, principal_ref.membership_id)
assert account is not None
assert user is not None
return ApiPrincipal(
principal=principal_ref,
account=account,
user=user,
permission_evaluator=CapabilityPermissionEvaluator(),
)
router = APIRouter()
@router.get("/capability-principal")
@@ -1365,6 +1437,7 @@ class AccessContractTests(unittest.TestCase):
capability_factories={
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER: lambda context: CapabilityPrincipalResolver(),
CAPABILITY_ACCESS_PERMISSION_EVALUATOR: lambda context: CapabilityPermissionEvaluator(),
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER: lambda context: CapabilityApiPrincipalProvider(),
},
)
@@ -1372,7 +1445,7 @@ class AccessContractTests(unittest.TestCase):
title="access contract test",
version="test",
settings=SimpleNamespace(database_url=f"sqlite:///{root / 'test.db'}"),
enabled_modules=(),
enabled_modules=(ACCESS_MODULE_ID,),
manifest_factories=(access_manifest,),
base_routers=(router,),
post_module_routers=(),
+318 -144
View File
@@ -52,6 +52,7 @@ engine = _database.engine
SessionLocal = _database.SessionLocal
from govoplan_core.server.app import app
from govoplan_campaign.backend.sending.execution import SNAPSHOT_VERSION
class ApiSmokeTests(unittest.TestCase):
@@ -100,6 +101,55 @@ class ApiSmokeTests(unittest.TestCase):
payload = response.json()
return {"Authorization": f"Bearer {payload['access_token']}"}, payload
def _create_test_mail_profile(
self,
headers: dict[str, str],
*,
name: str,
include_imap: bool = False,
) -> str:
credentials = {
"smtp": {"username": "sender@example.org", "password": "test-secret"},
}
payload: dict[str, object] = {
"name": name,
"smtp": {
"host": "mock.smtp",
"port": 2525,
"security": "starttls",
},
"credentials": credentials,
}
if include_imap:
payload["imap"] = {
"host": "mock.imap",
"port": 993,
"security": "tls",
"sent_folder": "Sent",
}
credentials["imap"] = {"username": "sender@example.org", "password": "test-secret"}
response = self.client.post("/api/v1/mail/profiles", headers=headers, json=payload)
self.assertEqual(response.status_code, 201, response.text)
return str(response.json()["id"])
def _campaign_version_precondition(
self,
headers: dict[str, str],
campaign_id: str,
version_id: str,
) -> tuple[dict[str, str], int]:
response = self.client.get(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
headers=headers,
)
self.assertEqual(response.status_code, 200, response.text)
etag = response.headers.get("etag")
self.assertIsNotNone(etag)
return (
{**headers, "If-Match": str(etag)},
int(response.json()["edit_revision"]),
)
def test_recipient_import_mapping_profiles_are_db_backed(self) -> None:
headers, _ = self._login()
payload = {
@@ -224,6 +274,7 @@ class ApiSmokeTests(unittest.TestCase):
external_id: str,
recipient_count: int = 1,
) -> tuple[str, str]:
mail_profile_id = self._create_test_mail_profile(headers, name=f"{external_id} delivery")
entries = [
{
"id": f"recipient-{index + 1}",
@@ -237,15 +288,7 @@ class ApiSmokeTests(unittest.TestCase):
"campaign": {"id": external_id, "name": external_id, "mode": "test"},
"fields": [{"name": "first_name", "type": "string", "required": True}],
"global_values": {},
"server": {
"smtp": {
"host": "mock.smtp",
"port": 2525,
"username": "sender@example.org",
"password": "test-secret",
"security": "starttls",
}
},
"server": {"mail_profile_id": mail_profile_id},
"recipients": {
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
"allow_individual_to": True,
@@ -740,10 +783,42 @@ class ApiSmokeTests(unittest.TestCase):
assert version is not None
self.assertEqual(version.raw_json["server"], {"mail_profile_id": profile_id})
snapshot = version.execution_snapshot or {}
self.assertIsNone(snapshot.get("smtp", {}).get("password"))
self.assertEqual(snapshot.get("smtp", {}).get("host"), "mock.smtp")
self.assertEqual(snapshot["snapshot_version"], SNAPSHOT_VERSION)
self.assertEqual(snapshot["mail_profile_id"], profile_id)
self.assertNotIn("smtp", snapshot)
self.assertNotIn("imap", snapshot)
self.assertTrue(snapshot["smtp_transport_revision"])
self.assertTrue(snapshot["imap_transport_revision"])
def test_mail_profile_can_inherit_server_without_credentials(self) -> None:
deleted = self.client.delete(f"/api/v1/mail/profiles/{profile_id}", headers=headers)
self.assertEqual(deleted.status_code, 200, deleted.text)
self.assertFalse(deleted.json()["is_active"])
# Reusable hierarchy credentials outlive a deactivated profile. The
# profile's retired legacy secret columns are still scrubbed below.
self.assertTrue(deleted.json()["smtp_password_configured"])
self.assertTrue(deleted.json()["imap_password_configured"])
from govoplan_audit.backend.db.models import AuditLog
with SessionLocal() as session:
profile = session.get(MailServerProfile, profile_id)
self.assertIsNotNone(profile)
assert profile is not None
self.assertIsNone(profile.smtp_password_encrypted)
self.assertIsNone(profile.imap_password_encrypted)
audit = (
session.query(AuditLog)
.filter(
AuditLog.action == "mail.profile_credentials_deleted",
AuditLog.object_id == profile_id,
)
.one()
)
self.assertEqual(audit.details["deleted_protocols"], ["smtp", "imap"])
self.assertEqual(audit.details["deletion_reason"], "profile_deactivated")
self.assertNotIn("secret", repr(audit.details))
def test_campaign_runtime_does_not_receive_mail_owned_profile_credentials(self) -> None:
headers, _ = self._login()
created_profile = self.client.post(
"/api/v1/mail/profiles",
@@ -775,15 +850,7 @@ class ApiSmokeTests(unittest.TestCase):
"campaign": {"id": "profile-local-creds", "name": "Profile local creds", "mode": "test"},
"fields": [{"name": "first_name", "type": "string", "required": True}],
"global_values": {},
"server": {
"mail_profile_id": profile_id,
"inherit_smtp_credentials": False,
"inherit_imap_credentials": False,
"credentials": {
"smtp": {"username": "campaign-smtp@example.org", "password": "campaign-smtp-secret"},
"imap": {"username": "campaign-imap@example.org", "password": "campaign-imap-secret"},
},
},
"server": {"mail_profile_id": profile_id},
"recipients": {
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
"to": [{"email": "recipient@example.org", "type": "to"}],
@@ -810,31 +877,46 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(built.status_code, 200, built.text)
from govoplan_campaign.backend.db.models import CampaignVersion
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot, runtime_imap_config, runtime_smtp_config
from govoplan_campaign.backend.integrations import mail_integration
from govoplan_mail.backend.db.models import MailServerProfile
with SessionLocal() as session:
version = session.get(CampaignVersion, version_id)
self.assertIsNotNone(version)
assert version is not None
snapshot_payload = version.execution_snapshot or {}
self.assertEqual(snapshot_payload.get("smtp", {}).get("host"), "mock.smtp")
self.assertEqual(snapshot_payload.get("smtp", {}).get("username"), "campaign-smtp@example.org")
self.assertIsNone(snapshot_payload.get("smtp", {}).get("password"))
self.assertEqual(snapshot_payload.get("imap", {}).get("host"), "mock.imap")
self.assertEqual(snapshot_payload.get("imap", {}).get("username"), "campaign-imap@example.org")
self.assertIsNone(snapshot_payload.get("imap", {}).get("password"))
self.assertEqual(
snapshot_payload["snapshot_version"],
SNAPSHOT_VERSION,
)
self.assertEqual(snapshot_payload["mail_profile_id"], profile_id)
self.assertNotIn("smtp", snapshot_payload)
self.assertNotIn("imap", snapshot_payload)
self.assertTrue(snapshot_payload["smtp_transport_revision"])
self.assertTrue(snapshot_payload["imap_transport_revision"])
snapshot = ExecutionSnapshot.model_validate(snapshot_payload)
smtp_runtime = runtime_smtp_config(session, version, snapshot)
imap_runtime = runtime_imap_config(session, version, snapshot)
self.assertEqual(smtp_runtime.host, "mock.smtp")
self.assertEqual(smtp_runtime.username, "campaign-smtp@example.org")
self.assertEqual(smtp_runtime.password, "campaign-smtp-secret")
self.assertIsNotNone(imap_runtime)
assert imap_runtime is not None
self.assertEqual(imap_runtime.host, "mock.imap")
self.assertEqual(imap_runtime.username, "campaign-imap@example.org")
self.assertEqual(imap_runtime.password, "campaign-imap-secret")
profile = session.get(MailServerProfile, profile_id)
self.assertIsNotNone(profile)
assert profile is not None
integration = mail_integration()
summary = integration.campaign_profile_delivery_summary(
session,
tenant_id=profile.tenant_id,
campaign_id=version.campaign_id,
profile_id=profile_id,
)
self.assertTrue(summary["smtp_available"])
self.assertTrue(summary["imap_available"])
self.assertNotIn("mock.smtp", repr(summary))
self.assertNotIn("profile-smtp", repr(summary))
self.assertNotIn("secret", repr(summary))
for name in (
"smtp_config_from_profile",
"imap_config_from_profile",
"send_email_bytes",
"send_email_message",
):
self.assertFalse(hasattr(integration, name), name)
def test_health_schema_and_dev_mailbox_gates(self) -> None:
public_health = self.client.get("/health")
@@ -886,6 +968,33 @@ class ApiSmokeTests(unittest.TestCase):
user_always = {item["id"]: item for item in user_docs_payload["layers"]["always"]["documentation"]}
self.assertEqual(user_always["docs.configured-system-documentation"]["translation_locale"], "de")
sources = self.client.get("/api/v1/docs/sources", headers=headers)
self.assertEqual(sources.status_code, 200, sources.text)
source_payload = sources.json()
self.assertEqual(source_payload["total"], len(source_payload["items"]))
manifest_source = next(
item
for item in source_payload["items"]
if item["id"] == "docs.manifest"
)
self.assertEqual(manifest_source["kind"], "manifest")
self.assertNotIn("inspection", manifest_source)
inspected_source = self.client.get(
manifest_source["inspection_url"],
headers=headers,
)
self.assertEqual(inspected_source.status_code, 200, inspected_source.text)
self.assertEqual(
inspected_source.json()["inspection"]["module_id"],
"docs",
)
unknown_source = self.client.get(
"/api/v1/docs/sources/docs.unknown",
headers=headers,
)
self.assertEqual(unknown_source.status_code, 404, unknown_source.text)
platform_status = self.client.get("/api/v1/platform/status", headers=headers)
self.assertEqual(platform_status.status_code, 200, platform_status.text)
i18n_status = platform_status.json()["i18n"]
@@ -2683,7 +2792,10 @@ class ApiSmokeTests(unittest.TestCase):
archive.writestr("reports/february.txt", "February report")
payload.seek(0)
with patch("govoplan_files.backend.router._read_limited_upload", side_effect=AssertionError("ZIP upload should not be fully buffered")):
with patch(
"govoplan_files.backend.routes.uploads._read_limited_upload",
side_effect=AssertionError("ZIP upload should not be fully buffered"),
):
response = self.client.post(
"/api/v1/files/upload-zip",
headers=headers,
@@ -2826,10 +2938,18 @@ class ApiSmokeTests(unittest.TestCase):
self.assertTrue(full_payload["full"])
self.assertEqual(full_payload["current_version"]["id"], version_id)
mutation_headers, base_revision = self._campaign_version_precondition(
headers,
campaign_id,
version_id,
)
autosaved = self.client.post(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/autosave",
headers=headers,
json={"current_step": "fields"},
headers=mutation_headers,
json={
"current_step": "fields",
"base_revision": base_revision,
},
)
self.assertEqual(autosaved.status_code, 200, autosaved.text)
self.assertEqual(autosaved.json()["current_step"], "fields")
@@ -2885,7 +3005,10 @@ class ApiSmokeTests(unittest.TestCase):
self.assertFalse(delta_payload["full"])
self.assertEqual([item["id"] for item in delta_payload["jobs"]], [job_id])
self.assertEqual(delta_payload["jobs"][0]["send_status"], "outcome_unknown")
self.assertEqual(delta_payload["jobs"][0]["last_error"], "SMTP outcome needs reconciliation")
self.assertEqual(
delta_payload["jobs"][0]["last_error"],
"Delivery outcome requires operator reconciliation.",
)
self.assertEqual(delta_payload["counts"]["send"]["outcome_unknown"], 1)
self.assertTrue(str(delta_payload["watermark"]).startswith("seq:"))
@@ -2971,10 +3094,18 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(temporary.json()["user_lock_state"], "temporary")
self.assertTrue(temporary.json()["user_locked_at"])
mutation_headers, base_revision = self._campaign_version_precondition(
headers,
campaign_id,
version_id,
)
blocked_update = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
headers=headers,
json={"current_step": "fields"},
headers=mutation_headers,
json={
"current_step": "fields",
"base_revision": base_revision,
},
)
self.assertEqual(blocked_update.status_code, 409, blocked_update.text)
@@ -2985,10 +3116,18 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(unlocked.status_code, 200, unlocked.text)
self.assertIsNone(unlocked.json()["user_lock_state"])
mutation_headers, base_revision = self._campaign_version_precondition(
headers,
campaign_id,
version_id,
)
updated = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
headers=headers,
json={"current_step": "fields"},
headers=mutation_headers,
json={
"current_step": "fields",
"base_revision": base_revision,
},
)
self.assertEqual(updated.status_code, 200, updated.text)
self.assertEqual(updated.json()["current_step"], "fields")
@@ -3049,18 +3188,34 @@ class ApiSmokeTests(unittest.TestCase):
self.assertNotEqual(second_version_id, first_version_id)
self.assertEqual(copied.json()["campaign"]["current_version_id"], second_version_id)
mutation_headers, base_revision = self._campaign_version_precondition(
headers,
campaign_id,
first_version_id,
)
historical_update = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}",
headers=headers,
json={"current_step": "fields"},
headers=mutation_headers,
json={
"current_step": "fields",
"base_revision": base_revision,
},
)
self.assertEqual(historical_update.status_code, 409, historical_update.text)
self.assertIn("Historical campaign versions are read-only", historical_update.text)
mutation_headers, base_revision = self._campaign_version_precondition(
headers,
campaign_id,
second_version_id,
)
second_update = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{second_version_id}",
headers=headers,
json={"current_step": "fields"},
headers=mutation_headers,
json={
"current_step": "fields",
"base_revision": base_revision,
},
)
self.assertEqual(second_update.status_code, 200, second_update.text)
@@ -3095,28 +3250,13 @@ class ApiSmokeTests(unittest.TestCase):
def test_campaign_create_validate_build_and_mock_send(self) -> None:
headers, _ = self._login()
mail_profile_id = self._create_test_mail_profile(headers, name="API smoke delivery", include_imap=True)
campaign_json = {
"version": "1.0",
"campaign": {"id": "api-smoke", "name": "API smoke campaign", "mode": "test"},
"fields": [{"name": "first_name", "type": "string", "required": True}],
"global_values": {},
"server": {
"smtp": {
"host": "smtp.example.invalid",
"port": 587,
"username": "sender@example.org",
"password": "test-secret",
"security": "starttls",
},
"imap": {
"enabled": True,
"host": "imap.example.invalid",
"port": 993,
"username": "sender@example.org",
"password": "test-secret",
"security": "tls",
},
},
"server": {"mail_profile_id": mail_profile_id},
"recipients": {
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
"allow_individual_to": True,
@@ -3188,20 +3328,13 @@ class ApiSmokeTests(unittest.TestCase):
def test_managed_attachment_patterns_preview_build_and_mock_send(self) -> None:
headers, login = self._login()
user_id = login["user"]["id"]
mail_profile_id = self._create_test_mail_profile(headers, name="Managed attachments delivery")
campaign_json = {
"version": "1.0",
"campaign": {"id": "managed-attachments", "name": "Managed attachments", "mode": "test"},
"fields": [{"name": "invoice_number", "type": "string", "required": True}],
"global_values": {},
"server": {
"smtp": {
"host": "smtp.example.invalid",
"port": 587,
"username": "sender@example.org",
"password": "test-secret",
"security": "starttls",
}
},
"server": {"mail_profile_id": mail_profile_id},
"recipients": {
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
"allow_individual_to": True,
@@ -3401,20 +3534,13 @@ class ApiSmokeTests(unittest.TestCase):
def test_managed_attachment_unlinked_candidates_can_be_linked_on_validation(self) -> None:
headers, login = self._login()
user_id = login["user"]["id"]
mail_profile_id = self._create_test_mail_profile(headers, name="Unlinked attachments delivery")
campaign_json = {
"version": "1.0",
"campaign": {"id": "managed-unlinked-attachments", "name": "Managed unlinked attachments", "mode": "test"},
"fields": [{"name": "invoice_number", "type": "string", "required": True}],
"global_values": {},
"server": {
"smtp": {
"host": "smtp.example.invalid",
"port": 587,
"username": "sender@example.org",
"password": "test-secret",
"security": "starttls",
}
},
"server": {"mail_profile_id": mail_profile_id},
"recipients": {
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
"allow_individual_to": True,
@@ -3522,20 +3648,13 @@ class ApiSmokeTests(unittest.TestCase):
def test_managed_attachment_send_uses_frozen_build_artifact_after_file_changes(self) -> None:
headers, login = self._login()
user_id = login["user"]["id"]
mail_profile_id = self._create_test_mail_profile(headers, name="Frozen attachment delivery")
campaign_json = {
"version": "1.0",
"campaign": {"id": "managed-send-freeze", "name": "Managed send freeze", "mode": "test"},
"fields": [],
"global_values": {},
"server": {
"smtp": {
"host": "mock.smtp",
"port": 2525,
"username": "sender@example.org",
"password": "test-secret",
"security": "starttls",
}
},
"server": {"mail_profile_id": mail_profile_id},
"recipients": {
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
"allow_individual_to": True,
@@ -3689,11 +3808,12 @@ class ApiSmokeTests(unittest.TestCase):
def test_non_blocking_review_conditions_can_be_accepted_in_bulk(self) -> None:
headers, login = self._login()
user_id = login["user"]["id"]
mail_profile_id = self._create_test_mail_profile(headers, name="Bulk review delivery")
campaign_json = {
"version": "1.0",
"campaign": {"id": "bulk-review", "name": "Bulk review", "mode": "test"},
"fields": [], "global_values": {},
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
"server": {"mail_profile_id": mail_profile_id},
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
"template": {"subject": "Warning test", "text": "Body"},
"attachments": {
@@ -3741,12 +3861,13 @@ class ApiSmokeTests(unittest.TestCase):
def test_inactive_recipients_are_aggregated_but_not_built_or_reviewed(self) -> None:
headers, login = self._login()
user_id = login["user"]["id"]
mail_profile_id = self._create_test_mail_profile(headers, name="Inactive recipients delivery")
campaign_json = {
"version": "1.0",
"campaign": {"id": "inactive-recipients", "name": "Inactive recipients", "mode": "test"},
"fields": [],
"global_values": {},
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
"server": {"mail_profile_id": mail_profile_id},
"recipients": {"from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, "allow_individual_to": True},
"template": {"subject": "Hello", "text": "Active recipient only"},
"attachments": {
@@ -3801,12 +3922,13 @@ class ApiSmokeTests(unittest.TestCase):
def test_recipient_address_fields_and_merge_modes(self) -> None:
headers, _ = self._login()
mail_profile_id = self._create_test_mail_profile(headers, name="Recipient merge delivery")
campaign_json = {
"version": "1.0",
"campaign": {"id": "recipient-address-fields", "name": "Recipient address fields", "mode": "test"},
"fields": [],
"global_values": {},
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
"server": {"mail_profile_id": mail_profile_id},
"recipients": {
"from": [{"email": "global-from@example.org", "type": "to"}],
"allow_individual_from": True,
@@ -3867,12 +3989,13 @@ class ApiSmokeTests(unittest.TestCase):
def test_multiple_from_addresses_are_rejected(self) -> None:
headers, _ = self._login()
mail_profile_id = self._create_test_mail_profile(headers, name="Multiple sender validation delivery")
campaign_json = {
"version": "1.0",
"campaign": {"id": "multiple-from", "name": "Multiple From", "mode": "test"},
"fields": [],
"global_values": {},
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
"server": {"mail_profile_id": mail_profile_id},
"recipients": {
"from": [
{"email": "first@example.org", "type": "to"},
@@ -3892,12 +4015,13 @@ class ApiSmokeTests(unittest.TestCase):
def test_duplicate_zip_archive_filenames_block_validation(self) -> None:
headers, _ = self._login()
mail_profile_id = self._create_test_mail_profile(headers, name="Duplicate ZIP validation delivery")
campaign_json = {
"version": "1.0",
"campaign": {"id": "duplicate-zip-names", "name": "Duplicate ZIP names", "mode": "test"},
"fields": [],
"global_values": {},
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
"server": {"mail_profile_id": mail_profile_id},
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
"template": {"subject": "ZIP validation", "text": "Body"},
"attachments": {
@@ -3937,6 +4061,7 @@ class ApiSmokeTests(unittest.TestCase):
def test_recipient_zip_archive_with_field_password_and_rule_exclusions(self) -> None:
headers, login = self._login()
user_id = login["user"]["id"]
mail_profile_id = self._create_test_mail_profile(headers, name="ZIP attachment delivery")
campaign_json = {
"version": "1.0",
"campaign": {"id": "zip-attachments", "name": "ZIP attachments", "mode": "test"},
@@ -3946,7 +4071,7 @@ class ApiSmokeTests(unittest.TestCase):
{"name": "global_zip_password", "type": "password", "required": True},
],
"global_values": {"global_zip_password": "campaign-secret"},
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
"server": {"mail_profile_id": mail_profile_id},
"recipients": {
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
"allow_individual_to": True,
@@ -4133,14 +4258,15 @@ class ApiSmokeTests(unittest.TestCase):
def test_execution_snapshot_freezes_delivery_configuration_and_job_manifest(self) -> None:
def test_execution_snapshot_detects_profile_drift_and_freezes_job_manifest(self) -> None:
headers, _ = self._login()
campaign_id, version_id = self._create_built_delivery_campaign(
headers,
external_id="snapshot-freeze",
)
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, SendAttempt
from govoplan_mail.backend.db.models import MailServerEndpoint, MailServerProfile
with SessionLocal() as session:
version = session.get(CampaignVersion, version_id)
@@ -4148,23 +4274,32 @@ class ApiSmokeTests(unittest.TestCase):
assert version is not None
snapshot = version.execution_snapshot
self.assertIsInstance(snapshot, dict)
self.assertEqual(snapshot["snapshot_version"], "3")
self.assertEqual(snapshot["snapshot_version"], SNAPSHOT_VERSION)
self.assertEqual(snapshot["job_count"], 1)
self.assertEqual(snapshot["queueable_job_count"], 1)
self.assertTrue(snapshot["job_manifest_sha256"])
self.assertTrue(snapshot["smtp_config_fingerprint"])
self.assertIsNone(snapshot["smtp"].get("password"))
self.assertTrue(snapshot["smtp_transport_revision"])
self.assertNotIn("smtp", snapshot)
self.assertNotIn("imap", snapshot)
self.assertTrue(version.execution_snapshot_hash)
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
self.assertTrue(job.eml_sha256)
self.assertTrue(job.message_id_header)
# Simulate accidental/config-drift mutation after the build. Sending
# must still use the immutable mock SMTP snapshot, not raw_json.
mutated = json.loads(json.dumps(version.raw_json))
mutated["server"]["smtp"]["host"] = "smtp.example.invalid"
version.raw_json = mutated
session.add(version)
profile = session.get(MailServerProfile, snapshot["mail_profile_id"])
self.assertIsNotNone(profile)
assert profile is not None
smtp_server_id = snapshot.get("smtp_server_id")
self.assertTrue(smtp_server_id)
smtp_server = session.get(MailServerEndpoint, smtp_server_id)
self.assertIsNotNone(smtp_server)
assert smtp_server is not None
smtp_server.config = {
**smtp_server.config,
"host": "smtp.example.invalid",
}
smtp_server.transport_revision = "manually-rotated-transport-revision"
session.add(smtp_server)
session.commit()
sent = self.client.post(
@@ -4178,13 +4313,17 @@ class ApiSmokeTests(unittest.TestCase):
"enqueue_imap_task": False,
},
)
self.assertEqual(sent.status_code, 200, sent.text)
self.assertEqual(sent.json()["result"]["sent_count"], 1, sent.text)
self.assertEqual(sent.json()["result"]["outcome_unknown_count"], 0, sent.text)
self.assertEqual(sent.status_code, 422, sent.text)
self.assertIn("Synchronous preflight stopped before contacting SMTP", sent.json()["detail"])
with SessionLocal() as session:
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
self.assertEqual(job.send_status, "smtp_accepted")
self.assertEqual(job.queue_status, "draft")
self.assertEqual(job.send_status, "not_queued")
self.assertEqual(
session.query(SendAttempt).filter(SendAttempt.job_id == job.id).count(),
0,
)
def test_send_now_sends_exact_generated_eml_bytes(self) -> None:
headers, _ = self._login()
@@ -4248,29 +4387,26 @@ class ApiSmokeTests(unittest.TestCase):
"enqueue_imap_task": False,
},
)
self.assertEqual(sent.status_code, 200, sent.text)
self.assertEqual(sent.json()["result"]["failed_count"], 1)
self.assertIn("Generated EML", sent.json()["result"]["results"][0]["message"])
self.assertEqual(sent.status_code, 422, sent.text)
self.assertIn("Synchronous preflight stopped before contacting SMTP", sent.json()["detail"])
self.assertEqual(list_records(kind="smtp"), [])
with SessionLocal() as session:
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
self.assertEqual(job.queue_status, "draft")
self.assertEqual(job.send_status, "not_queued")
def test_partial_smtp_recipient_refusal_is_recorded_without_retrying_accepted_delivery(self) -> None:
headers, _ = self._login()
from govoplan_mail.backend.dev.mock_mailbox import set_failures
mail_profile_id = self._create_test_mail_profile(headers, name="Partial refusal delivery")
campaign_json = {
"version": "1.0",
"campaign": {"id": "partial-refusal", "name": "Partial refusal", "mode": "test"},
"fields": [],
"global_values": {},
"server": {
"smtp": {
"host": "mock.smtp",
"port": 2525,
"username": "sender@example.org",
"password": "test-secret",
"security": "starttls",
}
},
"server": {"mail_profile_id": mail_profile_id},
"recipients": {
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
"allow_individual_to": True,
@@ -4318,7 +4454,8 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(sent.status_code, 200, sent.text)
result = sent.json()["result"]
self.assertEqual(result["sent_count"], 1, sent.text)
self.assertIn("refused recipients", result["results"][0]["message"])
self.assertEqual(result["results"][0]["status"], "smtp_accepted")
self.assertNotIn("message", result["results"][0])
finally:
set_failures(smtp_reject_recipients_containing=None)
@@ -4564,8 +4701,14 @@ class ApiSmokeTests(unittest.TestCase):
)
updated = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{second_version_id}",
headers=headers,
json={"campaign_json": campaign_json},
headers={
**headers,
"If-Match": str(version_response.headers["etag"]),
},
json={
"campaign_json": campaign_json,
"base_revision": version_response.json()["edit_revision"],
},
)
self.assertEqual(updated.status_code, 200, updated.text)
validated = self.client.post(
@@ -5761,6 +5904,7 @@ class ApiSmokeTests(unittest.TestCase):
def test_campaign_acl_separates_capability_from_object_access(self) -> None:
owner_headers, _ = self._login()
mail_profile_id = self._create_test_mail_profile(owner_headers, name="ACL campaign delivery")
access_role = self._create_role(
owner_headers,
slug="campaign-collaborator",
@@ -5786,7 +5930,7 @@ class ApiSmokeTests(unittest.TestCase):
"campaign": {"id": "acl-campaign", "name": "ACL campaign", "mode": "test"},
"fields": [],
"global_values": {},
"server": {"smtp": {"host": "mock.smtp", "port": 2525, "security": "starttls"}},
"server": {"mail_profile_id": mail_profile_id},
"recipients": {"from": {"email": "sender@example.org", "type": "to"}},
"template": {"subject": "ACL", "text": "ACL"},
"attachments": {"base_path": ".", "global": [], "allow_individual": False},
@@ -5833,10 +5977,18 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(allowed_write.status_code, 200, allowed_write.text)
changed_config = version_detail.json()["raw_json"]
changed_config["entries"]["inline"][0]["to"][0]["email"] = "changed@example.org"
mutation_headers, base_revision = self._campaign_version_precondition(
reader_headers,
campaign_id,
version_id,
)
denied_recipient_edit = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
headers=reader_headers,
json={"campaign_json": changed_config},
headers=mutation_headers,
json={
"campaign_json": changed_config,
"base_revision": base_revision,
},
)
self.assertEqual(denied_recipient_edit.status_code, 403, denied_recipient_edit.text)
self.assertEqual(self.client.get(f"/api/v1/campaigns/{campaign_id}", headers=other_headers).status_code, 403)
@@ -5980,18 +6132,29 @@ class ApiSmokeTests(unittest.TestCase):
}
blocked_inline = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
headers=headers,
json={"campaign_json": inline_json},
headers={**headers, "If-Match": str(detail.headers["etag"])},
json={
"campaign_json": inline_json,
"base_revision": detail.json()["edit_revision"],
},
)
self.assertEqual(blocked_inline.status_code, 422, blocked_inline.text)
self.assertIn("Campaign-local inline mail settings", blocked_inline.json()["detail"])
self.assertIn("may only reference", blocked_inline.json()["detail"])
reusable_json = detail.json()["raw_json"]
reusable_json["server"] = {"mail_profile_id": tenant_profile_id}
mutation_headers, base_revision = self._campaign_version_precondition(
headers,
campaign_id,
version_id,
)
allowed_reusable = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
headers=headers,
json={"campaign_json": reusable_json},
headers=mutation_headers,
json={
"campaign_json": reusable_json,
"base_revision": base_revision,
},
)
self.assertEqual(allowed_reusable.status_code, 200, allowed_reusable.text)
@@ -6030,8 +6193,11 @@ class ApiSmokeTests(unittest.TestCase):
raw_json["server"] = {"mail_profile_id": profile_id}
denied_update = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
headers=headers,
json={"campaign_json": raw_json},
headers={**headers, "If-Match": str(detail.headers["etag"])},
json={
"campaign_json": raw_json,
"base_revision": detail.json()["edit_revision"],
},
)
self.assertEqual(denied_update.status_code, 422, denied_update.text)
@@ -6041,10 +6207,18 @@ class ApiSmokeTests(unittest.TestCase):
json={"policy": {"allowed_profile_ids": [profile_id]}},
)
self.assertEqual(allowed_policy.status_code, 200, allowed_policy.text)
mutation_headers, base_revision = self._campaign_version_precondition(
headers,
campaign_id,
version_id,
)
allowed_update = self.client.put(
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
headers=headers,
json={"campaign_json": raw_json},
headers=mutation_headers,
json={
"campaign_json": raw_json,
"base_revision": base_revision,
},
)
self.assertEqual(allowed_update.status_code, 200, allowed_update.text)
@@ -6105,7 +6279,7 @@ class ApiSmokeTests(unittest.TestCase):
}
blocked = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
self.assertEqual(blocked.status_code, 422, blocked.text)
self.assertIn("locked", blocked.json()["detail"])
self.assertIn("may only reference", blocked.json()["detail"])
campaign_json["server"] = {"mail_profile_id": profile_id}
allowed = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
+240
View File
@@ -0,0 +1,240 @@
from __future__ import annotations
import unittest
from govoplan_core.core.automation import (
ActionDefinition,
ActionEffectProvider,
ActionExecutionRequest,
ActionExecutionResult,
ActionPreview,
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
AutomationInvocation,
AutomationPrincipalProvider,
AutomationPrincipalRequest,
AutomationPrincipalResolution,
EffectDefinition,
EffectPreview,
ObservedEffect,
action_effect_provider,
automation_principal_provider,
)
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
class _Provider:
def resolve_automation_principal(self, session, *, request):
del session
return AutomationPrincipalResolution(
allowed=True,
principal={"account_id": request.account_id},
granted_scopes=request.grant_scopes,
)
class _ActionProvider:
action = ActionDefinition(
action_key="postbox.message.deliver",
owner_module="postbox",
description="Deliver one governed Postbox message.",
input_schema_ref="schema:postbox.message.deliver@1",
required_scopes=("postbox:message:write",),
required_capabilities=("postbox.delivery",),
risk_level="high",
reversibility="compensatable",
expected_effect_keys=("postbox.message.created",),
audit_event_types=("postbox.message.delivered",),
)
effect = EffectDefinition(
effect_key="postbox.message.created",
owner_module="postbox",
operation="created",
description="A durable Postbox message was created.",
resource_types=("postbox_message",),
)
def action_definitions(self):
return (self.action,)
def effect_definitions(self):
return (self.effect,)
def preview_action(self, session, principal, *, request):
del session, principal
return ActionPreview(
action_key=request.action_key,
allowed=True,
summary="One Postbox message will be created.",
risk_level=self.action.risk_level,
reversibility=self.action.reversibility,
effects=(
EffectPreview(
effect_key=self.effect.effect_key,
summary="Create message.",
),
),
preview_ref="preview:1",
)
def execute_action(self, session, principal, *, request):
del session, principal
return ActionExecutionResult(
state="completed",
output={"message_ref": "postbox-message:1"},
observed_effects=(
ObservedEffect(
effect_key=self.effect.effect_key,
operation="created",
resource_ref="postbox-message:1",
),
),
audit_event_refs=("audit-event:1",),
)
class AutomationContractTests(unittest.TestCase):
def test_principal_request_has_explicit_subject_contracts(self) -> None:
service = AutomationPrincipalRequest.service_account(
tenant_id="tenant-1",
service_account_id="service-1",
authorization_ref="trigger:1",
grant_scopes=("dataflow:pipeline:run",),
)
self.assertEqual("service_account", service.subject_kind)
self.assertEqual("service-1", service.service_account_id)
self.assertIsNone(service.account_id)
with self.assertRaisesRegex(
ValueError,
"Delegated-user automation",
):
AutomationPrincipalRequest(
tenant_id="tenant-1",
authorization_ref="trigger:1",
grant_scopes=("dataflow:pipeline:run",),
)
def test_invocation_records_stable_trigger_provenance(self) -> None:
invocation = AutomationInvocation(
kind="event",
trigger_ref="dataflow-trigger:1",
event_id="event-1",
event_type="files.uploaded",
)
self.assertEqual("event", invocation.kind)
self.assertEqual("event-1", invocation.event_id)
def test_principal_provider_is_runtime_resolved(self) -> None:
provider = _Provider()
self.assertIsInstance(provider, AutomationPrincipalProvider)
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="automation_contract_test",
name="Automation contract test",
version="test",
capability_factories={
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER: (
lambda context: provider
),
},
)
)
registry.configure_capability_context(
ModuleContext(registry=registry, settings=object())
)
self.assertIs(provider, automation_principal_provider(registry))
result = provider.resolve_automation_principal(
object(),
request=AutomationPrincipalRequest(
tenant_id="tenant-1",
account_id="account-1",
membership_id="membership-1",
authorization_ref="trigger:1",
grant_scopes=("dataflow:pipeline:run",),
),
)
self.assertTrue(result.allowed)
self.assertEqual(("dataflow:pipeline:run",), result.granted_scopes)
def test_action_effect_provider_is_previewable_and_idempotent_by_contract(
self,
) -> None:
provider = _ActionProvider()
self.assertIsInstance(provider, ActionEffectProvider)
request = ActionExecutionRequest(
tenant_id="tenant-1",
action_key=provider.action.action_key,
input={"postbox_ref": "postbox:1"},
idempotency_key="workflow:instance-1:step-2:attempt-1",
invocation=AutomationInvocation(
kind="workflow",
trigger_ref="workflow-instance:1",
),
)
preview = provider.preview_action(object(), object(), request=request)
result = provider.execute_action(object(), object(), request=request)
self.assertTrue(preview.allowed)
self.assertEqual("compensatable", preview.reversibility)
self.assertEqual("completed", result.state)
self.assertEqual(
"postbox-message:1",
result.observed_effects[0].resource_ref,
)
def test_action_effect_provider_is_resolved_by_capability_name(self) -> None:
provider = _ActionProvider()
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="action_contract_test",
name="Action contract test",
version="test",
capability_factories={
"postbox.actions": lambda context: provider,
"invalid.actions": lambda context: object(),
},
)
)
registry.configure_capability_context(
ModuleContext(registry=registry, settings=object())
)
self.assertIs(
provider,
action_effect_provider(registry, "postbox.actions"),
)
self.assertIsNone(
action_effect_provider(registry, "invalid.actions")
)
self.assertIsNone(
action_effect_provider(registry, "missing.actions")
)
def test_action_contract_rejects_unversioned_or_incomplete_definitions(
self,
) -> None:
with self.assertRaisesRegex(ValueError, "input schema"):
ActionDefinition(
action_key="invalid",
owner_module="test",
description="Invalid action",
input_schema_ref="",
)
with self.assertRaisesRegex(ValueError, "contract version"):
EffectDefinition(
effect_key="test.effect",
owner_module="test",
operation="changed",
description="Test effect",
contract_version="2",
)
if __name__ == "__main__":
unittest.main()
+47
View File
@@ -19,10 +19,25 @@ from govoplan_core.core.change_sequence import (
sequence_watermark_is_expired,
)
from govoplan_core.core.events import EventActorRef, EventBus, EventObjectRef, EventTenantRef, PlatformEvent, event_bus_context
from govoplan_core.core.runtime import (
clear_runtime,
configure_runtime,
get_runtime_context,
)
from govoplan_core.db.base import Base
class ChangeSequenceTests(unittest.TestCase):
def setUp(self) -> None:
self._runtime_context = get_runtime_context()
clear_runtime()
def tearDown(self) -> None:
if self._runtime_context is not None:
configure_runtime(self._runtime_context)
else:
clear_runtime()
def test_records_changes_and_returns_entries_after_watermark(self) -> None:
engine = create_engine("sqlite:///:memory:")
SessionLocal = sessionmaker(bind=engine)
@@ -93,6 +108,8 @@ class ChangeSequenceTests(unittest.TestCase):
actor_id="user-1",
payload={"scope_type": "tenant"},
)
self.assertEqual([], seen)
session.commit()
self.assertEqual([case[-1] for case in cases], [event.type for event in seen])
event = seen[1]
@@ -105,6 +122,36 @@ class ChangeSequenceTests(unittest.TestCase):
finally:
engine.dispose()
def test_record_change_discards_event_when_transaction_rolls_back(self) -> None:
engine = create_engine("sqlite:///:memory:")
SessionLocal = sessionmaker(bind=engine)
try:
Base.metadata.create_all(
bind=engine,
tables=[
ChangeSequenceEntry.__table__,
ChangeSequenceRetentionFloor.__table__,
],
)
seen: list[PlatformEvent] = []
bus = EventBus()
bus.subscribe("*", seen.append)
with SessionLocal() as session, event_bus_context(bus):
record_change(
session,
tenant_id="tenant-1",
module_id="files",
collection="files.assets",
resource_type="file",
resource_id="file-1",
operation="created",
)
session.rollback()
self.assertEqual([], seen)
finally:
engine.dispose()
def test_pruning_records_retention_floor_for_stale_watermarks(self) -> None:
engine = create_engine("sqlite:///:memory:")
SessionLocal = sessionmaker(bind=engine)
+7 -1
View File
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Response
from fastapi.responses import PlainTextResponse
from fastapi.testclient import TestClient
from govoplan_core.auth import get_api_principal
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.server.fastapi import create_govoplan_app
from govoplan_core.server.platform import create_platform_router
@@ -85,9 +86,14 @@ class ConditionalRequestTests(unittest.TestCase):
registry=PlatformRegistry(),
api_router=api_router,
)
app.dependency_overrides[get_api_principal] = lambda: object()
with TestClient(app) as client:
for path in ("/api/v1/platform/status", "/api/v1/platform/modules"):
for path in (
"/api/v1/platform/status",
"/api/v1/platform/modules",
"/api/v1/platform/public-modules",
):
first = client.get(path)
self.assertEqual(200, first.status_code, first.text)
etag = first.headers.get("etag")
+130 -1
View File
@@ -8,15 +8,19 @@ from unittest.mock import patch
from fastapi import APIRouter, Request
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_event, audit_operation_context
from govoplan_core.core.events import (
DurableEventConsumer,
EventActorRef,
EventBus,
EventObjectRef,
EventTenantRef,
PlatformEvent,
current_event_trace,
emit_platform_event,
event_bus_context,
event_context,
normalize_trace_id,
@@ -31,13 +35,96 @@ from tests.db_isolation import temporary_database
def _configure_audit_runtime() -> None:
registry = build_platform_registry(("audit",))
registry = build_platform_registry(("access", "audit"))
context = ModuleContext(registry=registry, settings=object())
registry.configure_capability_context(context)
configure_runtime(context)
class CoreEventTests(unittest.TestCase):
def test_durable_consumer_requires_policy_for_classified_events(self) -> None:
with self.assertRaisesRegex(ValueError, "policy decision"):
DurableEventConsumer(
consumer_id="workflow.triggers.v1",
classifications=frozenset({"restricted"}),
handler=lambda _event, _delivery_key: None,
)
consumer = DurableEventConsumer(
consumer_id="workflow.triggers.v1",
event_types=frozenset({"case.changed"}),
classifications=frozenset(
{"internal", "confidential"}
),
policy_decision_ref="policy:decision:1",
handler=lambda _event, _delivery_key: None,
)
accepted = PlatformEvent(
type="case.changed",
module_id="cases",
classification="confidential",
event_id="event-1",
)
rejected = PlatformEvent(
type="case.deleted",
module_id="cases",
classification="confidential",
)
self.assertTrue(consumer.accepts(accepted))
self.assertFalse(consumer.accepts(rejected))
self.assertEqual(
"event-1:workflow.triggers.v1",
consumer.delivery_key(accepted),
)
def test_fallback_event_waits_for_outer_commit_after_savepoint(self) -> None:
engine = create_engine("sqlite:///:memory:")
seen: list[PlatformEvent] = []
bus = EventBus()
bus.subscribe("*", seen.append)
try:
with Session(engine) as session, event_bus_context(bus):
with session.begin():
with session.begin_nested():
emit_platform_event(
session,
PlatformEvent(type="nested.created", module_id="core"),
)
self.assertEqual([], seen)
self.assertEqual(
["nested.created"],
[event.type for event in seen],
)
finally:
engine.dispose()
def test_fallback_event_discards_only_rolled_back_savepoint(self) -> None:
engine = create_engine("sqlite:///:memory:")
seen: list[PlatformEvent] = []
bus = EventBus()
bus.subscribe("*", seen.append)
try:
with Session(engine) as session, event_bus_context(bus):
with session.begin():
emit_platform_event(
session,
PlatformEvent(type="outer.created", module_id="core"),
)
savepoint = session.begin_nested()
emit_platform_event(
session,
PlatformEvent(type="nested.created", module_id="core"),
)
savepoint.rollback()
self.assertEqual([], seen)
self.assertEqual(
["outer.created"],
[event.type for event in seen],
)
finally:
engine.dispose()
def test_event_bus_adds_trace_ids_and_propagates_causation_to_nested_events(self) -> None:
bus = EventBus()
seen: list[PlatformEvent] = []
@@ -163,6 +250,38 @@ class CoreEventTests(unittest.TestCase):
self.assertIn("frame-ancestors 'none'", response.headers["Content-Security-Policy"])
self.assertEqual("max-age=86400", response.headers["Strict-Transport-Security"])
def test_slow_request_log_excludes_query_headers_and_cookies(self) -> None:
router = APIRouter()
@router.get("/slow")
def slow() -> dict[str, bool]:
return {"ok": True}
with patch("govoplan_core.server.fastapi._slow_request_threshold_ms", return_value=0.0001):
app = create_govoplan_app(
title="request log redaction test",
version="test",
registry=PlatformRegistry(),
api_router=router,
)
with self.assertLogs("govoplan.request", level="WARNING") as captured:
with TestClient(app) as client:
response = client.get(
"/slow?token=query-secret",
headers={
"Authorization": "Bearer header-secret",
"Cookie": "govoplan_session=cookie-secret",
},
)
self.assertEqual(200, response.status_code, response.text)
rendered = "\n".join(captured.output)
self.assertIn("path=/slow", rendered)
self.assertNotIn("query-secret", rendered)
self.assertNotIn("header-secret", rendered)
self.assertNotIn("cookie-secret", rendered)
def test_production_app_startup_rejects_an_unvalidated_environment(self) -> None:
with patch.dict("os.environ", {"APP_ENV": "production"}, clear=True), self.assertRaisesRegex(
RuntimeError,
@@ -267,6 +386,11 @@ class CoreEventTests(unittest.TestCase):
object_id="user-2",
details={"password": "secret", "field": "display_name"},
)
session.commit()
from govoplan_audit.backend.outbox import SqlAuditOutbox
SqlAuditOutbox().dispatch_pending(session)
session.commit()
action_events = [event for event in seen if event.type == "user.updated"]
self.assertEqual(1, len(action_events))
@@ -315,6 +439,11 @@ class CoreEventTests(unittest.TestCase):
object_type="demo",
object_id=action,
)
session.commit()
from govoplan_audit.backend.outbox import SqlAuditOutbox
SqlAuditOutbox().dispatch_pending(session)
session.commit()
by_type = {event.type: event.module_id for event in seen if event.type in cases}
self.assertEqual(cases, by_type)
+245
View File
@@ -0,0 +1,245 @@
from __future__ import annotations
import json
import unittest
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.security.credential_envelopes import (
CredentialAccessContext,
CredentialEnvelope,
CredentialEnvelopeError,
create_credential_envelope,
credential_envelope_summary,
credential_visible_to_context,
list_managed_credential_envelopes,
resolve_credential_envelope,
update_credential_envelope,
)
from govoplan_core.security.secrets import encrypt_secret
from govoplan_core.server.credentials import _target_scope
def _principal(scopes: set[str]) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id="account-1",
membership_id="user-1",
tenant_id="tenant-1",
scopes=frozenset(scopes),
),
account=object(),
user=object(),
)
class CredentialEnvelopeTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
ChangeSequenceEntry.__table__.create(self.engine)
CredentialEnvelope.__table__.create(self.engine)
def tearDown(self) -> None:
self.engine.dispose()
def test_inherited_credential_is_filtered_by_module_and_server(self) -> None:
row = CredentialEnvelope(
id="credential-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Shared account",
credential_kind="username_password",
public_data={"username": "service@example.org"},
secret_data_encrypted=encrypt_secret(json.dumps({"password": "not-returned"})),
secret_keys=["password"],
allowed_modules=["mail"],
allowed_server_refs=["mail:server-1"],
inherit_to_lower_scopes=True,
is_active=True,
revision="revision-1",
)
allowed = CredentialAccessContext(
tenant_id="tenant-1",
user_id="user-1",
target_scope_type="user",
target_scope_id="user-1",
module_id="mail",
server_ref="mail:server-1",
)
wrong_module = CredentialAccessContext(
tenant_id="tenant-1",
user_id="user-1",
target_scope_type="user",
target_scope_id="user-1",
module_id="calendar",
server_ref="mail:server-1",
)
wrong_server = CredentialAccessContext(
tenant_id="tenant-1",
user_id="user-1",
target_scope_type="user",
target_scope_id="user-1",
module_id="mail",
server_ref="mail:server-2",
)
self.assertTrue(credential_visible_to_context(row, allowed))
self.assertFalse(credential_visible_to_context(row, wrong_module))
self.assertFalse(credential_visible_to_context(row, wrong_server))
self.assertNotIn("secret_data_encrypted", credential_envelope_summary(row))
def test_resolution_returns_secret_only_after_access_check(self) -> None:
with Session(self.engine) as session:
session.add(
CredentialEnvelope(
id="credential-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Shared account",
credential_kind="username_password",
public_data={"username": "service@example.org"},
secret_data_encrypted=encrypt_secret(json.dumps({"password": "secret"})),
secret_keys=["password"],
allowed_modules=["mail"],
allowed_server_refs=[],
inherit_to_lower_scopes=True,
is_active=True,
revision="revision-1",
)
)
session.commit()
resolved = resolve_credential_envelope(
session,
credential_id="credential-1",
context=CredentialAccessContext(
tenant_id="tenant-1",
user_id="user-1",
target_scope_type="user",
target_scope_id="user-1",
module_id="mail",
),
)
self.assertEqual(resolved.public_data["username"], "service@example.org")
self.assertEqual(resolved.secret_data["password"], "secret")
with self.assertRaises(CredentialEnvelopeError):
resolve_credential_envelope(
session,
credential_id="credential-1",
context=CredentialAccessContext(
tenant_id="tenant-2",
user_id="user-2",
target_scope_type="user",
target_scope_id="user-2",
module_id="mail",
),
)
def test_management_listing_stays_in_tenant_but_ignores_use_site_limits(self) -> None:
with Session(self.engine) as session:
session.add_all(
[
CredentialEnvelope(
id="tenant-credential",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Tenant restricted",
credential_kind="username_password",
public_data={},
secret_keys=[],
allowed_modules=["calendar"],
allowed_server_refs=["calendar:source-1"],
inherit_to_lower_scopes=True,
is_active=True,
revision="revision-1",
),
CredentialEnvelope(
id="other-credential",
tenant_id="tenant-2",
scope_type="tenant",
scope_id="tenant-2",
name="Other tenant",
credential_kind="username_password",
public_data={},
secret_keys=[],
allowed_modules=[],
allowed_server_refs=[],
inherit_to_lower_scopes=False,
is_active=True,
revision="revision-2",
),
]
)
session.commit()
rows = list_managed_credential_envelopes(
session,
tenant_id="tenant-1",
)
self.assertEqual([row.id for row in rows], ["tenant-credential"])
def test_tenant_credential_permission_cannot_manage_system_scope(self) -> None:
tenant_principal = _principal({"access:credential:write"})
with self.assertRaises(HTTPException) as raised:
_target_scope(
tenant_principal,
scope_type="system",
scope_id=None,
write=True,
)
self.assertEqual(raised.exception.status_code, 403)
system_principal = _principal({"access:system_credential:write"})
self.assertEqual(
_target_scope(
system_principal,
scope_type="system",
scope_id=None,
write=True,
),
(None, "system", None),
)
def test_public_data_rejects_secrets_and_kind_changes_require_secret_decision(self) -> None:
with Session(self.engine) as session:
with self.assertRaisesRegex(CredentialEnvelopeError, "public_data"):
create_credential_envelope(
session,
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Unsafe",
credential_kind="username_password",
public_data={"nested": [{"clientSecret": "not-public"}]},
)
row = create_credential_envelope(
session,
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Safe",
credential_kind="username_password",
public_data={"username": "service@example.org"},
secret_data={"password": "secret"},
)
with self.assertRaisesRegex(CredentialEnvelopeError, "requires replacing"):
update_credential_envelope(
session,
row,
credential_kind="token",
)
if __name__ == "__main__":
unittest.main()
+16 -3
View File
@@ -131,8 +131,15 @@ class DatabaseMigrationTests(unittest.TestCase):
with tempfile.TemporaryDirectory(prefix="govoplan-core-baseline-test-") as directory:
database = Path(directory) / "core.db"
url = f"sqlite:///{database}"
enabled_modules = ("access",)
command.upgrade(alembic_config(database_url=url, enabled_modules=()), "heads")
command.upgrade(
alembic_config(
database_url=url,
enabled_modules=enabled_modules,
),
"heads",
)
engine = create_engine(url)
try:
@@ -157,7 +164,13 @@ class DatabaseMigrationTests(unittest.TestCase):
).scalar_one()
current = database_migration_heads(connection)
self.assertEqual(current, configured_migration_heads(url, enabled_modules=()))
self.assertEqual(
current,
configured_migration_heads(
url,
enabled_modules=enabled_modules,
),
)
self.assertIn("core_scopes", tables)
self.assertNotIn("tenancy_tenants", tables)
self.assertIn("access_accounts", tables)
@@ -232,7 +245,7 @@ class DatabaseMigrationTests(unittest.TestCase):
self.assertEqual(current, configured_migration_heads(url, migration_track="dev"))
self.assertEqual(result.current_revision, ",".join(sorted(current)))
self.assertIn("0f1e2d3c4b5a", current)
self.assertIn("core_credential_envelopes", tables)
self.assertIn("audit_outbox_events", tables)
self.assertIn("calendar_outbox_operations", tables)
self.assertIn("file_connector_profiles", tables)
+99
View File
@@ -0,0 +1,99 @@
from __future__ import annotations
import unittest
from govoplan_core.core.dataflows import (
CAPABILITY_DATAFLOW_RUN_LIFECYCLE,
CAPABILITY_DATAFLOW_RUN_WORKER,
DataflowRunDescriptor,
DataflowRunLifecycleProvider,
DataflowRunWorker,
dataflow_run_lifecycle,
dataflow_run_worker,
)
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
class _Provider:
def start_run(self, session, principal, *, request):
del session, principal
return DataflowRunDescriptor(
ref="dataflow-run:1",
pipeline_ref=request.pipeline_ref,
revision=request.revision,
status="succeeded",
definition_hash="abc",
executor_version="test",
)
def get_run(self, session, principal, *, run_ref):
del session, principal
if run_ref != "dataflow-run:1":
return None
return DataflowRunDescriptor(
ref=run_ref,
pipeline_ref="pipeline:1",
revision=1,
status="succeeded",
definition_hash="abc",
executor_version="test",
)
def cancel_run(self, session, principal, *, run_ref):
del session, principal
return DataflowRunDescriptor(
ref=run_ref,
pipeline_ref="pipeline:1",
revision=1,
status="cancelled",
definition_hash="abc",
executor_version="test",
)
class _Worker:
def dispatch_pending(
self,
session,
*,
now=None,
limit=10,
worker_id=None,
):
return {"claimed": 0}
def purge_expired(self, session, *, now=None, limit=500):
return {"purged": 0}
class DataflowContractTests(unittest.TestCase):
def test_run_lifecycle_is_runtime_checkable_and_resolved(self) -> None:
provider = _Provider()
worker = _Worker()
self.assertIsInstance(provider, DataflowRunLifecycleProvider)
self.assertIsInstance(worker, DataflowRunWorker)
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="dataflow_contract_test",
name="Dataflow contract test",
version="test",
capability_factories={
CAPABILITY_DATAFLOW_RUN_LIFECYCLE: lambda context: provider,
CAPABILITY_DATAFLOW_RUN_WORKER: lambda context: worker,
},
)
)
registry.configure_capability_context(
ModuleContext(registry=registry, settings=object())
)
self.assertIs(provider, dataflow_run_lifecycle(registry))
self.assertIs(worker, dataflow_run_worker(registry))
self.assertIsNone(dataflow_run_lifecycle(PlatformRegistry()))
self.assertIsNone(dataflow_run_worker(PlatformRegistry()))
if __name__ == "__main__":
unittest.main()
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
import unittest
from unittest.mock import ANY, MagicMock, patch
from govoplan_core.celery_app import (
celery,
dispatch_dataflow_runs,
purge_dataflow_runs,
)
class DataflowRunWorkerTests(unittest.TestCase):
def test_dispatch_commits_worker_outcome(self) -> None:
session = MagicMock()
database = MagicMock()
database.SessionLocal.return_value.__enter__.return_value = session
provider = MagicMock()
provider.dispatch_pending.return_value = {
"claimed": 1,
"succeeded": 1,
}
with (
patch(
"govoplan_core.celery_app._dataflow_run_worker",
return_value=provider,
),
patch(
"govoplan_core.db.session.get_database",
return_value=database,
),
):
result = dispatch_dataflow_runs.run(7)
provider.dispatch_pending.assert_called_once_with(
session,
limit=7,
worker_id=ANY,
)
session.commit.assert_called_once_with()
self.assertEqual(1, result["succeeded"])
def test_retention_commits_worker_outcome(self) -> None:
session = MagicMock()
database = MagicMock()
database.SessionLocal.return_value.__enter__.return_value = session
provider = MagicMock()
provider.purge_expired.return_value = {"purged": 2}
with (
patch(
"govoplan_core.celery_app._dataflow_run_worker",
return_value=provider,
),
patch(
"govoplan_core.db.session.get_database",
return_value=database,
),
):
result = purge_dataflow_runs.run(25)
provider.purge_expired.assert_called_once_with(session, limit=25)
session.commit.assert_called_once_with()
self.assertEqual(2, result["purged"])
def test_routes_and_periodic_jobs_are_registered(self) -> None:
self.assertEqual(
celery.conf.task_routes["govoplan.dataflow.dispatch_runs"],
{"queue": "dataflow"},
)
self.assertEqual(
celery.conf.task_routes["govoplan.dataflow.purge_runs"],
{"queue": "dataflow"},
)
self.assertEqual(
"govoplan.dataflow.dispatch_runs",
celery.conf.beat_schedule[
"dataflow-runs-every-five-seconds"
]["task"],
)
self.assertEqual(
"govoplan.dataflow.purge_runs",
celery.conf.beat_schedule[
"dataflow-run-retention-daily"
]["task"],
)
if __name__ == "__main__":
unittest.main()
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
import unittest
from unittest.mock import MagicMock, patch
from govoplan_core.celery_app import celery, dispatch_dataflow_triggers
class DataflowTriggerWorkerTests(unittest.TestCase):
def test_worker_commits_dispatch_outcome(self) -> None:
session = MagicMock()
database = MagicMock()
database.SessionLocal.return_value.__enter__.return_value = session
provider = MagicMock()
provider.dispatch_due.return_value = {
"queued": 1,
"processed": 1,
"succeeded": 1,
"failed": 0,
"blocked": 0,
"skipped": 0,
}
with (
patch(
"govoplan_core.celery_app._dataflow_trigger_dispatcher",
return_value=provider,
),
patch(
"govoplan_core.db.session.get_database",
return_value=database,
),
):
result = dispatch_dataflow_triggers.run(25)
provider.dispatch_due.assert_called_once_with(session, limit=25)
session.commit.assert_called_once_with()
self.assertEqual(result["succeeded"], 1)
def test_worker_route_and_periodic_dispatch_are_registered(self) -> None:
self.assertEqual(
celery.conf.task_routes["govoplan.dataflow.dispatch_triggers"],
{"queue": "dataflow"},
)
schedule = celery.conf.beat_schedule[
"dataflow-triggers-every-minute"
]
self.assertEqual(
schedule["task"],
"govoplan.dataflow.dispatch_triggers",
)
self.assertEqual(schedule["schedule"], 60.0)
if __name__ == "__main__":
unittest.main()
+186
View File
@@ -0,0 +1,186 @@
from __future__ import annotations
import unittest
from govoplan_core.core.datasources import (
CAPABILITY_DATASOURCE_CATALOGUE,
CAPABILITY_DATASOURCE_LIFECYCLE,
CAPABILITY_DATASOURCE_ORIGINS,
CAPABILITY_DATASOURCE_PUBLICATION,
DatasourceCatalogueProvider,
DatasourceDescriptor,
DatasourceField,
DatasourceLifecycleProvider,
DatasourceMaterialization,
DatasourceOrigin,
DatasourceOriginProvider,
DatasourceOriginReadResult,
DatasourcePublicationProvider,
DatasourcePublicationResult,
DatasourceReadResult,
DatasourceStage,
datasource_catalogue,
datasource_lifecycle,
datasource_origins,
datasource_publication,
)
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
class _Provider:
descriptor = DatasourceDescriptor(
ref="datasource:source-1",
source_name="monthly_cases",
name="Monthly cases",
kind="upload",
mode="static",
shape="tabular",
schema=(DatasourceField(name="case_id", data_type="string", nullable=False),),
fingerprint="abc123",
)
def list_datasources(self, session, principal, *, query="", limit=100):
del session, principal, query
return (self.descriptor,)[:limit]
def get_datasource(self, session, principal, *, datasource_ref):
del session, principal
return self.descriptor if datasource_ref == self.descriptor.ref else None
def read_datasource(self, session, principal, *, request):
del session, principal, request
return DatasourceReadResult(
datasource=self.descriptor,
rows=({"case_id": "0012"},),
total_rows=1,
truncated=False,
)
def list_materializations(self, session, principal, *, datasource_ref):
del session, principal, datasource_ref
return ()
def list_stages(self, session, principal, *, limit=100):
del session, principal, limit
return ()
def create_stage(self, session, principal, *, stage):
del session, principal, stage
return DatasourceStage(
ref="stage:1",
name="Stage",
source_name="stage",
kind="upload",
mode="static",
shape="tabular",
state="ready",
)
def promote_stage(self, session, principal, *, stage_ref, freeze=False, frozen_label=None):
del session, principal, stage_ref, freeze, frozen_label
return self.descriptor, DatasourceMaterialization(
ref="materialization:1",
datasource_ref=self.descriptor.ref,
revision=1,
state="published",
fingerprint=self.descriptor.fingerprint,
)
def register_origin(self, session, principal, **kwargs):
del session, principal, kwargs
return self.descriptor
def refresh_datasource(self, session, principal, *, datasource_ref):
del session, principal, datasource_ref
return self.promote_stage(object(), object(), stage_ref="stage:1")
def freeze_datasource(self, session, principal, *, datasource_ref, label=None):
del session, principal, datasource_ref, label
return self.promote_stage(object(), object(), stage_ref="stage:1")[1]
def retire_datasource(self, session, principal, *, datasource_ref):
del session, principal, datasource_ref
return self.descriptor
def publish_rows(self, session, principal, *, request):
del session, principal, request
return DatasourcePublicationResult(
ref="publication:1",
status="published",
datasource=self.descriptor,
materialization=DatasourceMaterialization(
ref="materialization:1",
datasource_ref=self.descriptor.ref,
revision=1,
state="published",
fingerprint=self.descriptor.fingerprint,
),
)
def list_origins(self, session, principal, *, query="", limit=100):
del session, principal, query
return (
DatasourceOrigin(
ref="origin:1",
source_name="origin",
name="Origin",
kind="database",
shape="tabular",
supported_modes=("live", "cached"),
provider="test",
),
)[:limit]
def get_origin(self, session, principal, *, origin_ref):
return self.list_origins(session, principal)[0] if origin_ref == "origin:1" else None
def read_origin(self, session, principal, *, request):
origin = self.get_origin(session, principal, origin_ref=request.origin_ref)
return DatasourceOriginReadResult(
origin=origin,
rows=({"id": 1},),
total_rows=1,
truncated=False,
)
class DatasourceContractTests(unittest.TestCase):
def test_capabilities_are_runtime_checkable_and_resolved_without_modules(self) -> None:
provider = _Provider()
self.assertIsInstance(provider, DatasourceCatalogueProvider)
self.assertIsInstance(provider, DatasourceLifecycleProvider)
self.assertIsInstance(provider, DatasourcePublicationProvider)
self.assertIsInstance(provider, DatasourceOriginProvider)
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="datasource_contract_test",
name="Datasource contract test",
version="test",
capability_factories={
CAPABILITY_DATASOURCE_CATALOGUE: lambda context: provider,
CAPABILITY_DATASOURCE_LIFECYCLE: lambda context: provider,
CAPABILITY_DATASOURCE_PUBLICATION: lambda context: provider,
CAPABILITY_DATASOURCE_ORIGINS: lambda context: provider,
},
)
)
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
self.assertIs(provider, datasource_catalogue(registry))
self.assertIs(provider, datasource_lifecycle(registry))
self.assertIs(provider, datasource_publication(registry))
self.assertIs(provider, datasource_origins(registry))
self.assertIsNone(datasource_catalogue(PlatformRegistry()))
def test_descriptor_distinguishes_mode_kind_shape_and_materialization(self) -> None:
descriptor = _Provider.descriptor
self.assertEqual("static", descriptor.mode)
self.assertEqual("upload", descriptor.kind)
self.assertEqual("tabular", descriptor.shape)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,79 @@
from __future__ import annotations
import unittest
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.policy import (
CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
DefinitionGovernancePolicy,
DefinitionGovernanceRequest,
DefinitionScopeRef,
PolicyDecision,
definition_governance_policy,
)
from govoplan_core.core.registry import PlatformRegistry
class _Policy:
def resolve_definition_action(self, *, request):
return PolicyDecision(
allowed=request.definition_kind != "template"
or request.action != "run",
)
class DefinitionGovernanceContractTests(unittest.TestCase):
def test_scope_paths_reuse_policy_provenance_format(self) -> None:
self.assertEqual("system", DefinitionScopeRef("system").path)
self.assertEqual(
"tenant:tenant-1",
DefinitionScopeRef("tenant", "tenant-1").path,
)
def test_policy_is_runtime_resolved(self) -> None:
provider = _Policy()
self.assertIsInstance(provider, DefinitionGovernancePolicy)
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="definition_governance_test",
name="Definition governance test",
version="test",
capability_factories={
CAPABILITY_POLICY_DEFINITION_GOVERNANCE: (
lambda context: provider
),
},
)
)
registry.configure_capability_context(
ModuleContext(registry=registry, settings=object())
)
resolved = definition_governance_policy(registry)
self.assertIs(provider, resolved)
decision = resolved.resolve_definition_action(
request=DefinitionGovernanceRequest(
module_id="dataflow",
definition_ref="pipeline:1",
tenant_id="tenant-1",
definition_scope=DefinitionScopeRef(
"tenant",
"tenant-1",
),
target_scope=DefinitionScopeRef("tenant", "tenant-1"),
definition_kind="template",
action="run",
actor=PrincipalRef(
account_id="account-1",
membership_id="membership-1",
tenant_id="tenant-1",
),
)
)
self.assertFalse(decision.allowed)
if __name__ == "__main__":
unittest.main()
+145
View File
@@ -0,0 +1,145 @@
from __future__ import annotations
import unittest
from govoplan_core.core.definition_graphs import (
DefinitionEdge,
DefinitionGraphConstraints,
DefinitionGraphLibrary,
DefinitionNode,
DefinitionNodeCountConstraint,
DefinitionNodeType,
DefinitionPort,
validate_definition_graph,
)
def library(*, allow_cycles: bool) -> DefinitionGraphLibrary:
return DefinitionGraphLibrary(
id="test",
version="0.1.0",
category_labels={"control": "Control"},
node_types=(
DefinitionNodeType(
type="start",
category="control",
label="Start",
description="Start",
icon="play",
),
DefinitionNodeType(
type="step",
category="control",
label="Step",
description="Step",
icon="square",
input_ports=(DefinitionPort(id="input", label="Input"),),
),
DefinitionNodeType(
type="end",
category="control",
label="End",
description="End",
icon="circle-stop",
input_ports=(DefinitionPort(id="input", label="Input"),),
output_ports=(),
),
),
constraints=DefinitionGraphConstraints(
allow_cycles=allow_cycles,
node_counts=(
DefinitionNodeCountConstraint(
code="graph.start_count",
label="start",
minimum=1,
maximum=1,
node_types=("start",),
),
DefinitionNodeCountConstraint(
code="graph.end_count",
label="end",
minimum=1,
node_types=("end",),
),
),
),
)
class DefinitionGraphTests(unittest.TestCase):
def test_library_driven_ports_and_counts_validate_a_definition(self) -> None:
diagnostics = validate_definition_graph(
library(allow_cycles=False),
nodes=(
DefinitionNode(id="start", type="start"),
DefinitionNode(id="step", type="step"),
DefinitionNode(id="end", type="end"),
),
edges=(
DefinitionEdge(id="edge-1", source="start", target="step"),
DefinitionEdge(id="edge-2", source="step", target="end"),
),
)
self.assertEqual((), diagnostics)
def test_constraints_change_cycle_semantics_without_changing_graph_shape(self) -> None:
nodes = (
DefinitionNode(id="start", type="start"),
DefinitionNode(id="step", type="step"),
DefinitionNode(id="retry", type="step"),
DefinitionNode(id="end", type="end"),
)
edges = (
DefinitionEdge(id="edge-1", source="start", target="step"),
DefinitionEdge(id="edge-2", source="step", target="retry"),
DefinitionEdge(id="edge-3", source="retry", target="step"),
DefinitionEdge(id="edge-4", source="retry", target="end"),
)
dag_codes = {
item.code
for item in validate_definition_graph(
library(allow_cycles=False),
nodes=nodes,
edges=edges,
)
}
workflow_codes = {
item.code
for item in validate_definition_graph(
library(allow_cycles=True),
nodes=nodes,
edges=edges,
)
}
self.assertIn("graph.cycle", dag_codes)
self.assertNotIn("graph.cycle", workflow_codes)
def test_unknown_ports_and_missing_required_inputs_are_explicit(self) -> None:
diagnostics = validate_definition_graph(
library(allow_cycles=False),
nodes=(
DefinitionNode(id="start", type="start"),
DefinitionNode(id="end", type="end"),
),
edges=(
DefinitionEdge(
id="edge",
source="start",
target="end",
target_port="missing",
),
),
)
self.assertTrue(
{"edge.unknown_target_port", "node.input_required"}.issubset(
{item.code for item in diagnostics}
)
)
if __name__ == "__main__":
unittest.main()
+82 -1
View File
@@ -6,11 +6,92 @@ import sys
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from govoplan_core.devserver import redacted_database_url
from govoplan_core.devserver import build_reload_dirs, redacted_database_url
class DevserverSmokeTests(unittest.TestCase):
def test_reload_dirs_can_be_limited_to_selected_modules(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-reload-") as directory:
root = Path(directory)
core_root = root / "core"
calendar_root = root / "calendar"
mail_root = root / "mail"
extra_root = root / "extra"
for path in (core_root, calendar_root, mail_root, extra_root):
path.mkdir()
manifests = (
SimpleNamespace(id="calendar"),
SimpleNamespace(id="mail"),
)
config = SimpleNamespace(enabled_modules=(), manifest_factories=())
registry = SimpleNamespace(manifests=lambda: manifests)
def manifest_roots(manifest: object) -> tuple[Path, ...]:
return (
calendar_root
if getattr(manifest, "id") == "calendar"
else mail_root,
)
with (
patch(
"govoplan_core.devserver._config_source_roots",
return_value=(core_root,),
),
patch(
"govoplan_core.devserver._entry_point_source_roots",
return_value=(),
),
patch(
"govoplan_core.devserver._manifest_source_roots",
side_effect=manifest_roots,
),
):
broad = build_reload_dirs(config, registry=registry)
focused = build_reload_dirs(
config,
registry=registry,
module_ids=("calendar",),
extra_dirs=(str(extra_root),),
)
core_only = build_reload_dirs(
config,
registry=registry,
module_ids=(),
)
self.assertEqual(
{str(core_root), str(calendar_root), str(mail_root)},
set(broad),
)
self.assertEqual(
{str(core_root), str(calendar_root), str(extra_root)},
set(focused),
)
self.assertEqual([str(core_root)], core_only)
def test_reload_dirs_reject_disabled_module_selector(self) -> None:
config = SimpleNamespace(enabled_modules=(), manifest_factories=())
registry = SimpleNamespace(
manifests=lambda: (SimpleNamespace(id="calendar"),)
)
with (
patch(
"govoplan_core.devserver._config_source_roots",
return_value=(),
),
self.assertRaisesRegex(SystemExit, "mail"),
):
build_reload_dirs(
config,
registry=registry,
module_ids=("mail",),
)
def test_database_url_redaction_hides_passwords(self) -> None:
rendered = redacted_database_url(
"postgresql+psycopg://govoplan:database-secret@db.example.test/govoplan"
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
import unittest
from govoplan_core.core.distribution_lists import (
CAPABILITY_DISTRIBUTION_LIST_EXPAND,
DistributionExpansionLimits,
DistributionExpansionRequest,
DistributionListEntryRef,
DistributionSourceReference,
)
class DistributionListContractTests(unittest.TestCase):
def test_mixed_entry_and_bounded_request_are_provider_neutral(self) -> None:
source = DistributionSourceReference(
provider="addresses",
resource_type="address_list",
resource_id="list-1",
revision="4",
fingerprint="sha256:abc",
)
entry = DistributionListEntryRef(
id="entry-1",
kind="address_list",
mode="include",
source=source,
requested_channels=("email",),
)
request = DistributionExpansionRequest(
list_id="distribution-1",
limits=DistributionExpansionLimits(max_results=25),
)
self.assertEqual("address_list", entry.kind)
self.assertEqual(25, request.limits.max_results)
self.assertEqual("dist_lists.expand", CAPABILITY_DISTRIBUTION_LIST_EXPAND)
if __name__ == "__main__":
unittest.main()
+168
View File
@@ -0,0 +1,168 @@
from __future__ import annotations
import unittest
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationConfigurationDecision,
DocumentationConfigurationProviderRegistration,
DocumentationCondition,
DocumentationSourceDefinition,
DocumentationTopic,
ModuleManifest,
user_workflow_scope_condition_issues,
)
from govoplan_core.core.registry import PlatformRegistry, RegistryError
def workflow_topic(
*,
conditions: tuple[DocumentationCondition, ...],
documentation_types: tuple[str, ...] = ("user",),
kind: str = "workflow",
) -> DocumentationTopic:
return DocumentationTopic(
id="example.workflow.task",
title="Complete a task",
summary="Complete the example task.",
documentation_types=documentation_types, # type: ignore[arg-type]
conditions=conditions,
metadata={"kind": kind},
)
class DocumentationTopicContractTests(unittest.TestCase):
def test_user_workflow_requires_scope_conditions(self) -> None:
topic = workflow_topic(conditions=())
self.assertEqual(
user_workflow_scope_condition_issues(topic),
("user workflow topics must declare at least one scope-conditioned alternative",),
)
with self.assertRaisesRegex(RegistryError, "scope-conditioned alternative"):
registry_for(topic).validate()
def test_every_condition_alternative_must_be_scope_conditioned(self) -> None:
topic = workflow_topic(
conditions=(
DocumentationCondition(required_scopes=("example:item:read",)),
DocumentationCondition(required_modules=("example",)),
)
)
with self.assertRaisesRegex(RegistryError, r"unscoped alternative\(s\): 2"):
registry_for(topic).validate()
def test_scoped_user_workflow_and_non_user_topics_are_accepted(self) -> None:
scoped = workflow_topic(
conditions=(
DocumentationCondition(required_scopes=("example:item:read",)),
DocumentationCondition(any_scopes=("example:item:write", "example:item:admin")),
)
)
admin_workflow = workflow_topic(
conditions=(),
documentation_types=("admin",),
)
user_reference = workflow_topic(conditions=(), kind="reference")
self.assertEqual(user_workflow_scope_condition_issues(scoped), ())
self.assertEqual(user_workflow_scope_condition_issues(admin_workflow), ())
self.assertEqual(user_workflow_scope_condition_issues(user_reference), ())
registry_for(scoped, admin_workflow, user_reference).validate()
def test_documentation_configuration_and_source_extensions_are_validated(self) -> None:
resolver = lambda _context, keys: { # noqa: E731
key: DocumentationConfigurationDecision(key=key, state="enabled")
for key in keys
}
registry = PlatformRegistry()
registry.register(ModuleManifest(
id="example",
name="Example",
version="1.0.0",
documentation_configuration_providers=(
DocumentationConfigurationProviderRegistration(
keys=("example.feature",),
resolve=resolver,
),
),
documentation_sources=(
DocumentationSourceDefinition(
id="example.handbook",
kind="repository",
label="Example handbook",
),
),
))
registry.validate()
duplicate = PlatformRegistry()
duplicate.register(ModuleManifest(
id="example",
name="Example",
version="1.0.0",
documentation_configuration_providers=(
DocumentationConfigurationProviderRegistration(
keys=("example.feature",),
resolve=resolver,
),
DocumentationConfigurationProviderRegistration(
keys=("example.feature",),
resolve=resolver,
),
),
))
with self.assertRaisesRegex(RegistryError, "duplicate documentation configuration key"):
duplicate.validate()
def test_capability_documentation_is_typed_and_must_match_a_provider(self) -> None:
registry = PlatformRegistry()
registry.register(ModuleManifest(
id="example",
name="Example",
version="1.0.0",
capability_factories={"example.lookup": lambda _context: object()},
capability_documentation={
"example.lookup": CapabilityDocumentation(
label="Example lookup",
summary="Resolves example records without exposing provider internals.",
contract_version="2",
audience=("module_admin",),
),
},
))
registry.validate()
missing_provider = PlatformRegistry()
missing_provider.register(ModuleManifest(
id="example",
name="Example",
version="1.0.0",
capability_documentation={
"example.lookup": CapabilityDocumentation(
label="Example lookup",
summary="Resolves example records.",
),
},
))
with self.assertRaisesRegex(RegistryError, "does not provide it"):
missing_provider.validate()
def registry_for(*topics: DocumentationTopic) -> PlatformRegistry:
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="example",
name="Example",
version="1.0.0",
documentation=topics,
)
)
return registry
if __name__ == "__main__":
unittest.main()

Some files were not shown because too many files have changed in this diff Show More