Compare commits
161
Commits
v0.1.8
...
21c1fa49b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21c1fa49b6 | ||
|
|
435b924fd9 | ||
|
|
af5c6af0e7 | ||
|
|
c6ef644842 | ||
|
|
5783d43547 | ||
|
|
e4d2d10c7e | ||
|
|
fe62fd4644 | ||
|
|
9ecdc6d713 | ||
|
|
ca35aad286 | ||
|
|
d6255f9f8f | ||
|
|
b58c9c55cf | ||
|
|
3c4bcc28f1 | ||
|
|
972c681650 | ||
|
|
2b4eb0151f | ||
|
|
7192d32e65 | ||
|
|
b65b48832b | ||
|
|
4cb334c912 | ||
|
|
6ebb299d6c | ||
|
|
42b5019464 | ||
|
|
4c0e6435ab | ||
|
|
e37d8fee94 | ||
|
|
50a8d459e7 | ||
|
|
5ee85d07d6 | ||
|
|
cf01545806 | ||
|
|
1884274f8d | ||
|
|
5211e07d0b | ||
|
|
7b8072d049 | ||
|
|
5b55f59a92 | ||
|
|
f0898fcdee | ||
|
|
9b88ae388b | ||
|
|
6970bf7457 | ||
|
|
47e106684d | ||
|
|
9e219bc4d3 | ||
|
|
ea436a513f | ||
|
|
e7c84e3227 | ||
|
|
cf7afe9dda | ||
|
|
ca8a8c5111 | ||
|
|
f3b388fe7e | ||
|
|
af3e0a055d | ||
|
|
51d4032b86 | ||
|
|
0beb9ffea9 | ||
|
|
9e6a6b5fdc | ||
|
|
48fb953b93 | ||
|
|
4bde0495f7 | ||
|
|
a80caf7933 | ||
|
|
790790ab37 | ||
|
|
920e3c9834 | ||
|
|
13893c80cd | ||
|
|
a192a2215f | ||
|
|
e8fed6d25a | ||
|
|
d9b5708df0 | ||
|
|
68328f3d8e | ||
|
|
53e947935a | ||
|
|
324c26da78 | ||
|
|
389f98e349 | ||
|
|
ce9ef8d88f | ||
|
|
13bc3d3b4e | ||
|
|
3f5870281a | ||
|
|
a46df85479 | ||
|
|
c31581b1b9 | ||
|
|
26ae034153 | ||
|
|
baa2143a26 | ||
|
|
8b1910b5b7 | ||
|
|
d36bb94335 | ||
|
|
74034947c6 | ||
|
|
c7183fe7f1 | ||
|
|
139a352c80 | ||
|
|
336c94137f | ||
|
|
93225b6487 | ||
|
|
e11ea81008 | ||
|
|
bc8afeb139 | ||
|
|
f876345656 | ||
|
|
d487726f4d | ||
|
|
e6fc07da37 | ||
|
|
e6d589eb07 | ||
|
|
59610e21d2 | ||
|
|
cece71d945 | ||
|
|
22e8183846 | ||
|
|
aa111a5fe1 | ||
|
|
e6062fe9e4 | ||
|
|
987ca894ed | ||
|
|
4caa326878 | ||
|
|
8c4c4456c6 | ||
|
|
6abe292ac8 | ||
|
|
fea2807754 | ||
|
|
22646c614c | ||
|
|
17376332a2 | ||
|
|
0946bc84a9 | ||
|
|
a18499cbb5 | ||
|
|
36d7b73bb5 | ||
|
|
b89a2d15f1 | ||
|
|
7f923afdad | ||
|
|
a7683c5d4a | ||
|
|
41ad057f7e | ||
|
|
bf0729eb59 | ||
|
|
c4b90181e0 | ||
|
|
55ed194a99 | ||
|
|
b3b0cf0fca | ||
|
|
fa9119bea7 | ||
|
|
70ca772138 | ||
|
|
2eae5c4df6 | ||
|
|
57fe6c6006 | ||
|
|
713afdb39b | ||
|
|
77f8d15d17 | ||
|
|
fda99d40eb | ||
|
|
5ab1af803b | ||
|
|
0845e99cf6 | ||
|
|
28a0a596a6 | ||
|
|
ae74189588 | ||
|
|
09b5009187 | ||
|
|
2ca61059dc | ||
|
|
865901f090 | ||
|
|
2ac1e64daa | ||
|
|
7526c5ebb2 | ||
|
|
8e1f64c790 | ||
|
|
66e4783d2e | ||
|
|
7af86b42eb | ||
|
|
ad202f1267 | ||
|
|
6526f37aae | ||
|
|
9dabd9356d | ||
|
|
6502775bf7 | ||
|
|
b2492b820f | ||
|
|
78d9ae48b2 | ||
|
|
4cb3e94de3 | ||
|
|
9131838b98 | ||
|
|
8e9eb6e1f5 | ||
|
|
249bf63eb8 | ||
|
|
248e3dc70e | ||
|
|
230ecf42b0 | ||
|
|
825791e9b0 | ||
|
|
7184b6cdd6 | ||
|
|
ea8c600dce | ||
|
|
1839693575 | ||
|
|
183bf7aef0 | ||
|
|
37a5dfb182 | ||
|
|
844f934379 | ||
|
|
a98475f7bc | ||
|
|
1153c9dd36 | ||
|
|
7eef52776c | ||
|
|
c50ce58ad8 | ||
|
|
344fc0077f | ||
|
|
28afc01371 | ||
|
|
1a29e75db4 | ||
|
|
57ec960f40 | ||
|
|
6388afdad8 | ||
|
|
1a0e90b22d | ||
|
|
7ad6f6328a | ||
|
|
c79a7124b7 | ||
|
|
abbef5a10b | ||
|
|
2f559e3f0b | ||
|
|
9b5418db78 | ||
|
|
1678602fd6 | ||
|
|
6e373dcdd6 | ||
|
|
d1c033edc7 | ||
|
|
b5cfba666c | ||
|
|
78b4afdec4 | ||
|
|
15596f0742 | ||
|
|
a2320fcb5d | ||
|
|
e6f7c45f0a | ||
|
|
8aa1943581 | ||
|
|
b9badc9153 |
@@ -138,11 +138,13 @@ dist
|
|||||||
|
|
||||||
# Local WebUI test/build scratch directories
|
# Local WebUI test/build scratch directories
|
||||||
.component-test-build/
|
.component-test-build/
|
||||||
|
.file-drop-test-build/
|
||||||
.module-test-build/
|
.module-test-build/
|
||||||
.policy-test-build/
|
.policy-test-build/
|
||||||
.template-preview-test-build/
|
.template-preview-test-build/
|
||||||
.import-test-build/
|
.import-test-build/
|
||||||
webui/.component-test-build/
|
webui/.component-test-build/
|
||||||
|
webui/.file-drop-test-build/
|
||||||
webui/.module-test-build/
|
webui/.module-test-build/
|
||||||
webui/.policy-test-build/
|
webui/.policy-test-build/
|
||||||
webui/.template-preview-test-build/
|
webui/.template-preview-test-build/
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ tools/checks/check-focused.sh
|
|||||||
- Prefer `rg`, `sed`, and targeted tests over broad recursive scans or full builds.
|
- Prefer `rg`, `sed`, and targeted tests over broad recursive scans or full builds.
|
||||||
- Avoid DataGrid changes unless explicitly requested; it is intentionally brittle and has known deferred work.
|
- Avoid DataGrid changes unless explicitly requested; it is intentionally brittle and has known deferred work.
|
||||||
- Do not add module-to-module imports for optional integrations. Use core registry/capability/module metadata paths.
|
- Do not add module-to-module imports for optional integrations. Use core registry/capability/module metadata paths.
|
||||||
|
- Treat documentation as part of every behavior change. Update the owning module's manifest-driven `DocumentationTopic` contributions for each affected user and administrator workflow, setting, permission, limitation, and operational consequence. Feature modules own this content; `govoplan-docs` projects it and must not import feature internals.
|
||||||
|
- Keep a static user and administrator documentation baseline in every module manifest, even when richer configured-state topics come from `documentation_providers`. Run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after changing a manifest or module behavior.
|
||||||
- Treat Gitea issues as the canonical backlog and state log. Treat Gitea wiki pages as durable project context mirrored from repository and product docs. Use `docs/GITEA_ISSUES.md` for labels, templates, TODO import, wiki sync, and Codex issue updates.
|
- Treat Gitea issues as the canonical backlog and state log. Treat Gitea wiki pages as durable project context mirrored from repository and product docs. Use `docs/GITEA_ISSUES.md` for labels, templates, TODO import, wiki sync, and Codex issue updates.
|
||||||
- Do not keep generated WebUI test folders in git status; they should be ignored and removable.
|
- Do not keep generated WebUI test folders in git status; they should be ignored and removable.
|
||||||
- Do not start persistent dev servers unless the user asks.
|
- Do not start persistent dev servers unless the user asks.
|
||||||
|
|||||||
@@ -4,13 +4,6 @@
|
|||||||
**Repository type:** system (kernel).
|
**Repository type:** system (kernel).
|
||||||
<!-- govoplan-repository-type:end -->
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
[](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=module-matrix.yml&actor=0&status=0)
|
|
||||||
[](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=release-integration.yml&actor=0&status=0)
|
|
||||||
[](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=dependency-audit.yml&actor=0&status=0)
|
|
||||||
[](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.
|
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
|
## Repository ownership
|
||||||
@@ -23,6 +16,9 @@ Core owns:
|
|||||||
- kernel APIs for platform metadata, module lifecycle, health, and development diagnostics
|
- 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
|
- `@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,
|
Platform and feature modules own their backend routers, models, migrations,
|
||||||
permissions, frontend packages, nav items, and route contributions. Access,
|
permissions, frontend packages, nav items, and route contributions. Access,
|
||||||
tenancy, policy, audit, and admin behavior live in their owning platform
|
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
|
./.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
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan-core
|
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`.
|
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/`.
|
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.
|
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).
|
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
|
## Module contract
|
||||||
|
|
||||||
Backend modules register through the `govoplan.modules` entry point and return a `ModuleManifest`. A manifest can contribute:
|
Backend modules register through the `govoplan.modules` entry point and return a `ModuleManifest`. A manifest can contribute:
|
||||||
|
|||||||
@@ -30,10 +30,14 @@ _TABLE_RENAMES = (
|
|||||||
("governance_templates", "admin_governance_templates"),
|
("governance_templates", "admin_governance_templates"),
|
||||||
("governance_template_assignments", "admin_governance_template_assignments"),
|
("governance_template_assignments", "admin_governance_template_assignments"),
|
||||||
)
|
)
|
||||||
|
_KNOWN_TABLE_NAMES = {name for pair in _TABLE_RENAMES for name in pair}
|
||||||
|
|
||||||
|
|
||||||
def _row_count(bind: sa.Connection, table_name: str) -> int:
|
def _row_count(bind: sa.Connection, table_name: str) -> int:
|
||||||
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {table_name}")).scalar_one())
|
if table_name not in _KNOWN_TABLE_NAMES:
|
||||||
|
raise RuntimeError(f"Unexpected table name: {table_name}")
|
||||||
|
quoted = bind.dialect.identifier_preparer.quote(table_name)
|
||||||
|
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one()) # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||||
|
|
||||||
|
|
||||||
def _rename_tables(renames: tuple[tuple[str, str], ...]) -> None:
|
def _rename_tables(renames: tuple[tuple[str, str], ...]) -> None:
|
||||||
|
|||||||
@@ -19,11 +19,14 @@ LEGACY_SCOPE_TABLE = "tenancy_tenants"
|
|||||||
CORE_SCOPE_TABLE = "core_scopes"
|
CORE_SCOPE_TABLE = "core_scopes"
|
||||||
LEGACY_SLUG_INDEX = "ix_tenancy_tenants_slug"
|
LEGACY_SLUG_INDEX = "ix_tenancy_tenants_slug"
|
||||||
CORE_SLUG_INDEX = "ix_core_scopes_slug"
|
CORE_SLUG_INDEX = "ix_core_scopes_slug"
|
||||||
|
_KNOWN_SCOPE_TABLES = {LEGACY_SCOPE_TABLE, CORE_SCOPE_TABLE}
|
||||||
|
|
||||||
|
|
||||||
def _row_count(bind: sa.Connection, table_name: str) -> int:
|
def _row_count(bind: sa.Connection, table_name: str) -> int:
|
||||||
|
if table_name not in _KNOWN_SCOPE_TABLES:
|
||||||
|
raise RuntimeError(f"Unexpected scope table name: {table_name}")
|
||||||
quoted = bind.dialect.identifier_preparer.quote(table_name)
|
quoted = bind.dialect.identifier_preparer.quote(table_name)
|
||||||
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one())
|
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one()) # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||||
|
|
||||||
|
|
||||||
def _scope_tables(bind: sa.Connection) -> set[str]:
|
def _scope_tables(bind: sa.Connection) -> set[str]:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ revision = "9d0e1f2a3b4c"
|
|||||||
down_revision = "8c9d0e1f2a3b"
|
down_revision = "8c9d0e1f2a3b"
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
_RECONCILE_CREATE_ALL_TABLES = ("admin_governance_template_assignments", "admin_governance_templates", "core_system_settings")
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
@@ -28,9 +29,10 @@ def upgrade() -> None:
|
|||||||
tables = set(inspector.get_table_names())
|
tables = set(inspector.get_table_names())
|
||||||
|
|
||||||
# Reconcile only the empty create_all shape for the newly introduced tables.
|
# Reconcile only the empty create_all shape for the newly introduced tables.
|
||||||
for table_name in ("admin_governance_template_assignments", "admin_governance_templates", "core_system_settings"):
|
for table_name in _RECONCILE_CREATE_ALL_TABLES:
|
||||||
if table_name in tables:
|
if table_name in tables:
|
||||||
count = bind.execute(sa.text(f"SELECT COUNT(*) FROM {table_name}")).scalar_one()
|
quoted = bind.dialect.identifier_preparer.quote(table_name)
|
||||||
|
count = bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one() # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||||
if count:
|
if count:
|
||||||
raise RuntimeError(f"Cannot reconcile non-empty create_all table {table_name}")
|
raise RuntimeError(f"Cannot reconcile non-empty create_all table {table_name}")
|
||||||
op.drop_table(table_name)
|
op.drop_table(table_name)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ def upgrade() -> None:
|
|||||||
placeholders = ", ".join(f":action_{index}" for index, _ in enumerate(SYSTEM_ACTIONS))
|
placeholders = ", ".join(f":action_{index}" for index, _ in enumerate(SYSTEM_ACTIONS))
|
||||||
params = {f"action_{index}": action for index, action in enumerate(SYSTEM_ACTIONS)}
|
params = {f"action_{index}": action for index, action in enumerate(SYSTEM_ACTIONS)}
|
||||||
bind.execute(
|
bind.execute(
|
||||||
sa.text(f"UPDATE audit_log SET scope = 'system' WHERE action IN ({placeholders})"),
|
sa.text(f"UPDATE audit_log SET scope = 'system' WHERE action IN ({placeholders})"), # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||||
params,
|
params,
|
||||||
)
|
)
|
||||||
bind.execute(sa.text("UPDATE audit_log SET scope = 'tenant' WHERE scope IS NULL OR scope NOT IN ('tenant', 'system')"))
|
bind.execute(sa.text("UPDATE audit_log SET scope = 'tenant' WHERE scope IS NULL OR scope NOT IN ('tenant', 'system')"))
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""development-track wrapper for runtime coordination and recovery."""
|
||||||
|
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"
|
||||||
|
/ "e14b8c2d6f90_runtime_coordination_recovery.py"
|
||||||
|
)
|
||||||
|
_spec = spec_from_file_location("govoplan_runtime_coordination_migration", _path)
|
||||||
|
if _spec is None or _spec.loader is None:
|
||||||
|
raise RuntimeError(f"Unable to load runtime coordination 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
|
||||||
+11
-2
@@ -5,9 +5,15 @@ from logging.config import fileConfig
|
|||||||
from alembic import context
|
from alembic import context
|
||||||
from sqlalchemy import engine_from_config, pool
|
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.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 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.core.migrations import migration_metadata_plan
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_core.server.default_config import get_server_config
|
from govoplan_core.server.default_config import get_server_config
|
||||||
@@ -20,7 +26,10 @@ database_url = config.attributes.get("database_url") or settings.database_url
|
|||||||
config.set_main_option("sqlalchemy.url", database_url)
|
config.set_main_option("sqlalchemy.url", database_url)
|
||||||
|
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
fileConfig(config.config_file_name)
|
# Migrations can run inside the long-lived application process when module
|
||||||
|
# state changes. Do not let Alembic's logging setup disable loggers that the
|
||||||
|
# server already created (for example slow-request diagnostics).
|
||||||
|
fileConfig(config.config_file_name, disable_existing_loggers=False)
|
||||||
|
|
||||||
|
|
||||||
def _target_metadata():
|
def _target_metadata():
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"""add runtime coordination and recovery evidence
|
||||||
|
|
||||||
|
Revision ID: e14b8c2d6f90
|
||||||
|
Revises: d03a7b9c1e5f
|
||||||
|
Create Date: 2026-08-01 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "e14b8c2d6f90"
|
||||||
|
down_revision = "d03a7b9c1e5f"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
if "core_runtime_nodes" not in tables:
|
||||||
|
op.create_table(
|
||||||
|
"core_runtime_nodes",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("installation_id", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("node_id", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("incarnation", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("role", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("software_version", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("composition_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("queues", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("state", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("last_heartbeat_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("drain_requested_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("drain_reason", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("stopped_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
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_runtime_nodes")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"installation_id",
|
||||||
|
"node_id",
|
||||||
|
name="uq_core_runtime_node_installation_node",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"installation_id",
|
||||||
|
"node_id",
|
||||||
|
"incarnation",
|
||||||
|
"role",
|
||||||
|
"composition_hash",
|
||||||
|
"state",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_core_runtime_nodes_{column}"),
|
||||||
|
"core_runtime_nodes",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_runtime_nodes_installation_state_heartbeat",
|
||||||
|
"core_runtime_nodes",
|
||||||
|
["installation_id", "state", "last_heartbeat_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if "core_distributed_leases" not in tables:
|
||||||
|
op.create_table(
|
||||||
|
"core_distributed_leases",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("installation_id", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("resource_key", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("holder_node_id", sa.String(length=200), nullable=True),
|
||||||
|
sa.Column("holder_incarnation", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("fencing_token", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("acquired_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("renewed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), 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_distributed_leases")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"installation_id",
|
||||||
|
"resource_key",
|
||||||
|
name="uq_core_distributed_lease_resource",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"installation_id",
|
||||||
|
"resource_key",
|
||||||
|
"holder_node_id",
|
||||||
|
"holder_incarnation",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_core_distributed_leases_{column}"),
|
||||||
|
"core_distributed_leases",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_distributed_leases_expiry",
|
||||||
|
"core_distributed_leases",
|
||||||
|
["installation_id", "expires_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if "core_recovery_operations" not in tables:
|
||||||
|
op.create_table(
|
||||||
|
"core_recovery_operations",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("installation_id", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("module_id", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("operation_type", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("resource_type", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("resource_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("mode", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("plan", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("backup_reference", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("approval_reference", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("lease_resource_key", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("holder_node_id", sa.String(length=200), nullable=True),
|
||||||
|
sa.Column("holder_incarnation", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("fencing_token", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("checkpoint_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("evidence_head_sha256", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("failure_summary", sa.Text(), nullable=True),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("recovery_started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("recovered_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_recovery_operations")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"installation_id",
|
||||||
|
"module_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_core_recovery_operation_idempotency",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"installation_id",
|
||||||
|
"module_id",
|
||||||
|
"operation_type",
|
||||||
|
"resource_type",
|
||||||
|
"resource_id",
|
||||||
|
"mode",
|
||||||
|
"status",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_core_recovery_operations_{column}"),
|
||||||
|
"core_recovery_operations",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_recovery_operations_status_updated",
|
||||||
|
"core_recovery_operations",
|
||||||
|
["installation_id", "status", "updated_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_recovery_operations_resource",
|
||||||
|
"core_recovery_operations",
|
||||||
|
["module_id", "resource_type", "resource_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if "core_recovery_checkpoints" not in tables:
|
||||||
|
op.create_table(
|
||||||
|
"core_recovery_checkpoints",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("operation_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("kind", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("summary", sa.Text(), nullable=False),
|
||||||
|
sa.Column("evidence", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("previous_sha256", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("checkpoint_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["operation_id"],
|
||||||
|
["core_recovery_operations.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_core_recovery_checkpoints_operation_id_core_recovery_operations"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_recovery_checkpoints")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"operation_id",
|
||||||
|
"sequence",
|
||||||
|
name="uq_core_recovery_checkpoint_sequence",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column in ("operation_id", "status", "checkpoint_sha256"):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_core_recovery_checkpoints_{column}"),
|
||||||
|
"core_recovery_checkpoints",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_recovery_checkpoints_operation_created",
|
||||||
|
"core_recovery_checkpoints",
|
||||||
|
["operation_id", "created_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
for table in (
|
||||||
|
"core_recovery_checkpoints",
|
||||||
|
"core_recovery_operations",
|
||||||
|
"core_distributed_leases",
|
||||||
|
"core_runtime_nodes",
|
||||||
|
):
|
||||||
|
if table in tables:
|
||||||
|
op.drop_table(table)
|
||||||
@@ -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
|
and external events all need to request governed actions without bypassing the
|
||||||
same safety rules that apply to human users.
|
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,
|
Create a separate `govoplan-automation` module only if action planning,
|
||||||
schedulers, rule execution, or cross-module automation become too broad for
|
schedulers, rule execution, or cross-module automation become too broad for
|
||||||
workflow ownership.
|
workflow ownership.
|
||||||
@@ -29,6 +29,10 @@ of module capabilities.
|
|||||||
## Action Definition
|
## Action Definition
|
||||||
|
|
||||||
An `ActionDefinition` describes something a human or system actor can request.
|
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:
|
Recommended fields:
|
||||||
|
|
||||||
@@ -95,6 +99,22 @@ The runner must never advance workflow state past a required side effect unless
|
|||||||
the action definition explicitly allows asynchronous completion and the pending
|
the action definition explicitly allows asynchronous completion and the pending
|
||||||
state is visible.
|
state is visible.
|
||||||
|
|
||||||
|
For external and asynchronous effects, providers must preserve the distinction
|
||||||
|
between:
|
||||||
|
|
||||||
|
1. requested intent;
|
||||||
|
2. approved intent;
|
||||||
|
3. dispatched command;
|
||||||
|
4. possibly executed but unconfirmed outcome;
|
||||||
|
5. confirmed observed effect;
|
||||||
|
6. reconciled, corrected, or compensated outcome.
|
||||||
|
|
||||||
|
An API timeout after dispatch is not a failed effect and must not be retried as
|
||||||
|
a fresh command. The actor context should retain the real identity/account,
|
||||||
|
represented function or party, delegation or power, and mandate/jurisdiction
|
||||||
|
references when applicable. Domain modules remain responsible for deciding
|
||||||
|
which of those references are required for their action.
|
||||||
|
|
||||||
## Failure States
|
## Failure States
|
||||||
|
|
||||||
Automation should use explicit failure states:
|
Automation should use explicit failure states:
|
||||||
@@ -110,10 +130,15 @@ Automation should use explicit failure states:
|
|||||||
|
|
||||||
These states should be visible in workflow, task, and admin diagnostics.
|
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
|
## Boundary
|
||||||
|
|
||||||
Core may own stable DTOs, registry contracts, and generic audit/event hooks.
|
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.
|
module that coordinates cross-module process actions.
|
||||||
|
|
||||||
Domain modules own their own action providers. For example, templates own
|
Domain modules own their own action providers. For example, templates own
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -49,6 +49,15 @@ The broad writable root reduces approval churn. The explicit project trust entri
|
|||||||
|
|
||||||
Each active repository has an `AGENTS.md` file. These files define ownership, module boundaries, and focused commands for Codex. Keep durable project conventions there instead of repeating them in every prompt.
|
Each active repository has an `AGENTS.md` file. These files define ownership, module boundaries, and focused commands for Codex. Keep durable project conventions there instead of repeating them in every prompt.
|
||||||
|
|
||||||
|
Documentation is part of the completion criteria for every behavior change. The
|
||||||
|
owning module must update its manifest-driven `DocumentationTopic` contributions
|
||||||
|
for affected user and administrator workflows, settings, permissions,
|
||||||
|
limitations, and operational consequences. Feature documentation remains in the
|
||||||
|
feature module; the optional `govoplan-docs` module projects those contributions
|
||||||
|
without importing feature internals. Every module manifest must retain a static
|
||||||
|
user and administrator baseline even when runtime providers add configured-state
|
||||||
|
details.
|
||||||
|
|
||||||
Use `~/.codex/config.toml` for personal defaults, auth/runtime settings, writable roots, and trust decisions. Avoid checking in absolute-path writable roots or model preferences unless they are intentionally team-wide.
|
Use `~/.codex/config.toml` for personal defaults, auth/runtime settings, writable roots, and trust decisions. Avoid checking in absolute-path writable roots or model preferences unless they are intentionally team-wide.
|
||||||
|
|
||||||
Use Gitea issues as the canonical backlog and state log. See `docs/GITEA_ISSUES.md` for label setup, TODO import, and Codex issue update commands. Durable docs should describe stable behavior; changing work state belongs on the issue.
|
Use Gitea issues as the canonical backlog and state log. See `docs/GITEA_ISSUES.md` for label setup, TODO import, and Codex issue update commands. Durable docs should describe stable behavior; changing work state belongs on the issue.
|
||||||
@@ -81,5 +90,6 @@ PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versi
|
|||||||
- Avoid broad recursive scans and full builds unless the change warrants them.
|
- Avoid broad recursive scans and full builds unless the change warrants them.
|
||||||
- Keep generated build/test folders ignored.
|
- Keep generated build/test folders ignored.
|
||||||
- Keep optional module behavior behind core registry/capability/module metadata boundaries.
|
- Keep optional module behavior behind core registry/capability/module metadata boundaries.
|
||||||
|
- Run `tools/checks/check-manifest-shapes.py` after module behavior or manifest changes so user/admin documentation coverage remains complete.
|
||||||
- Create or update Gitea issues for TODOs, follow-ups, blockers, and feature requests instead of keeping local backlog files.
|
- Create or update Gitea issues for TODOs, follow-ups, blockers, and feature requests instead of keeping local backlog files.
|
||||||
- Do not start persistent dev servers unless the user asks.
|
- Do not start persistent dev servers unless the user asks.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -48,6 +48,35 @@ interface = how configured parts connect
|
|||||||
data = what the operator must provide for this deployment
|
data = what the operator must provide for this deployment
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Package Classes
|
||||||
|
|
||||||
|
The same signed package mechanism supports several explicitly named classes:
|
||||||
|
|
||||||
|
| Class | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `reference` | Prove a bounded journey, degraded states, recovery, and target-environment acceptance. |
|
||||||
|
| `product` | Provide a reusable operating capability such as governed communication, service-to-decision, procurement/contracts, or governed BI. |
|
||||||
|
| `sector` | Specialize terminology, forms, rules, process baselines, controls, reports, and integrations for an institutional sector without forking modules. |
|
||||||
|
| `deployment` | Declare a supported infrastructure topology, operational assumptions, health gates, and recovery evidence. |
|
||||||
|
| `integration` | Declare external systems, provider bindings, source-authority modes, maturity, required health, and reconciliation behavior. |
|
||||||
|
|
||||||
|
Package class is metadata and validation context, not additional authority. A
|
||||||
|
sector package does not become a module and cannot write another module's
|
||||||
|
tables. Packages may extend other packages only through versioned fragments and
|
||||||
|
must preserve provenance and parent constraints.
|
||||||
|
|
||||||
|
The contract enforces class-specific evidence. Reference packages require
|
||||||
|
target, recovery, security, operations, accessibility, privacy, and
|
||||||
|
documentation evidence. Deployment and integration packages require their
|
||||||
|
corresponding target/recovery/operations evidence, while integration packages
|
||||||
|
also name provider authority and minimum-maturity expectations. Preflight
|
||||||
|
blocks a missing, incompatible, or unhealthy provider. A derived package may
|
||||||
|
tighten parent module, capability, and provider requirements but cannot remove
|
||||||
|
or loosen them. Every non-documentation claim made by reference, deployment, or
|
||||||
|
integration packages carries a `sha256:<digest>` binding. Repository checks
|
||||||
|
recompute those hashes, while signed package verification protects the declared
|
||||||
|
manifest during transport.
|
||||||
|
|
||||||
## Package Model
|
## Package Model
|
||||||
|
|
||||||
A configuration package should be a signed, portable manifest plus module-owned
|
A configuration package should be a signed, portable manifest plus module-owned
|
||||||
@@ -68,6 +97,14 @@ Required package metadata:
|
|||||||
- preflight checks and post-import health checks
|
- preflight checks and post-import health checks
|
||||||
- migration or transformation rules for older package versions
|
- migration or transformation rules for older package versions
|
||||||
- provenance, export source metadata, and signature metadata
|
- provenance, export source metadata, and signature metadata
|
||||||
|
- package class and optional parent package/version constraints
|
||||||
|
- source-authority bindings and provider-operation expectations for every
|
||||||
|
external integration used by the package
|
||||||
|
|
||||||
|
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
|
Configuration fragments are interpreted only by the module that owns them. For
|
||||||
example, workflow imports workflow definitions; forms imports form schemas;
|
example, workflow imports workflow definitions; forms imports form schemas;
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -36,7 +36,7 @@ set +a
|
|||||||
| Setting | Required outside dev | Purpose |
|
| Setting | Required outside dev | Purpose |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `APP_ENV` | yes | Runtime profile. Use `prod`, `staging`, or a deployment-specific value outside local development. |
|
| `APP_ENV` | yes | Runtime profile. Use `prod`, `staging`, or a deployment-specific value outside local development. |
|
||||||
| `MASTER_KEY_B64` | yes | Fernet key or base64 encoded 32-byte key used for encrypted module secrets. Rotate through an explicit operator plan. |
|
| `MASTER_KEY_B64` | yes | Fernet key or base64 encoded 32-byte deployment root used for encrypted module secrets and, when enabled, the Encryption module's local server-envelope provider. Rotate only through an explicit provider-aware migration plan. |
|
||||||
| `DATABASE_URL` | yes | SQLAlchemy database URL for core and installed modules. SQLite is supported for dev/small installs; PostgreSQL is the preferred production target. |
|
| `DATABASE_URL` | yes | SQLAlchemy database URL for core and installed modules. SQLite is supported for dev/small installs; PostgreSQL is the preferred production target. |
|
||||||
| `ENABLED_MODULES` | yes | Comma-separated startup module set. Keep `tenancy,access` enabled; keep `admin` enabled for operator UI. |
|
| `ENABLED_MODULES` | yes | Comma-separated startup module set. Keep `tenancy,access` enabled; keep `admin` enabled for operator UI. |
|
||||||
|
|
||||||
@@ -144,8 +144,12 @@ tools/checks/postgres-integration-check.py \
|
|||||||
```
|
```
|
||||||
|
|
||||||
The integration check runs migrations and startup smoke checks across the
|
The integration check runs migrations and startup smoke checks across the
|
||||||
standard module permutations. `--reset-schema` is destructive and belongs only
|
standard module permutations. It first requires the retirement atomicity proof,
|
||||||
on throwaway databases.
|
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
|
### Broker And Workers
|
||||||
|
|
||||||
@@ -153,16 +157,34 @@ on throwaway databases.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. |
|
| `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_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. |
|
||||||
| `CELERY_QUEUES` | `send_email,append_sent,default` | Queue list expected by worker/process manager definitions. |
|
| `CELERY_QUEUES` | `send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default` | Queue list expected by worker/process manager definitions. Keep this aligned with every enabled module task route; Ops reports missing worker queue consumers. |
|
||||||
|
| `CELERY_VISIBILITY_TIMEOUT_SECONDS` | `3600` | Maximum time before Redis may redeliver work left unacknowledged by a lost worker. Set this above the longest supported task duration; changing it requires a worker-loss acceptance drill. |
|
||||||
|
| `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:
|
Worker command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m celery -A govoplan_core.celery_app:celery worker \
|
python -m celery -A govoplan_core.celery_app:celery worker \
|
||||||
--queues send_email,append_sent,default \
|
--queues send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default \
|
||||||
--loglevel INFO
|
--loglevel INFO
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run Celery beat as a separately supervised process. Its built-in one-minute
|
||||||
|
schedule recovers Calendar outbox rows left behind by broker failures, process
|
||||||
|
crashes, and expired worker leases:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m celery -A govoplan_core.celery_app:celery beat --loglevel INFO
|
||||||
|
```
|
||||||
|
|
||||||
|
Before promoting a worker composition, run the repository worker-runtime drill
|
||||||
|
against the same Redis and Core build. It uses the bounded
|
||||||
|
`govoplan.worker.acceptance` task and records publish/consume, retry, warm
|
||||||
|
SIGTERM, and worker-loss redelivery evidence without accessing tenant data.
|
||||||
|
Production evidence must use the deployed queue configuration and a visibility
|
||||||
|
timeout that is longer than every supported business task.
|
||||||
|
|
||||||
### Storage
|
### Storage
|
||||||
|
|
||||||
| Setting | Default | Notes |
|
| Setting | Default | Notes |
|
||||||
@@ -175,6 +197,11 @@ python -m celery -A govoplan_core.celery_app:celery worker \
|
|||||||
| `FILE_STORAGE_S3_ACCESS_KEY_ID` | empty | Secret; inject through deployment environment. |
|
| `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_SECRET_ACCESS_KEY` | empty | Secret; inject through deployment environment. |
|
||||||
| `FILE_STORAGE_S3_BUCKET` | `files` | Managed-file object bucket. |
|
| `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
|
Legacy `S3_*` settings remain for older storage paths but new deployments should
|
||||||
prefer `FILE_STORAGE_*`.
|
prefer `FILE_STORAGE_*`.
|
||||||
@@ -184,15 +211,64 @@ prefer `FILE_STORAGE_*`.
|
|||||||
| Setting | Default | Notes |
|
| Setting | Default | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `CORS_ORIGINS` | local dev origins | Set to the exact WebUI origins in staging/production. |
|
| `CORS_ORIGINS` | local dev origins | Set to the exact WebUI origins in staging/production. |
|
||||||
|
| `GOVOPLAN_TRUSTED_HOSTS` | empty | Exact API host names accepted by the application. Production-like validation requires an explicit list; narrowly scoped `*.example.org` entries are supported. |
|
||||||
|
| `FORWARDED_ALLOW_IPS` | Uvicorn default | Address or network of the trusted reverse proxy. Never use `*` in production-like deployments. |
|
||||||
| `AUTH_SESSION_COOKIE_NAME` | configured default | Change only through a controlled rollout because it logs users out. |
|
| `AUTH_SESSION_COOKIE_NAME` | configured default | Change only through a controlled rollout because it logs users out. |
|
||||||
| `AUTH_CSRF_COOKIE_NAME` | configured default | Must match WebUI/API deployment. |
|
| `AUTH_CSRF_COOKIE_NAME` | configured default | Must match WebUI/API deployment. |
|
||||||
| `AUTH_COOKIE_SECURE` | `false` | Set `true` behind HTTPS. |
|
| `AUTH_COOKIE_SECURE` | `false` | Set `true` behind HTTPS. |
|
||||||
| `AUTH_COOKIE_SAMESITE` | `lax` | Use a stricter value only after testing login and CSRF flows. |
|
| `AUTH_COOKIE_SAMESITE` | `lax` | Use a stricter value only after testing login and CSRF flows. |
|
||||||
| `AUTH_COOKIE_DOMAIN` | empty | Set only when the API and WebUI intentionally share a parent domain. |
|
| `AUTH_COOKIE_DOMAIN` | empty | Set only when the API and WebUI intentionally share a parent domain. |
|
||||||
|
| `GOVOPLAN_HTTP_HSTS_SECONDS` | `31536000` in production, otherwise `0` | Emitted only for HTTPS requests. Set `0` while rehearsing a deployment that is not yet HTTPS-only. |
|
||||||
|
| `GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES` | `536870912` (512 MiB) | Deployment hard ceiling; file and module APIs apply their own lower limits where appropriate. |
|
||||||
|
|
||||||
|
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. 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
|
||||||
|
|
||||||
|
| Setting | Default | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS` | `true` in dev/test, otherwise `false` | Deployment-wide decision. Set `true` only when pinned HTTP(S), DAV, SMTP, or IMAP transports must reach internal addresses. It does not enable an SDK transport that cannot pin every peer. |
|
||||||
|
| `GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES` | `16777216` (16 MiB) | Maximum buffered JSON, XML, iCalendar, vCard, catalog, and connector error response. |
|
||||||
|
| `GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES` | `536870912` (512 MiB) | Hard upper bound for a single remote file; module upload limits may be lower. |
|
||||||
|
| `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST` | empty | Comma-separated exact environment names usable by deployment-owned connector profiles. Tenant/API-managed profiles cannot select process variables, even when a name is listed. |
|
||||||
|
| `GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST` | empty | Comma-separated exact absolute CA bundle paths. Mount the same files at the same paths on every API and connector worker. |
|
||||||
|
|
||||||
|
Production-like configuration validation requires the private-network choice to
|
||||||
|
be explicit. HTTP connector downloads are streamed up to the configured bound,
|
||||||
|
and credential-bearing DAV redirects remain confined to their configured
|
||||||
|
origin.
|
||||||
|
|
||||||
|
The urllib, HTTPX/httpcore, SMTP, and IMAP transports resolve, validate, and
|
||||||
|
connect to the same approved address record while retaining the original host
|
||||||
|
for HTTP Host, TLS SNI, and certificate verification. Live SMB and S3 access
|
||||||
|
fails closed in both public-only and private-network deployments: the current
|
||||||
|
SDK transports cannot pin every initial and secondary peer or revalidate every
|
||||||
|
SDK-managed redirect/referral. An explicit IP endpoint does not bypass this
|
||||||
|
rule. Production deployments should still enforce the same decision at their
|
||||||
|
worker/container egress firewall or outbound proxy as a second boundary.
|
||||||
|
|
||||||
|
File connector TLS verification may be disabled only in dev/test. A custom CA
|
||||||
|
bundle must be an existing regular file whose resolved absolute path is listed
|
||||||
|
in `GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST`. Environment-backed file connector
|
||||||
|
credentials are supported only in deployment-owned connector JSON and require
|
||||||
|
their exact names in `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST`; UI/API profiles
|
||||||
|
must use encrypted stored credentials or a scoped secret-provider reference.
|
||||||
|
|
||||||
Public URLs are currently supplied by deployment/reverse-proxy configuration and
|
Public URLs are currently supplied by deployment/reverse-proxy configuration and
|
||||||
module settings. Do not hardcode them in core; configuration packages should ask
|
module settings. Do not hardcode them in core; configuration packages should ask
|
||||||
for portal, WebUI, postbox, and notification URLs when they become relevant.
|
for portal, WebUI, postbox, and notification URLs when they become relevant.
|
||||||
|
Uvicorn applies `X-Forwarded-*` only from `FORWARDED_ALLOW_IPS`; keep that value
|
||||||
|
aligned with the reverse proxy and do not expose the application server directly
|
||||||
|
through the same trusted address range.
|
||||||
|
|
||||||
### Module Catalogs, Licenses, And Trust Roots
|
### Module Catalogs, Licenses, And Trust Roots
|
||||||
|
|
||||||
@@ -275,7 +351,7 @@ the checked in `.env.example`. It runs:
|
|||||||
- explicit `ENABLED_MODULES`
|
- explicit `ENABLED_MODULES`
|
||||||
- explicit migrations and `--with-dev-data` bootstrap
|
- explicit migrations and `--with-dev-data` bootstrap
|
||||||
- API via the module-aware devserver
|
- API via the module-aware devserver
|
||||||
- a Celery worker for `send_email,append_sent,default`
|
- a Celery worker for `send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default`
|
||||||
- WebUI through the Vite dev server
|
- WebUI through the Vite dev server
|
||||||
- durable local files under `runtime/production-like/files`
|
- durable local files under `runtime/production-like/files`
|
||||||
|
|
||||||
@@ -394,7 +470,9 @@ Run the rollback drill before relying on installer automation in a new
|
|||||||
environment:
|
environment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/mnt/DATA/git/govoplan/tools/checks/module-installer-rollback-drill.py --format json
|
/mnt/DATA/git/govoplan/tools/checks/module-installer-rollback-drill.py \
|
||||||
|
--format json \
|
||||||
|
--evidence-path runtime/module-installer/restore-drill-evidence.json
|
||||||
```
|
```
|
||||||
|
|
||||||
The drill uses temporary SQLite databases and simulated package commands. It
|
The drill uses temporary SQLite databases and simulated package commands. It
|
||||||
@@ -415,7 +493,7 @@ checks, catalog trust, signing, keyring, replay, and license operation.
|
|||||||
## Operator Checklist
|
## Operator Checklist
|
||||||
|
|
||||||
- Runtime secrets are injected outside git.
|
- Runtime secrets are injected outside git.
|
||||||
- `MASTER_KEY_B64` is set and backed up securely.
|
- `MASTER_KEY_B64` is set and backed up securely; restores of locally encrypted content fail closed without the exact matching key.
|
||||||
- Database backup and restore commands are tested.
|
- Database backup and restore commands are tested.
|
||||||
- File/object storage is durable and backed up.
|
- File/object storage is durable and backed up.
|
||||||
- `CORS_ORIGINS` and cookie settings match the deployed WebUI origin.
|
- `CORS_ORIGINS` and cookie settings match the deployed WebUI origin.
|
||||||
|
|||||||
@@ -9,12 +9,16 @@ operator, and roadmap pages.
|
|||||||
| Topic | Canonical document | Notes |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| Policy decision DTOs and provenance | `POLICY_CONTRACTS.md` | Shared explain/provenance shape; module-specific policy docs should link here. |
|
||||||
| Events and audit trace context | `EVENTS_AND_AUDIT.md` | Event dispatch semantics and audit payload conventions. |
|
| Events and audit trace context | `EVENTS_AND_AUDIT.md` | Event dispatch semantics and audit payload conventions. |
|
||||||
| Action/effect automation layer | `ACTION_EFFECT_AUTOMATION_LAYER.md` | Action/effect contracts, consequence preview, runner semantics, and module boundary for automation. |
|
| Action/effect automation layer | `ACTION_EFFECT_AUTOMATION_LAYER.md` | Action/effect contracts, consequence preview, runner semantics, and module boundary for automation. |
|
||||||
|
| External references and integration maturity | `EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md` | Stable external identity and cumulative connector maturity; configured source authority is defined by the meta target architecture. |
|
||||||
|
| Institutional context and governed references | `INSTITUTIONAL_CONTEXT_CONTRACT.md` | Shared temporal, actor/representation, institution, mandate, service, party, decision, evidence, legal-basis, information-governance, presentation, and geo DTO/provider contracts. |
|
||||||
| Postbox E2EE target architecture | `POSTBOX_E2EE_ARCHITECTURE.md` | Strategic encrypted postbox/mailbox model, key ownership, role mailbox semantics, and retraction limits. |
|
| Postbox E2EE target architecture | `POSTBOX_E2EE_ARCHITECTURE.md` | Strategic encrypted postbox/mailbox model, key ownership, role mailbox semantics, and retraction limits. |
|
||||||
|
| Shared state, runtime coordination, and recovery | `STATE_AND_RECOVERY_CONTRACT.md` | State profiles, object storage, node registration/drain, fenced leases, migration ordering, and recovery evidence. |
|
||||||
|
|
||||||
## Release And Operations
|
## Release And Operations
|
||||||
|
|
||||||
@@ -25,12 +29,14 @@ 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. |
|
| Release dependencies and catalogs | `RELEASE_DEPENDENCIES.md` | Release package refs, migration baselines, release lockfiles, catalog trust/licensing, catalog publishing, and release checklist. |
|
||||||
| Dependency vulnerability audits | `DEPENDENCY_AUDITS.md` | Local and CI audit commands plus dated audit result notes. |
|
| Dependency vulnerability audits | `DEPENDENCY_AUDITS.md` | Local and CI audit commands plus dated audit result notes. |
|
||||||
| Remote WebUI bundle design | `REMOTE_WEBUI_BUNDLES.md` | Experimental controlled-deployment design; normal releases still use package builds. |
|
| Remote WebUI bundle design | `REMOTE_WEBUI_BUNDLES.md` | Experimental controlled-deployment design; normal releases still use package builds. |
|
||||||
|
| WebUI loading and bundle budgets | `WEBUI_BUNDLE_BUDGETS.md` | Installed-module lazy boundaries, enforced initial/async budgets, and baseline measurements. |
|
||||||
|
|
||||||
## Product And Module Planning
|
## Product And Module Planning
|
||||||
|
|
||||||
| Topic | Canonical document | Notes |
|
| Topic | Canonical document | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Product roadmap and module routing | `GOVOPLAN_MASTER_ROADMAP.md` | Product-level sequencing, implementation gates, issue routing, and missing-module decisions. |
|
| Product roadmap and module routing | `GOVOPLAN_MASTER_ROADMAP.md` | Product-level sequencing, implementation gates, issue routing, and missing-module decisions. |
|
||||||
|
| Institutional governance target | `govoplan/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md` | Cross-product semantic layers, source-authority modes, candidate Mandates/Services/Parties/Decisions boundaries, and migration sequence. |
|
||||||
| UI/UX decisions | `UI_UX_DECISION_LEDGER.md` | Binding guided-UI decisions, open decisions, impact index, and review checklist. |
|
| UI/UX decisions | `UI_UX_DECISION_LEDGER.md` | Binding guided-UI decisions, open decisions, impact index, and review checklist. |
|
||||||
| Interface ethics and design doctrine | `INTERFACE_ETHICS_AND_DESIGN_DOCTRINE.md` | Product-level doctrine for context, decision, consequence, contestability, responsibility, and traceability. |
|
| Interface ethics and design doctrine | `INTERFACE_ETHICS_AND_DESIGN_DOCTRINE.md` | Product-level doctrine for context, decision, consequence, contestability, responsibility, and traceability. |
|
||||||
| Public-sector integration posture | `PUBLIC_SECTOR_INTEGRATION_STRATEGY.md` | Strategy index; executable target inventory lives in `govoplan-connectors`. |
|
| Public-sector integration posture | `PUBLIC_SECTOR_INTEGRATION_STRATEGY.md` | Strategy index; executable target inventory lives in `govoplan-connectors`. |
|
||||||
|
|||||||
+27
-19
@@ -8,25 +8,32 @@ module reactions, and operator diagnostics.
|
|||||||
|
|
||||||
## Production Transport Decision
|
## Production Transport Decision
|
||||||
|
|
||||||
The first production target is a **database outbox plus in-process immediate
|
The production transport is a **transactional database outbox plus retrying
|
||||||
dispatch**:
|
dispatcher**:
|
||||||
|
|
||||||
- Use `govoplan_core.core.events.PlatformEvent` for domain and platform events.
|
- Use `govoplan_core.core.events.PlatformEvent` for domain and platform events.
|
||||||
- Use `EventBus` as the in-process dispatch contract for same-process module
|
- Call `emit_platform_event(session, event)` to bind event delivery to the
|
||||||
reactions that are safe to run inline.
|
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
|
- 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.
|
governed `PlatformEvent` whose `type` is the audit action.
|
||||||
- Use `record_change` for module delta feeds. It persists the change-sequence
|
- 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`.
|
`mail.profile.updated`.
|
||||||
- Persist durable integration/workflow events through a database outbox before
|
- Drain the outbox with the Celery `govoplan.events.dispatch_outbox` task on the
|
||||||
acknowledging the state change that produced them.
|
`events` queue. The periodic schedule also retries pending rows.
|
||||||
- Drain the outbox through a small dispatcher process. The dispatcher may call
|
- The dispatcher invokes Dataflow event ingestion when that capability is
|
||||||
in-process handlers in the same deployment first, but its storage contract is
|
active, then publishes to the process-local bus.
|
||||||
database-backed.
|
|
||||||
- Treat Redis/Celery as worker/job infrastructure, not as the authoritative
|
- 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`
|
- Keep the dispatch implementation pluggable behind the `PlatformEvent`
|
||||||
envelope so a future message broker can be added without changing event
|
envelope so a future message broker can be added without changing event
|
||||||
producers.
|
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
|
database transaction wherever possible. Handlers must be idempotent because the
|
||||||
outbox dispatcher can retry after a crash or timeout.
|
outbox dispatcher can retry after a crash or timeout.
|
||||||
|
|
||||||
Recommended first outbox columns:
|
The current outbox stores:
|
||||||
|
|
||||||
- `event_id`, `event_type`, `module_id`
|
- `event_id`, `event_type`, `module_id`
|
||||||
- `correlation_id`, `causation_id`
|
- `correlation_id`, `causation_id`
|
||||||
- `payload`, `occurred_at`
|
- `classification`, serialized event `payload`
|
||||||
- `available_at`, `attempt_count`, `claimed_at`, `claim_token`
|
- `status`, `attempts`, `next_attempt_at`
|
||||||
- `processed_at`, `last_error`
|
- `dispatched_at`, `last_error`, timestamps
|
||||||
|
|
||||||
Inline `EventBus` handlers are allowed only for non-critical local reactions.
|
Handlers must be idempotent: a worker may complete an external effect and fail
|
||||||
Anything that must survive process failure, restart, package update, or worker
|
before marking its outbox row dispatched. Anything that must survive process
|
||||||
redeployment belongs in the outbox.
|
failure, restart, package update, or worker redeployment requires the outbox
|
||||||
|
provider and dispatcher.
|
||||||
|
|
||||||
## Trace IDs
|
## Trace IDs
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Source Authority Is A Separate Dimension
|
||||||
|
|
||||||
|
Integration maturity states what an adapter is capable of doing. It does not
|
||||||
|
decide which system owns truth for a configured object or field group. A
|
||||||
|
binding separately selects one of the source-authority modes defined by the
|
||||||
|
[institutional governance target architecture](../../govoplan/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md):
|
||||||
|
|
||||||
|
- `native_authoritative`
|
||||||
|
- `external_authoritative`
|
||||||
|
- `external_mirror`
|
||||||
|
- `governed_sync`
|
||||||
|
- `governance_overlay`
|
||||||
|
- `linked_reference`
|
||||||
|
|
||||||
|
A connector can therefore support `synchronize` while a tenant deliberately
|
||||||
|
uses it only as an external mirror. Conversely, a native GovOPlaN object may
|
||||||
|
retain link-only references to several external systems. Authority may be
|
||||||
|
narrowed by tenant, organization, service, object type, object, field group, or
|
||||||
|
process step and must be visible in provenance and configuration preflight.
|
||||||
|
|
||||||
|
## 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`.
|
||||||
+235
-156
@@ -1,13 +1,26 @@
|
|||||||
# GovOPlaN Master Roadmap
|
# GovOPlaN Master Roadmap
|
||||||
|
|
||||||
This roadmap is the durable product north star and sequencing guide for
|
This roadmap is the technical and module-sequencing companion for GovOPlaN as
|
||||||
GovOPlaN as a modular platform for administrative operations. It keeps the
|
a modular platform for administrative operations. It translates the
|
||||||
product moving without turning every possible public-sector need into an
|
cross-product outcome horizons into dependency waves without turning every
|
||||||
immediate implementation track.
|
possible public-sector need into an immediate implementation track.
|
||||||
|
|
||||||
Use this document for product direction, sequencing, and module routing. Issues
|
Use this document for technical sequencing, module routing, and implementation
|
||||||
are the active backlog; this document is durable planning context and should be
|
gates. Issues are the active backlog; this document is durable architecture
|
||||||
mirrored to the Gitea wiki.
|
planning context and should be mirrored to the Gitea wiki.
|
||||||
|
|
||||||
|
The meta repository's
|
||||||
|
[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/GovOPlaN/govoplan/src/branch/main/docs/REFERENCE_JOURNEY_PROGRAM.md).
|
||||||
|
The semantic target, source-authority modes, and reconciliation with the
|
||||||
|
implemented platform are in the meta repository's
|
||||||
|
[Institutional Governance Target Architecture](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
|
||||||
|
Those product documents are canonical; this Core roadmap remains their
|
||||||
|
technical sequencing and module-routing companion.
|
||||||
|
|
||||||
## Product Thesis
|
## Product Thesis
|
||||||
|
|
||||||
@@ -54,8 +67,9 @@ verify or reverse those effects.
|
|||||||
`INTERFACE_ETHICS_AND_DESIGN_DOCTRINE.md`.
|
`INTERFACE_ETHICS_AND_DESIGN_DOCTRINE.md`.
|
||||||
- Automation must use governed action/effect contracts, not hidden side
|
- Automation must use governed action/effect contracts, not hidden side
|
||||||
effects. The first automation layer is defined in
|
effects. The first automation layer is defined in
|
||||||
`ACTION_EFFECT_AUTOMATION_LAYER.md` and should start in `govoplan-workflow`
|
`ACTION_EFFECT_AUTOMATION_LAYER.md`; its first runner lives in
|
||||||
unless a separate automation module becomes justified.
|
`govoplan-workflow-engine`. Create a separate automation module only if the
|
||||||
|
scheduler/action runtime outgrows workflow coordination.
|
||||||
- Encrypted postboxes are a strategic target. Early postbox, access, and
|
- Encrypted postboxes are a strategic target. Early postbox, access, and
|
||||||
identity-trust contracts should stay compatible with the E2EE architecture in
|
identity-trust contracts should stay compatible with the E2EE architecture in
|
||||||
`POSTBOX_E2EE_ARCHITECTURE.md`.
|
`POSTBOX_E2EE_ARCHITECTURE.md`.
|
||||||
@@ -113,7 +127,8 @@ pattern exists.
|
|||||||
|
|
||||||
## Focus Rules
|
## Focus Rules
|
||||||
|
|
||||||
1. Build one reference journey per wave.
|
1. Build one selected reference journey stage at a time; a later capability
|
||||||
|
cluster is not an active program merely because it appears below.
|
||||||
2. Do not implement a module because the repository exists.
|
2. Do not implement a module because the repository exists.
|
||||||
3. Do not add module-to-module imports for optional behavior.
|
3. Do not add module-to-module imports for optional behavior.
|
||||||
4. Every new domain module must justify its own semantics beyond `cases`,
|
4. Every new domain module must justify its own semantics beyond `cases`,
|
||||||
@@ -133,18 +148,28 @@ pattern exists.
|
|||||||
| Structured forms and validation | `govoplan-forms` |
|
| Structured forms and validation | `govoplan-forms` |
|
||||||
| Uploaded files and managed storage | `govoplan-files` |
|
| Uploaded files and managed storage | `govoplan-files` |
|
||||||
| Case record and lifecycle | `govoplan-cases` |
|
| Case record and lifecycle | `govoplan-cases` |
|
||||||
| Workflow transitions and automation | `govoplan-workflow` |
|
| Workflow runtime, transitions, and automation | `govoplan-workflow-engine` |
|
||||||
| Action/effect catalogue and automation runner | first `govoplan-workflow`; possible future `govoplan-automation` if it outgrows workflow |
|
| Workflow definition editing | optional `govoplan-workflow` |
|
||||||
|
| Action/effect catalogue and automation runner | first `govoplan-workflow-engine`; possible future `govoplan-automation` only if it outgrows workflow |
|
||||||
| Internal work queues and tasks | `govoplan-tasks` |
|
| Internal work queues and tasks | `govoplan-tasks` |
|
||||||
| Appointment proposals and booking | `govoplan-appointments`, `govoplan-calendar` |
|
| Appointment proposals and booking | `govoplan-appointments`, `govoplan-calendar` |
|
||||||
| Postbox, email, and notifications | `govoplan-postbox`, `govoplan-mail`, `govoplan-notifications` |
|
| Postbox, email, and notifications | `govoplan-postbox`, `govoplan-mail`, `govoplan-notifications` |
|
||||||
|
| Canonical subjects and account links | `govoplan-identity` |
|
||||||
|
| Organizational structures, units, and functions | `govoplan-organizations` |
|
||||||
|
| Identity-to-function assignments and directory synchronization | `govoplan-idm` |
|
||||||
| Identity trust, device keys, and encrypted postbox key contracts | `govoplan-identity-trust`, `govoplan-access`, `govoplan-postbox` |
|
| Identity trust, device keys, and encrypted postbox key contracts | `govoplan-identity-trust`, `govoplan-access`, `govoplan-postbox` |
|
||||||
| Service directory/catalog | `govoplan-portal` |
|
| Service directory presentation | `govoplan-portal` |
|
||||||
|
| Versioned institutional service definition | shared service contract first; candidate `govoplan-services` after reuse proof |
|
||||||
|
| Mandate, jurisdiction, and institutional authority | shared mandate contract first; candidate `govoplan-mandates` after reuse proof |
|
||||||
|
| Procedure-local parties and representation | shared party contract first; candidate `govoplan-parties` after reuse proof |
|
||||||
|
| Formal institutional decisions | shared decision contract first; candidate `govoplan-decisions` after reuse proof |
|
||||||
| Permit/document generation | `govoplan-templates`, `govoplan-dms` |
|
| Permit/document generation | `govoplan-templates`, `govoplan-dms` |
|
||||||
| Payment capture and accounting handoff | `govoplan-payments`, `govoplan-ledger` |
|
| Payment capture and accounting handoff | `govoplan-payments`, `govoplan-ledger` |
|
||||||
| Roles, permissions, tenants, policy, audit | `govoplan-access`, `govoplan-tenancy`, `govoplan-policy`, `govoplan-audit` |
|
| Authentication projection, roles, permissions, acting context | `govoplan-access` |
|
||||||
|
| Tenants, policy, and audit | `govoplan-tenancy`, `govoplan-policy`, `govoplan-audit` |
|
||||||
| External software integration | `govoplan-connectors` |
|
| External software integration | `govoplan-connectors` |
|
||||||
| Recurring extraction and transformation | possible future `govoplan-datasources`, possible future `govoplan-dataflow` |
|
| Governed data/register catalogue | `govoplan-datasources` |
|
||||||
|
| Recurring extraction and transformation | `govoplan-dataflow` over Datasources and Connectors contracts |
|
||||||
| Reports, BI, and management visibility | `govoplan-reporting` |
|
| Reports, BI, and management visibility | `govoplan-reporting` |
|
||||||
|
|
||||||
## Configuration And Safety Target
|
## Configuration And Safety Target
|
||||||
@@ -189,56 +214,85 @@ an editor applies a high-impact configuration change.
|
|||||||
|
|
||||||
## Reference Journeys
|
## Reference Journeys
|
||||||
|
|
||||||
The roadmap should be driven by three journeys.
|
The active sequence is selected. Workflow Engine and its optional editor are
|
||||||
|
now available foundations, but a reference journey does not depend on Workflow
|
||||||
|
unless its package explicitly composes and proves it.
|
||||||
|
|
||||||
### Journey 1: Permit To Payment
|
### Journey 1: Campaign Demonstration Composition
|
||||||
|
|
||||||
This is the primary public-administration journey.
|
Campaign is the first complete proof of modular composition. Campaign owns
|
||||||
|
intent, recipient snapshots, personalization, execution state, and delivery
|
||||||
|
evidence. Mail owns reusable profiles, credentials, protocol policy, and
|
||||||
|
provider execution; Campaign stores only a selected profile reference. Files
|
||||||
|
owns storage, connector profiles, file policy, and provenance.
|
||||||
|
|
||||||
1. A person applies for a permit through the public portal.
|
The technical gate is a pinned Campaign/Mail/Files composition with central
|
||||||
2. The applicant uploads required files and submits structured form data.
|
UI, adaptive user/admin/operator/integration documentation, target SMTP/IMAP
|
||||||
3. Submission creates a case, a workflow instance, and an internal task.
|
and file-provider evidence, and explicit test/send/resend/retry/reconciliation
|
||||||
4. Completing the task creates a postbox message, a notification, and an email
|
semantics. Readers must not receive backend paths, worker claims, secrets, or
|
||||||
notification with an appointment proposal.
|
raw provider diagnostics.
|
||||||
5. The applicant accepts an appointment, which updates the calendar and the
|
|
||||||
workflow state.
|
|
||||||
6. During the appointment, the case is opened and the permit is generated from
|
|
||||||
a governed template.
|
|
||||||
7. The payment is processed and linked to the case and accounting handoff.
|
|
||||||
8. The permit, payment evidence, communication history, audit trail, retention
|
|
||||||
state, and records evidence remain available according to policy.
|
|
||||||
|
|
||||||
This journey proves the platform can coordinate modules without core knowing
|
### Journey 2: Function-Bound Postbox Delivery
|
||||||
module internals.
|
|
||||||
|
|
||||||
### Journey 2: Training To Certificate
|
Postbox accepts delivery to an addressable postbox or a function in an
|
||||||
|
organizational unit. Organizations owns units and functions, Identity owns
|
||||||
|
subjects, IDM owns identity-to-function assignments and upstream sync, and
|
||||||
|
Access resolves current roles, delegation, acting context, and permission.
|
||||||
|
|
||||||
This is the best university-administration and internal-administration journey.
|
Campaign consumes a typed delivery-target capability without importing Postbox
|
||||||
|
or identity internals. Reassignment changes future access without moving the
|
||||||
|
message; vacancy or ambiguous acting context fails visibly; delivery, access,
|
||||||
|
and correction remain auditable.
|
||||||
|
|
||||||
1. Course or training offer is planned.
|
### Journey 3: Data-Backed Templates, Reports, And Deep Launch
|
||||||
2. Room, trainer, resource, and capacity are booked.
|
|
||||||
3. Participants register or are assigned.
|
|
||||||
4. Attendance is tracked.
|
|
||||||
5. Certificate or participation confirmation is issued.
|
|
||||||
6. Evidence remains available through records, files, audit, and docs.
|
|
||||||
|
|
||||||
This journey keeps `booking`, `resources`, `learning`, and `certificates`
|
An authenticated user follows an opaque, short-lived launch reference from HIS
|
||||||
focused instead of becoming broad ERP replacements.
|
or another specialist system. GovOPlaN re-authorizes the actor, resolves a
|
||||||
|
curated data context server-side, displays source/freshness/version, and renders
|
||||||
|
one reproducible document and report.
|
||||||
|
|
||||||
### Journey 3: Report To Resolution
|
Templates owns definition/version/schema/rendering, Reporting owns source
|
||||||
|
selection/parameters/execution/export, Files owns generated bytes, and
|
||||||
|
connectors own protocol access. URLs do not carry credentials, arbitrary SQL,
|
||||||
|
or trusted raw personal data. Retries are idempotent and generation evidence
|
||||||
|
connects source, snapshot/reference, transformation, definition, parameters,
|
||||||
|
output checksum, actor, and policy.
|
||||||
|
|
||||||
This is the internal operations and municipal issue-reporting journey.
|
### Journey 4: Governed University BI Path
|
||||||
|
|
||||||
1. A person reports an issue.
|
Starting from the Journey 3 source contract, one bounded university dataset is
|
||||||
2. The issue is triaged into helpdesk, facilities, assets, or a case.
|
catalogued, staged by snapshot or watermark, validated, transformed through a
|
||||||
3. Work is assigned, tracked, and escalated.
|
versioned lineage graph, and exposed as a policy-aware analytical data product.
|
||||||
4. Evidence, communication, and status updates are preserved.
|
The result must preserve official-key mappings, organizational and reporting
|
||||||
5. Reports show workload, SLA, recurring problems, and completion.
|
date semantics, quality findings, quarantine/replay, transparent calculation,
|
||||||
|
and reproducible promotion between development, test, and production.
|
||||||
|
|
||||||
This journey prevents `helpdesk`, `issue-reporting`, `facilities`, and `assets`
|
Reporting consumes the product. Datasources owns the governed source and
|
||||||
from becoming disconnected ticket silos.
|
materialization lifecycle; Dataflow owns typed transformation/run lineage;
|
||||||
|
Connectors owns external transport. The concrete path must now prove those
|
||||||
|
implemented boundaries and expose any missing contracts instead of recreating
|
||||||
|
them inside Reporting or a producing domain module.
|
||||||
|
|
||||||
## Roadmap Waves
|
### Journey 5: Collaborative Document Lifecycle
|
||||||
|
|
||||||
|
An uploaded or generated artifact becomes a DMS document. Files continues to
|
||||||
|
own bytes; DMS owns identity, versions, renditions, editing sessions, locks,
|
||||||
|
comments, review, approval, comparison, and recovery; a collaboration connector
|
||||||
|
owns provider-specific protocol behavior; Records owns later classification,
|
||||||
|
hold, archive, and disposal.
|
||||||
|
|
||||||
|
The gate requires no silent lost updates, short-lived and currently authorized
|
||||||
|
editing sessions, idempotent authenticated callbacks, visible uncertain saves,
|
||||||
|
immutable accepted renditions, and a Records-ready handoff with stable content
|
||||||
|
and provenance.
|
||||||
|
|
||||||
|
## Capability Dependency Waves
|
||||||
|
|
||||||
|
The waves below remain a dependency and ownership catalogue for the wider
|
||||||
|
product vision. They are not the active delivery order. The five selected
|
||||||
|
journeys above and the meta roadmap decide what is implemented now; other
|
||||||
|
clusters remain dormant until a selected journey consumes them or they are
|
||||||
|
explicitly reprioritized.
|
||||||
|
|
||||||
### Wave 0: Platform Spine
|
### Wave 0: Platform Spine
|
||||||
|
|
||||||
@@ -248,9 +302,13 @@ Refine:
|
|||||||
|
|
||||||
- `govoplan-core`: module discovery, capabilities, events, migrations, release
|
- `govoplan-core`: module discovery, capabilities, events, migrations, release
|
||||||
catalog, configuration package runtime, WebUI shell.
|
catalog, configuration package runtime, WebUI shell.
|
||||||
- `govoplan-access`: identities, sessions, API keys, users, groups, roles,
|
- `govoplan-identity`: canonical identities and account links.
|
||||||
memberships, function assignments, delegation, RBAC decisions.
|
- `govoplan-organizations`: organizational structures, units, and functions.
|
||||||
- `govoplan-tenancy`: tenant and organizational-unit boundaries.
|
- `govoplan-idm`: identity-to-function assignments, directory synchronization,
|
||||||
|
preview, conflicts, and reconciliation.
|
||||||
|
- `govoplan-access`: sessions, API keys, users, groups, roles, memberships,
|
||||||
|
function-to-role projection, delegation, acting context, and RBAC decisions.
|
||||||
|
- `govoplan-tenancy`: tenant lifecycle and tenant boundaries.
|
||||||
- `govoplan-identity-trust`: initial trust contracts for device keys, public key
|
- `govoplan-identity-trust`: initial trust contracts for device keys, public key
|
||||||
directory, assurance, and later encrypted postbox key access.
|
directory, assurance, and later encrypted postbox key access.
|
||||||
- `govoplan-policy`: policy sources, policy decisions, retention inputs.
|
- `govoplan-policy`: policy sources, policy decisions, retention inputs.
|
||||||
@@ -291,8 +349,9 @@ Create or refine in this order:
|
|||||||
access.
|
access.
|
||||||
4. `govoplan-cases`: case record, status, assignments, deadlines, and case
|
4. `govoplan-cases`: case record, status, assignments, deadlines, and case
|
||||||
evidence.
|
evidence.
|
||||||
5. `govoplan-workflow`: state machine, transitions, commands, and module
|
5. `govoplan-workflow-engine`: state machine, transitions, commands, module
|
||||||
handoff.
|
handoff, and resumable execution; optional `govoplan-workflow` supplies the
|
||||||
|
editor.
|
||||||
6. `govoplan-tasks`: work queues, assignments, due dates, and follow-ups.
|
6. `govoplan-tasks`: work queues, assignments, due dates, and follow-ups.
|
||||||
7. `govoplan-templates`: permit/decision document generation.
|
7. `govoplan-templates`: permit/decision document generation.
|
||||||
8. `govoplan-postbox`, `govoplan-mail`, `govoplan-notifications`: applicant and
|
8. `govoplan-postbox`, `govoplan-mail`, `govoplan-notifications`: applicant and
|
||||||
@@ -367,7 +426,7 @@ Goal: cover internal support and public issue reporting.
|
|||||||
|
|
||||||
Create or refine in this order:
|
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.
|
location, evidence, and triage.
|
||||||
2. `govoplan-helpdesk`: service desk tickets, queues, SLAs, assignments,
|
2. `govoplan-helpdesk`: service desk tickets, queues, SLAs, assignments,
|
||||||
escalation, and resolution evidence.
|
escalation, and resolution evidence.
|
||||||
@@ -481,31 +540,34 @@ Refine:
|
|||||||
dashboard data.
|
dashboard data.
|
||||||
- `govoplan-search`: permissioned cross-module discovery.
|
- `govoplan-search`: permissioned cross-module discovery.
|
||||||
|
|
||||||
Create only when justified:
|
Refine the existing owners:
|
||||||
|
|
||||||
- `govoplan-datasources`: source catalog, connection profiles, schema discovery,
|
- `govoplan-datasources`: governed data/register catalog, live/cached/static
|
||||||
freshness, provenance.
|
sources, staging, immutable materializations, freshness, quality, legal and
|
||||||
- `govoplan-dataflow`: transformations, validation, lineage, scheduled runs,
|
organizational context, and provenance. Connector profiles and credentials
|
||||||
publication outputs.
|
remain in Connectors.
|
||||||
- `govoplan-projects`: only if OpenProject connectors and existing cases/tasks
|
- `govoplan-dataflow`: typed transformations, validation, lineage, manual,
|
||||||
cannot cover the required semantics.
|
scheduled and event-triggered runs, reusable definitions, and publication
|
||||||
|
outputs.
|
||||||
|
- `govoplan-projects`: native projects, portfolios, milestones, goals,
|
||||||
|
dependencies, capacity, outcomes, and external OpenProject references;
|
||||||
|
Connectors owns OpenProject transport and synchronization.
|
||||||
|
|
||||||
Reference journey: monthly data extraction, transformation, validation, approval,
|
Reference journey: monthly data extraction, transformation, validation, approval,
|
||||||
publication, and reporting.
|
publication, and reporting.
|
||||||
|
|
||||||
Recurring extraction/transformation should start as a configuration package
|
Recurring extraction/transformation should be delivered as a configuration
|
||||||
across connectors, files, workflow, reporting, and templates. The package should
|
package across Connectors, Datasources, Dataflow, Workflow Engine, Reporting,
|
||||||
register sources, declare schemas, define mapping/validation versions, schedule
|
Files, and Templates. The package should register sources, declare schemas,
|
||||||
runs, produce previewable diffs, write governed outputs, and preserve lineage,
|
define mapping/validation versions, schedule runs, produce previewable diffs,
|
||||||
hashes, operator actions, and audit evidence. Create `govoplan-datasources` or
|
write governed outputs, and preserve lineage, hashes, operator actions, and
|
||||||
`govoplan-dataflow` only after this work exposes repeated contracts that do not
|
audit evidence.
|
||||||
belong to existing modules.
|
|
||||||
|
|
||||||
Exit criteria:
|
Exit criteria:
|
||||||
|
|
||||||
- connector catalog exists before building many adapters
|
- connector catalog exists before building many adapters
|
||||||
- dataflow is created only after recurring transformation becomes product
|
- datasource and dataflow ownership remains provider-neutral and is proved by
|
||||||
behavior
|
the recurring transformation package
|
||||||
- reporting consumes governed sources with provenance
|
- reporting consumes governed sources with provenance
|
||||||
|
|
||||||
## Implementation Gates
|
## Implementation Gates
|
||||||
@@ -530,25 +592,41 @@ Before a module becomes release-included, it needs:
|
|||||||
- smoke test or permutation test
|
- smoke test or permutation test
|
||||||
- no required imports from optional modules
|
- no required imports from optional modules
|
||||||
|
|
||||||
## Priority Order Summary
|
## Technical Dependency Order Summary
|
||||||
|
|
||||||
1. Stabilize the platform spine.
|
Use this active order while respecting the ownership and implementation gates
|
||||||
2. Deliver permit-to-payment MVP.
|
in the capability waves:
|
||||||
3. Build booking and resource operations.
|
|
||||||
4. Add learning and certificates.
|
1. Keep the platform/release spine green and extend connector, identity,
|
||||||
5. Add issue reporting and helpdesk.
|
external-effect, provenance, documentation, focused-view, recovery, and
|
||||||
6. Add records, DMS, search, and transparency.
|
version contracts only as the current journey requires.
|
||||||
7. Add procurement, contracts, grants, and finance handoff.
|
2. Complete and package Campaign with Mail-owned profiles, Files, target
|
||||||
8. Add committee and consultation workflows.
|
delivery/recovery, central UI, and adaptive documentation.
|
||||||
9. Expand integration, dataflow, reporting, and operations.
|
3. Implement function-bound Postbox delivery through
|
||||||
|
Organizations–Identity–IDM–Access and consume it from Campaign through a
|
||||||
|
typed capability.
|
||||||
|
4. Implement one data-backed Templates/Reporting path and safe HIS-style deep
|
||||||
|
launch.
|
||||||
|
5. Extend that concrete source into one governed university analytical data
|
||||||
|
product and use it to harden the existing Datasources/Dataflow ownership,
|
||||||
|
quality, lineage, and promotion contracts.
|
||||||
|
6. Implement Files-backed DMS versions and one provider-neutral collaborative
|
||||||
|
editing lifecycle, then connect Records handoff.
|
||||||
|
7. Maintain already integrated Calendar/Scheduling/Poll and other foundations;
|
||||||
|
activate another capability cluster only when the current journey needs it
|
||||||
|
or the product roadmap explicitly reprioritizes it.
|
||||||
|
8. Extend Workflow Engine and the optional editor only through stable actions
|
||||||
|
and one demonstrated package at a time.
|
||||||
|
|
||||||
## Deliberate Deferrals
|
## Deliberate Deferrals
|
||||||
|
|
||||||
Defer these until a reference journey proves the need:
|
Defer these until a reference journey proves the need:
|
||||||
|
|
||||||
- full ERP replacement
|
- full ERP replacement
|
||||||
- native project management beyond connector support
|
- unsupported breadth in native project management before the Projects/OpenProject
|
||||||
- broad BI/dataflow platform
|
boundary is proved in a reference journey
|
||||||
|
- unbounded Dataflow operators or execution engines without golden-flow,
|
||||||
|
quality, lineage, resource-limit, and recovery evidence
|
||||||
- every possible public-sector protocol adapter
|
- every possible public-sector protocol adapter
|
||||||
- rich LMS behavior beyond training administration
|
- rich LMS behavior beyond training administration
|
||||||
- full qualified digital signing/trust services beyond the identity-trust and
|
- full qualified digital signing/trust services beyond the identity-trust and
|
||||||
@@ -566,73 +644,69 @@ repositories or to explicit missing-module decisions.
|
|||||||
|
|
||||||
| Idea | Owner | Tracking |
|
| Idea | Owner | Tracking |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Government operations backbone reference model | `govoplan-core` | `add-ideas/govoplan-core#213` |
|
| Government operations backbone reference model | `govoplan-core` | `GovOPlaN/govoplan-core#213` |
|
||||||
| Permit-to-payment configuration package | `govoplan-core` plus participating modules | `add-ideas/govoplan-core#214` |
|
| 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` | `add-ideas/govoplan-core#218` |
|
| 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` | `add-ideas/govoplan-access#7` |
|
| Access as a module | `govoplan-access` | `GovOPlaN/govoplan-access#7` |
|
||||||
| Interface ethics and decision-consequence doctrine | `govoplan-core` plus all UI-owning modules | `add-ideas/govoplan-core#227` |
|
| 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` | `add-ideas/govoplan-workflow#1` |
|
| Action/effect automation layer | `govoplan-workflow-engine`; possible future `govoplan-automation` only after boundary proof | `GovOPlaN/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` |
|
| 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` | `add-ideas/govoplan-access#9` |
|
| Identity, account, function, role, right semantic model | `govoplan-access` | `GovOPlaN/govoplan-access#9` |
|
||||||
| Role-based service directory/catalog | `govoplan-portal` | `add-ideas/govoplan-portal#1` |
|
| Role-based service directory presentation | `govoplan-portal`; reusable service definition is a shared contract and candidate `govoplan-services` | `GovOPlaN/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` |
|
| 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` | `add-ideas/govoplan-connectors#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 | `add-ideas/govoplan-core#196`, `add-ideas/govoplan-connectors#1` |
|
| Native project-management module | `govoplan-projects`; OpenProject transport remains connector-owned | `GovOPlaN/govoplan-projects#1`, `GovOPlaN/govoplan-connectors#12` |
|
||||||
| 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` |
|
| Datasources for databases, CSV, files, APIs | `govoplan-datasources`, with external protocol and credential ownership in Connectors | `GovOPlaN/govoplan-datasources#1` |
|
||||||
| 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` |
|
| Dataflow for pipelines, BI, publication | `govoplan-dataflow` over Datasources and module-owned providers | `GovOPlaN/govoplan-dataflow#1` |
|
||||||
| Monthly datasource and transformation workflows | first as configuration package across connectors, files, workflow, reporting, and templates | `add-ideas/govoplan-core#216` |
|
| Monthly datasource and transformation workflows | configuration package across Connectors, Datasources, Dataflow, Workflow Engine, Reporting, Files, and Templates | `GovOPlaN/govoplan#8`, `GovOPlaN/govoplan-dataflow#17` |
|
||||||
| Templates for letters, emails, forms, reports | `govoplan-templates`, separate from reporting | `add-ideas/govoplan-core#190`, `add-ideas/govoplan-templates#1` |
|
| 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 | `add-ideas/govoplan-core#190`, `add-ideas/govoplan-reporting#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` | `add-ideas/govoplan-files#15` |
|
| File connectors: Nextcloud, Seafile, SMB, NFS | `govoplan-files` | `GovOPlaN/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 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 | `add-ideas/govoplan-core#215` |
|
| Public-sector integration landscape catalogue | `govoplan-connectors` with core tracking | `GovOPlaN/govoplan-core#215` |
|
||||||
| Cases module concept | `govoplan-cases` | `add-ideas/govoplan-core#174` |
|
| Cases module concept | `govoplan-cases` | `GovOPlaN/govoplan-core#174` |
|
||||||
| Workflow module concept | `govoplan-workflow` | `add-ideas/govoplan-core#175` |
|
| Workflow runtime/editor split | `govoplan-workflow-engine` runtime plus optional `govoplan-workflow` editor | `GovOPlaN/govoplan-workflow#12`, `GovOPlaN/govoplan-workflow#13` |
|
||||||
| Connectors module concept | `govoplan-connectors` | `add-ideas/govoplan-core#176` |
|
| Connectors module concept | `govoplan-connectors` | `GovOPlaN/govoplan-core#176` |
|
||||||
| Adrema-style address and distribution-list management | `govoplan-addresses` | `add-ideas/govoplan-addresses#1` |
|
| 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` | `add-ideas/govoplan-connectors#3`, `add-ideas/govoplan-core#198` |
|
| Consume sources and become a governed source | Connectors acquires, Datasources governs/materializes, Dataflow transforms/publishes | `GovOPlaN/govoplan-connectors#3`, `GovOPlaN/govoplan-datasources#3` |
|
||||||
| Governed connector configuration, dry-run, and simulation runtime | `govoplan-connectors` | `add-ideas/govoplan-connectors#6` |
|
| 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 | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-scheduling#1` |
|
| 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` | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-calendar#1` |
|
| Terminplaner and calendar primitives | `govoplan-calendar` | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-calendar#1` |
|
||||||
| Terminbuchung appointment booking | `govoplan-appointments` | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-appointments#1` |
|
| Terminbuchung appointment booking | `govoplan-appointments` | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-appointments#1` |
|
||||||
| Collaborative documents | `govoplan-dms` | `add-ideas/govoplan-dms#1` |
|
| Collaborative documents | `govoplan-dms` | `GovOPlaN/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` |
|
| 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` | `add-ideas/govoplan-connectors#4` |
|
| RSS consume and emit | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#4` |
|
||||||
| LDAP, Active Directory, OpenDesk identity | `govoplan-idm` | `add-ideas/govoplan-idm#1` |
|
| 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 | `add-ideas/govoplan-core#195`, `add-ideas/govoplan-connectors#5` |
|
| 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` | `add-ideas/govoplan-mail#5` |
|
| Open-Xchange mail/groupware | `govoplan-mail` | `GovOPlaN/govoplan-mail#5` |
|
||||||
| Open-Xchange calendar | `govoplan-calendar` | `add-ideas/govoplan-calendar#2` |
|
| Open-Xchange calendar | `govoplan-calendar` | `GovOPlaN/govoplan-calendar#2` |
|
||||||
| Scalability profiles and autoscaling readiness | `govoplan-ops`, `govoplan-core` | `add-ideas/govoplan-core#217` |
|
| Scalability profiles and autoscaling readiness | `govoplan-ops`, `govoplan-core` | `GovOPlaN/govoplan-core#217` |
|
||||||
| Hardware sizing matrix and requirements calculator | `govoplan-ops`, `govoplan-core` | `add-ideas/govoplan-core#219` |
|
| 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` | `add-ideas/govoplan-core#220` |
|
| 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` | `add-ideas/govoplan-core#19` |
|
| Install/runtime configuration contract | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#19` |
|
||||||
| Installer/deployment operator flow | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#26` |
|
| Installer/deployment operator flow | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#26` |
|
||||||
| Production-like deployment documentation | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#28` |
|
| Production-like deployment documentation | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#28` |
|
||||||
|
|
||||||
Boundary rationale lives in `MODULE_ARCHITECTURE.md`. Current decisions:
|
Boundary rationale lives in `MODULE_ARCHITECTURE.md`. Current decisions:
|
||||||
|
|
||||||
- templates and reporting are separate modules
|
- templates and reporting are separate modules
|
||||||
- RSS/source consume-publish starts in connectors; datasources/dataflow are not
|
- RSS/source consume-publish starts in Connectors; governed source identity and
|
||||||
repositories yet
|
snapshots belong to Datasources and transformations belong to Dataflow
|
||||||
- calendar, scheduling, and appointments are three separate modules
|
- calendar, scheduling, and appointments are three separate modules
|
||||||
- forms definitions and forms runtime are separate responsibilities
|
- forms definitions and forms runtime are separate responsibilities
|
||||||
- OpenDesk is an integration profile across modules, not a monolithic module
|
- OpenDesk is an integration profile across modules, not a monolithic module
|
||||||
- OpenProject is connector-first; no native projects module yet
|
- OpenProject transport is connector-owned; native portfolio/project semantics
|
||||||
|
belong to Projects
|
||||||
- public-sector integration strategy stays in core; executable catalogue work
|
- public-sector integration strategy stays in core; executable catalogue work
|
||||||
lives in connectors
|
lives in connectors
|
||||||
- encrypted postbox and identity-trust are strategic contracts, not mail-module
|
- encrypted postbox and identity-trust are strategic contracts, not mail-module
|
||||||
behavior
|
behavior
|
||||||
- automation starts as workflow-owned action/effect execution and may split into
|
- automation starts in Workflow Engine and may split into a dedicated module
|
||||||
a dedicated module only after the runner becomes broader than workflow
|
only after the runner becomes broader than workflow
|
||||||
|
- Mandates, Services, Parties, and Decisions begin as shared semantic contracts;
|
||||||
The following modules are intentionally not created yet:
|
create repositories only after independent persistence, lifecycle, security,
|
||||||
|
and multiple-consumer evidence passes the repository threshold in the
|
||||||
- `govoplan-datasources`
|
institutional governance target architecture
|
||||||
- `govoplan-dataflow`
|
|
||||||
- `govoplan-projects`
|
|
||||||
|
|
||||||
Create a repository only after a concrete implementation package proves that
|
|
||||||
existing connector, files, reporting, workflow, or task ownership is too narrow.
|
|
||||||
|
|
||||||
Core keeps the strategy index in
|
Core keeps the strategy index in
|
||||||
`PUBLIC_SECTOR_INTEGRATION_STRATEGY.md`: integration postures, default
|
`PUBLIC_SECTOR_INTEGRATION_STRATEGY.md`: integration postures, default
|
||||||
@@ -645,19 +719,24 @@ Release composition and tag-only repository handling are documented in
|
|||||||
|
|
||||||
## Next Practical Work
|
## Next Practical Work
|
||||||
|
|
||||||
The next planning step should create or update Gitea issues for Wave 0 and Wave
|
The active cross-product story is
|
||||||
1 only. Later waves should stay as roadmap context until the permit-to-payment
|
[`GovOPlaN/govoplan#14`](https://git.add-ideas.de/GovOPlaN/govoplan/issues/14).
|
||||||
MVP is demonstrable.
|
Module repositories own implementation issues; do not clone their state here.
|
||||||
|
|
||||||
Recommended immediate issue buckets:
|
Immediate issue buckets:
|
||||||
|
|
||||||
- platform spine hardening
|
- fail-closed connector destination pinning and private-network deployment
|
||||||
- configuration package preflight and rollback
|
control for every real transport
|
||||||
- forms-runtime MVP
|
- Mail-profile-only Campaign authoring/build/delivery and safe legacy failure
|
||||||
- portal submission MVP
|
- immediate audited secret deletion when a provider/profile is removed
|
||||||
- cases/workflow/tasks integration MVP
|
- Campaign central-component and role-safe UI acceptance
|
||||||
- template-generated decision document
|
- adaptive Campaign, Mail, and Files task/process/admin/operator/integration/
|
||||||
- postbox/notification handoff
|
security/acceptance documentation
|
||||||
- appointment/booking handoff
|
- target SMTP/IMAP, file-provider, queue/reconciliation, install/upgrade, and
|
||||||
- payment evidence handoff
|
restore proof for the pinned Campaign reference composition
|
||||||
- configured documentation for the reference process
|
|
||||||
|
Once that gate is demonstrable, activate the existing Postbox model, access,
|
||||||
|
API, inbox, and Campaign integration issues. Templates/Reporting, governed BI,
|
||||||
|
and DMS collaboration remain durable selected direction, but should be
|
||||||
|
decomposed only as the preceding stage stabilizes or a bounded independent
|
||||||
|
contract can be implemented without pre-deciding target-system choices.
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Institutional Context And Governed References
|
||||||
|
|
||||||
|
GovOPlaN consequential work must retain enough context to answer who acted,
|
||||||
|
for whom, through which function, under which mandate and jurisdiction, using
|
||||||
|
which rule and evidence versions, and with which requested and observed effect.
|
||||||
|
The shared contract lives in `govoplan_core.core.institutional`.
|
||||||
|
|
||||||
|
Core owns reference shapes and provider protocols only. It does not own shared
|
||||||
|
Mandate, Service, Party, Decision, evidence, or geography tables. Domain modules
|
||||||
|
own persistence and authorization; optional capabilities resolve the references.
|
||||||
|
|
||||||
|
## Envelope
|
||||||
|
|
||||||
|
`GovernedContextEnvelope` version 1 carries:
|
||||||
|
|
||||||
|
- a tenant and `TemporalRevision` with validity, recording, supersession, and
|
||||||
|
change reason;
|
||||||
|
- the real account or service account and represented account, function,
|
||||||
|
procedure party, assignment, delegation/power, and mandate;
|
||||||
|
- institution, organization unit, function, task, mandate, jurisdiction,
|
||||||
|
service, case, party, work item, workflow, approval, decision, and record
|
||||||
|
references;
|
||||||
|
- versioned legal bases and evidence references;
|
||||||
|
- information classification, purposes, retention/holds, minimization, and
|
||||||
|
disclosure state;
|
||||||
|
- external-source authority, maturity, freshness, health, and conflict state;
|
||||||
|
- language, accessibility, channel, explanation, and availability references.
|
||||||
|
|
||||||
|
Every institutional reference includes the owner module, tenant, stable object
|
||||||
|
identity, optional version/effective instant, and a protected display label.
|
||||||
|
Cross-tenant references are rejected. Safe serialization omits labels,
|
||||||
|
inspection URLs, formal reasoning, operative results, and conditions unless a
|
||||||
|
caller explicitly requests the protected projection.
|
||||||
|
|
||||||
|
## Semantic Providers
|
||||||
|
|
||||||
|
The first provider-neutral capabilities are:
|
||||||
|
|
||||||
|
- `mandates.resolver`: resolve competence for a task/authority type at an
|
||||||
|
effective instant and return the governing Mandate definition and evidence;
|
||||||
|
- `services.definitions`: obtain versioned institutional service definitions;
|
||||||
|
- `parties.resolver`: obtain effective procedure-local parties and powers of
|
||||||
|
representation without copying Identity or Organizations subjects; and
|
||||||
|
- `decisions.registry`: record and retrieve formal Decisions under optimistic
|
||||||
|
revision control.
|
||||||
|
|
||||||
|
The Mandate, Service, Party/representation, and Decision DTOs have strict
|
||||||
|
mapping round-trips so they can cross capability, event, package, and storage
|
||||||
|
boundaries without shared ORM models. Their lifecycle states are explicit:
|
||||||
|
Mandates distinguish draft/active/suspended/replaced/retired, Services retain
|
||||||
|
publication state, Parties retain effective representation and revocation, and
|
||||||
|
Decisions retain correction, revocation, and supersession references.
|
||||||
|
|
||||||
|
The DTOs are a repository threshold, not a mandate to create four modules.
|
||||||
|
Independent persistence, lifecycle, security/operations behavior, release
|
||||||
|
reason, reuse, and tests are still required before extraction.
|
||||||
|
|
||||||
|
Mandate resolution is deterministic: Core filters candidates by tenant,
|
||||||
|
effective interval, active state, task and authority type, stable
|
||||||
|
organization/function identity, jurisdiction coverage, and subject type. A
|
||||||
|
result is competent only when exactly one matching Mandate remains and it has
|
||||||
|
no unresolved conflicts. Evidence from matching definitions is deduplicated
|
||||||
|
and retained in the explanation result. `revise_mandate_definition` applies
|
||||||
|
optimistic concurrency and the allowed activation, suspension, replacement,
|
||||||
|
and retirement transitions while leaving the previous revision immutable.
|
||||||
|
|
||||||
|
`revise_formal_decision` provides the equivalent lifecycle primitive for
|
||||||
|
formal outcomes. Every accepted transition requires a new recorded revision
|
||||||
|
and change reason, links `supersedes_ref` to the prior version, updates the
|
||||||
|
authority envelope to the new version, and records explicit correction or
|
||||||
|
revocation provenance. Terminal and backward transitions fail closed. Each
|
||||||
|
Decision also records whether responsibility was human, human-reviewed
|
||||||
|
automation, or an automated service account acting under mandate. Automation
|
||||||
|
preparation/recommendation references remain inspectable without being
|
||||||
|
mistaken for the responsible outcome.
|
||||||
|
|
||||||
|
Procedure-party corrections use `revise_procedure_party`: the stable party
|
||||||
|
identity is retained, a new revision and reason are required, stale writes are
|
||||||
|
rejected, and revoked/expired/superseded assignments are terminal.
|
||||||
|
`revoke_party_representation` separately records when a limited power ceased
|
||||||
|
to authorize actions. This allows consuming procedures to evaluate historical
|
||||||
|
delivery or representation authority without rewriting Identity,
|
||||||
|
Organizations, or Addresses records.
|
||||||
|
|
||||||
|
Service templates and package/tenant specializations use
|
||||||
|
`derive_service_restriction`. The derived definition retains an explicit
|
||||||
|
parent-version reference, cannot extend the parent's validity, audience,
|
||||||
|
channels, or publication ceiling, and cannot remove inherited prerequisites,
|
||||||
|
required evidence, legal bases, or bindings. This is the fail-closed semantic
|
||||||
|
rule; configuration-package signature and provenance checks remain the package
|
||||||
|
transport rule.
|
||||||
|
|
||||||
|
`ServiceAvailabilityRequirement` represents module, capability, mandate,
|
||||||
|
policy, connector, maintenance, audience, and configuration prerequisites with
|
||||||
|
an explicit unavailable-or-hidden failure mode and explanation reference. The
|
||||||
|
optional `services.availability` evaluator returns policy-scoped boolean
|
||||||
|
assessments, reason codes, and evidence. Unknown consequential requirements
|
||||||
|
fail closed; a reference itself never grants access.
|
||||||
|
|
||||||
|
## Service Launch
|
||||||
|
|
||||||
|
`ServiceLaunchRequest` and `ServiceLaunchResult` define the owner-neutral
|
||||||
|
boundary between Portal entry and a case, form, or workflow runtime effect.
|
||||||
|
The request carries the exact published Service definition, exact selected
|
||||||
|
binding, tenant, acting identity, timezone-aware request time, bounded
|
||||||
|
parameters, and idempotency key. The result must retain that exact Service and
|
||||||
|
binding, a same-tenant target reference, optional same-tenant evidence, and
|
||||||
|
only a relative or credential-free HTTP(S) destination.
|
||||||
|
|
||||||
|
`service_launch_capability(kind)` maps bindings to owner capabilities:
|
||||||
|
|
||||||
|
- `case` -> `cases.service_launcher`
|
||||||
|
- `form` -> `forms_runtime.service_launcher`
|
||||||
|
- `workflow` -> `workflow_engine.service_launcher`
|
||||||
|
|
||||||
|
`FormDefinition`, `FormFieldDefinition`, and `forms.definitions` provide the
|
||||||
|
owner-neutral exact-schema boundary used by Forms Runtime. Definitions carry an
|
||||||
|
exact tenant/revision, field types/options/constraints/defaults, publication,
|
||||||
|
draft, attachment, signature, policy, and handoff requirements. Forms owns
|
||||||
|
those immutable definitions; Forms Runtime persists instances and validation
|
||||||
|
evidence. A form Service binding uses `<form-id>/<revision>` and the launcher
|
||||||
|
rejects missing, superseded, unpublished, cross-tenant, or invalid definitions.
|
||||||
|
|
||||||
|
Portal may discover and invoke those capabilities but cannot write owner
|
||||||
|
tables. The owner must revalidate its definition/binding and current
|
||||||
|
authorization, produce its normal audit/event state, and make replay after an
|
||||||
|
ambiguous response safe. If the capability is absent, the service is
|
||||||
|
explainably unavailable. URL-only entries pass through the same launch-time
|
||||||
|
availability check and destination validation. Forms Runtime now supplies the
|
||||||
|
definition-aware form launcher when both Forms and Forms Runtime are active;
|
||||||
|
otherwise Portal continues to fail closed.
|
||||||
|
|
||||||
|
## Propagation
|
||||||
|
|
||||||
|
`PlatformEvent`, `ActionExecutionRequest`, and `AuditEvent` can carry the
|
||||||
|
envelope. Audit persistence stores only its safe projection; platform-event
|
||||||
|
outbox serialization preserves it across asynchronous delivery. A module must
|
||||||
|
not invent a parallel context dictionary when the shared fields apply.
|
||||||
|
|
||||||
|
## First Proof
|
||||||
|
|
||||||
|
Committee's `committee.decision_path` capability is the first bounded proof. It
|
||||||
|
requires one effective, conflict-free Mandate covering the organization unit
|
||||||
|
function, and jurisdiction, an approval reference, fact evidence, versioned legal bases,
|
||||||
|
operative result, and reasoning. It emits a reconstructable `FormalDecision`,
|
||||||
|
including requested/observed effects and information governance. If a Decision
|
||||||
|
registry is installed it persists there; Committee does not take ownership of
|
||||||
|
the generic Decision lifecycle.
|
||||||
|
|
||||||
|
## Compatibility And Security
|
||||||
|
|
||||||
|
- Contract version changes follow Core compatibility policy.
|
||||||
|
- Unknown tenant or reference-kind combinations fail closed.
|
||||||
|
- Datetimes that affect authority must be timezone-aware.
|
||||||
|
- Protected labels, reasoning, evidence inspection links, and source details
|
||||||
|
remain subject to the owning module's access policy.
|
||||||
|
- References do not grant access to their targets.
|
||||||
|
- Evidence and audit payloads must contain stable references/checksums, not
|
||||||
|
plaintext secrets.
|
||||||
+308
-11
@@ -13,6 +13,10 @@ Policy decision, source provenance, and explain-response contracts are tracked
|
|||||||
in [`POLICY_CONTRACTS.md`](POLICY_CONTRACTS.md).
|
in [`POLICY_CONTRACTS.md`](POLICY_CONTRACTS.md).
|
||||||
The experimental remote WebUI bundle loading design is tracked in
|
The experimental remote WebUI bundle loading design is tracked in
|
||||||
[`REMOTE_WEBUI_BUNDLES.md`](REMOTE_WEBUI_BUNDLES.md).
|
[`REMOTE_WEBUI_BUNDLES.md`](REMOTE_WEBUI_BUNDLES.md).
|
||||||
|
The cross-product semantic layers, source-authority modes, and candidate
|
||||||
|
Mandates, Services, Parties, and Decisions boundaries are canonical in the
|
||||||
|
meta repository's
|
||||||
|
[`INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md`](../../govoplan/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
|
||||||
|
|
||||||
## Layer Model
|
## Layer Model
|
||||||
|
|
||||||
@@ -24,6 +28,36 @@ The experimental remote WebUI bundle loading design is tracked in
|
|||||||
| Business modules | Public-sector workflows | campaigns, cases, forms, approvals, appointments |
|
| Business modules | Public-sector workflows | campaigns, cases, forms, approvals, appointments |
|
||||||
| Connector modules | External system integration | FIT-Connect, XÖV/XTA, DMS/eAkte, ERP, IDM |
|
| Connector modules | External system integration | FIT-Connect, XÖV/XTA, DMS/eAkte, ERP, IDM |
|
||||||
|
|
||||||
|
This table is the technical composition model. The product portfolio uses a
|
||||||
|
more detailed institutional layer model, but it does not change dependency
|
||||||
|
direction: Core provides contracts and composition; modules own semantics;
|
||||||
|
packages compose modules.
|
||||||
|
|
||||||
|
## Institutional Semantic Boundaries
|
||||||
|
|
||||||
|
Cross-module references must keep these answers distinct:
|
||||||
|
|
||||||
|
- Organizations owns where structures, units, and functions exist.
|
||||||
|
- Identity owns who a subject is; Access owns accounts, roles, permissions,
|
||||||
|
and authorization decisions; IDM owns effective function assignments.
|
||||||
|
- A Mandates capability will answer why a unit or function is competent for a
|
||||||
|
task, jurisdiction, subject, or period. It must not become another RBAC
|
||||||
|
system.
|
||||||
|
- A Services capability will own versioned institutional service definitions;
|
||||||
|
Portal presents and starts them.
|
||||||
|
- A Parties capability will own procedure-local participant roles,
|
||||||
|
representation, and delivery authority; it must reference rather than copy
|
||||||
|
Identity, Organizations, and Addresses subjects.
|
||||||
|
- A Decisions capability will own formal institutional outcomes and their
|
||||||
|
authority, facts, rules, reasoning, effects, correction, and review.
|
||||||
|
Approvals owns review gates, Committee owns deliberation/votes, and Workflow
|
||||||
|
Engine owns coordination.
|
||||||
|
|
||||||
|
Start each missing concept as a versioned DTO/provider contract used by a
|
||||||
|
bounded journey. A repository is justified only when the concept gains
|
||||||
|
independent persistence, lifecycle, security/operations behavior, release
|
||||||
|
reason, and reuse. Core must not store these domain objects.
|
||||||
|
|
||||||
## Kernel Responsibilities
|
## Kernel Responsibilities
|
||||||
|
|
||||||
The kernel target owns:
|
The kernel target owns:
|
||||||
@@ -74,6 +108,10 @@ The compatibility/deprecation plan for the current split line is:
|
|||||||
- reject new cross-module imports that bypass manifests, capabilities, events,
|
- reject new cross-module imports that bypass manifests, capabilities, events,
|
||||||
or public module APIs
|
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
|
## Stable Kernel Contracts
|
||||||
|
|
||||||
The following contracts are the baseline API that modules can rely on:
|
The following contracts are the baseline API that modules can rely on:
|
||||||
@@ -87,20 +125,59 @@ The following contracts are the baseline API that modules can rely on:
|
|||||||
- capability factory contract
|
- capability factory contract
|
||||||
- access DTO/protocol contracts in `govoplan_core.core.access`
|
- access DTO/protocol contracts in `govoplan_core.core.access`
|
||||||
- resource ACL provider contract
|
- 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
|
- tenant delete-veto provider contract
|
||||||
- WebUI module contribution contract
|
- WebUI module contribution contract
|
||||||
- navigation metadata contract
|
- navigation metadata contract
|
||||||
- command/event envelope contract
|
- command/event envelope contract
|
||||||
- policy decision and source provenance contract in `govoplan_core.core.policy`
|
- policy decision and source provenance contract in `govoplan_core.core.policy`
|
||||||
|
- external object reference and integration-maturity contract in
|
||||||
|
`govoplan_core.core.external_references`
|
||||||
|
- action/effect preview and execution contract in
|
||||||
|
`govoplan_core.core.automation`
|
||||||
|
- workflow definition contribution and runtime-worker contracts
|
||||||
|
|
||||||
Changes to these contracts must be versioned or accompanied by compatibility shims.
|
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
|
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
|
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
|
remain source-compatible through the 0.1.x split line unless a migration shim
|
||||||
and deprecation note are provided.
|
and deprecation note are provided.
|
||||||
|
|
||||||
|
### Architecture Metadata
|
||||||
|
|
||||||
|
`ModuleManifest.architecture` is the backward-compatible, versioned product-
|
||||||
|
portfolio declaration for:
|
||||||
|
|
||||||
|
- module kind and institutional architecture layer;
|
||||||
|
- evidence-backed maturity claim (`concept`, `scaffold`, `vertical_slice`,
|
||||||
|
`reference_ready`, `supported`, or `lts`);
|
||||||
|
- owned and explicitly non-owned concepts;
|
||||||
|
- supported source-authority modes;
|
||||||
|
- reference packages, tested providers, and known limits;
|
||||||
|
- migration, upgrade, recovery, security, operations, and documentation
|
||||||
|
evidence references.
|
||||||
|
|
||||||
|
Core validates the claim and all provider references during registry startup.
|
||||||
|
`reference_ready`, `supported`, and `lts` claims require a named reference
|
||||||
|
package and the cumulative evidence set; target-tested providers additionally
|
||||||
|
require provider evidence. A supported module with migrations must include
|
||||||
|
migration evidence. Signed release catalogs retain and revalidate the
|
||||||
|
declaration. The meta manifest check validates repository evidence paths and
|
||||||
|
provides an opt-in `--require-architecture` rollout gate. Platform metadata,
|
||||||
|
Docs, and Ops project the same declaration. A module cannot make itself
|
||||||
|
supported solely by changing its maturity string.
|
||||||
|
|
||||||
Known access-related capability names are defined in
|
Known access-related capability names are defined in
|
||||||
`govoplan_core.core.access`, including:
|
`govoplan_core.core.access`, including:
|
||||||
|
|
||||||
@@ -126,6 +203,33 @@ Feature modules should prefer these capabilities over direct reads of
|
|||||||
access/tenant ORM models when they need labels, group membership, default
|
access/tenant ORM models when they need labels, group membership, default
|
||||||
access provisioning, counts, audit actor labels, or tenant metadata.
|
access provisioning, counts, audit actor labels, or tenant metadata.
|
||||||
|
|
||||||
|
Other stable runtime capabilities currently include:
|
||||||
|
|
||||||
|
- `identity.directory` and `identity.search`
|
||||||
|
- `organizations.directory`
|
||||||
|
- `idm.directory`, `idm.function_assignments`, `idm.relationships`, and
|
||||||
|
`idm.assignment_lifecycle`
|
||||||
|
- `calendar.outbox`, `calendar.scheduling`, `calendar.invitations`, and
|
||||||
|
`calendar.externalProfiles`
|
||||||
|
- `poll.scheduling`
|
||||||
|
- `notifications.dispatch`
|
||||||
|
- `workflow.definitionContributions` and `workflow.runtimeWorker`
|
||||||
|
|
||||||
|
The provider-neutral `idm.relationships` contract carries tenant-scoped typed
|
||||||
|
groups, effective-dated identity relationships, and explicit membership
|
||||||
|
decisions. It deliberately does not expose IDM persistence models or imply an
|
||||||
|
Access permission. Consumers can retain source revisions and inclusion or
|
||||||
|
exclusion provenance while remaining optional-module safe.
|
||||||
|
|
||||||
|
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
|
### Named Interface Contracts
|
||||||
|
|
||||||
Capabilities are runtime objects. Named interface contracts are compatibility
|
Capabilities are runtime objects. Named interface contracts are compatibility
|
||||||
@@ -147,15 +251,66 @@ intended for SemVer major-version lines. Missing optional interfaces are
|
|||||||
allowed, but an installed provider with an incompatible version blocks
|
allowed, but an installed provider with an incompatible version blocks
|
||||||
activation because the integration would otherwise bind to an unsafe API.
|
activation because the integration would otherwise bind to an unsafe API.
|
||||||
|
|
||||||
Current named interfaces:
|
### Source Authority And Provider Operations
|
||||||
|
|
||||||
- `files.campaign_attachments`
|
Integration maturity and configured authority are independent. The existing
|
||||||
|
external-reference maturity ladder describes whether an adapter can discover,
|
||||||
|
link, search, read, publish, synchronize, migrate, or replace. A binding must
|
||||||
|
also state whether GovOPlaN is native authoritative, the external system is
|
||||||
|
authoritative, GovOPlaN keeps a mirror, both sides use governed sync, GovOPlaN
|
||||||
|
adds only a governance overlay, or the object is link-only.
|
||||||
|
|
||||||
|
`ModuleManifest.external_providers` composes existing contracts rather than
|
||||||
|
replacing them. Each declaration describes owned object/field groups, authority modes,
|
||||||
|
operations, revisions, freshness, health, limits, idempotency, conflicts,
|
||||||
|
outcome-unknown handling, evidence, correction/compensation, reconciliation,
|
||||||
|
outage behavior, classification, purpose, retention, and secret requirements.
|
||||||
|
Core owns the typed declaration and validation. Connectors and domain modules
|
||||||
|
own the actual protocol and domain behavior; configuration packages select the
|
||||||
|
effective mode and block missing/incompatible/unhealthy providers; Docs and Ops
|
||||||
|
explain the result. Effect-capable declarations fail validation unless their
|
||||||
|
retry, concurrency, outcome-unknown, correction, reconciliation, evidence,
|
||||||
|
audit, timeout, outage, classification, purpose, retention, and secret behavior
|
||||||
|
is explicit.
|
||||||
|
|
||||||
|
Declarations are release-time capability claims. Configured state is projected
|
||||||
|
separately through `ModuleManifest.external_provider_state_providers`. A state
|
||||||
|
provider receives a bounded tenant context and returns one sanitized observation
|
||||||
|
per configured binding: stable binding reference, effective authority mode,
|
||||||
|
active/configured state, health, freshness, conflict, recovery readiness,
|
||||||
|
observation/last-success time, and scalar metrics. Core validates and aggregates
|
||||||
|
those observations, isolates provider failures, and never accepts URLs,
|
||||||
|
credentials, or arbitrary nested metadata as runtime-state fields. Docs removes
|
||||||
|
binding-level detail from ordinary-user projections; Ops may show the full
|
||||||
|
sanitized operator projection.
|
||||||
|
|
||||||
|
Configuration-package preflight selects the exact requested binding from this
|
||||||
|
runtime state before evaluating authority, health, freshness, and recovery. A
|
||||||
|
healthy sibling binding therefore cannot mask an unhealthy required binding.
|
||||||
|
Providers with multiple configurations must use non-secret, stable references
|
||||||
|
such as `calendar:sync-source:<id>`.
|
||||||
|
|
||||||
|
Current named interfaces, generated from the source manifests by the workspace
|
||||||
|
contract checks, are:
|
||||||
|
|
||||||
|
- `addresses.contact_point_resolution`, `addresses.contact_writer`,
|
||||||
|
`addresses.lookup`, `addresses.recipient_source`
|
||||||
|
- `calendar.external_profiles`, `calendar.invitations`, `calendar.outbox`,
|
||||||
|
`calendar.scheduling`
|
||||||
|
- `campaigns.access`, `campaigns.delivery_tasks`,
|
||||||
|
`campaigns.mail_policy_context`, `campaigns.policy_context`,
|
||||||
|
`campaigns.retention`
|
||||||
|
- `dist_lists.expand`, `dist_lists.source`, `dist_lists.writer`
|
||||||
|
- `evaluation.feedback`, `evaluation.result_aggregation`, `evaluation.scoring`
|
||||||
|
- `files.access`, `files.campaign_attachments`
|
||||||
- `mail.campaign_delivery`
|
- `mail.campaign_delivery`
|
||||||
- `campaigns.access`
|
- `notifications.dispatch`
|
||||||
- `campaigns.delivery_tasks`
|
- `poll.availability_matrix`, `poll.option_selection`,
|
||||||
- `campaigns.mail_policy_context`
|
`poll.response_collection`, `poll.signed_participation`,
|
||||||
- `campaigns.policy_context`
|
`poll.workflow_context`
|
||||||
- `campaigns.retention`
|
- `rest.function_publication`
|
||||||
|
- `scheduling.candidate_slots`, `scheduling.decision_handoff`
|
||||||
|
- `soap.operation_publication`
|
||||||
|
|
||||||
Core validates named interface contracts in three places:
|
Core validates named interface contracts in three places:
|
||||||
|
|
||||||
@@ -244,6 +399,23 @@ unsafe methods.
|
|||||||
This avoids retransmitting unchanged snapshots. It does not identify which row
|
This avoids retransmitting unchanged snapshots. It does not identify which row
|
||||||
changed inside a collection.
|
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
|
### Delta Collections
|
||||||
|
|
||||||
Collection endpoints that can expose row-level changes should use the shared
|
Collection endpoints that can expose row-level changes should use the shared
|
||||||
@@ -314,6 +486,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
|
until such a floor exists, even if unrelated collections have advanced the
|
||||||
global sequence.
|
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
|
### Cursor/Keyset Pages
|
||||||
|
|
||||||
Offset pagination remains supported for compatibility and for first page loads,
|
Offset pagination remains supported for compatibility and for first page loads,
|
||||||
@@ -434,6 +631,18 @@ The manifest should declare:
|
|||||||
- navigation metadata using serializable icon names
|
- navigation metadata using serializable icon names
|
||||||
- uninstall guard providers for data, migration, worker, or scheduler vetoes
|
- 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:
|
Backend nav metadata must use icon-name strings, not frontend components:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -496,6 +705,39 @@ Rules:
|
|||||||
one migration run, and do not switch a database between tracks unless it is a
|
one migration run, and do not switch a database between tracks unless it is a
|
||||||
disposable development database.
|
disposable development database.
|
||||||
|
|
||||||
|
### Shared State And Runtime Ordering
|
||||||
|
|
||||||
|
Multi-host application roles use the `shared` state profile. In that profile,
|
||||||
|
PostgreSQL, Redis, a stable installation identifier, and S3-compatible object
|
||||||
|
storage are mandatory. Module durable artifacts must use Core's object-storage
|
||||||
|
contract and module-owned opaque key namespaces; node-local paths are limited
|
||||||
|
to temporary materialization. Same-host replicas may use the `host-shared`
|
||||||
|
profile and one shared volume.
|
||||||
|
|
||||||
|
Only the migration command mutates schema. PostgreSQL migration runs acquire a
|
||||||
|
deployment-wide advisory lock before module pre-tasks, Alembic, and post-tasks.
|
||||||
|
API, worker, and scheduler roles wait for exact configured migration heads and
|
||||||
|
fail closed instead of applying migrations during startup.
|
||||||
|
|
||||||
|
Runtime roles register identity, software/module composition, queues, heartbeat,
|
||||||
|
and drain state in PostgreSQL. Singleton work must use a distributed lease and
|
||||||
|
validate its monotonically increasing fencing token at the consequential
|
||||||
|
commit. See `STATE_AND_RECOVERY_CONTRACT.md` for the complete contract.
|
||||||
|
|
||||||
|
### Recovery Evidence
|
||||||
|
|
||||||
|
Operations spanning transactions, object storage, queues, or external systems
|
||||||
|
must choose an explicit Core recovery mode: atomic, compensation,
|
||||||
|
snapshot-restore, forward-recovery, or irreversible. Plans require verification
|
||||||
|
steps and mode-specific recovery material. Use idempotency keys, append-only
|
||||||
|
evidence checkpoints, and a runtime fence where work may race across nodes.
|
||||||
|
|
||||||
|
The recovery ledger is a shared primitive, not automatic coverage. A module may
|
||||||
|
claim its guarantees only after its operation records preconditions before side
|
||||||
|
effects, transitions partial/unknown outcomes honestly, and records verified
|
||||||
|
completion or recovery. Plaintext secrets must never enter recovery metadata or
|
||||||
|
evidence.
|
||||||
|
|
||||||
## Install, Uninstall, And Catalogs
|
## Install, Uninstall, And Catalogs
|
||||||
|
|
||||||
Core owns the install plan, signed catalog validation, license entitlement
|
Core owns the install plan, signed catalog validation, license entitlement
|
||||||
@@ -554,11 +796,18 @@ Uninstall remains non-destructive unless the operator explicitly requests
|
|||||||
|
|
||||||
## WebUI Contract
|
## 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:
|
Example:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
const FilesPage = lazy(() => import("./features/files/FilesPage"));
|
||||||
|
|
||||||
export const filesModule: PlatformWebModule = {
|
export const filesModule: PlatformWebModule = {
|
||||||
id: "files",
|
id: "files",
|
||||||
label: "Files",
|
label: "Files",
|
||||||
@@ -573,12 +822,21 @@ 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:
|
WebUI modules receive only the core route context:
|
||||||
|
|
||||||
- `settings`
|
- `settings`
|
||||||
- `auth`
|
- `auth`
|
||||||
|
|
||||||
A module should call its own API client and module-owned backend routes. Shared API helpers should live in core only when they are truly platform-level concerns.
|
A module should call its own API client and module-owned backend routes. Shared API helpers should live in core only when they are truly platform-level concerns.
|
||||||
|
For ordinary JSON mutations, use Core's `apiPostJson` and `apiPatchJson`
|
||||||
|
helpers. They preserve the shared authentication, CSRF, error, and request
|
||||||
|
invalidation behavior while leaving endpoint types and feature semantics in the
|
||||||
|
owning module.
|
||||||
|
|
||||||
Modules can also contribute named UI capabilities for explicit extension
|
Modules can also contribute named UI capabilities for explicit extension
|
||||||
points. Capability values must be narrow, typed contracts, not imports from a
|
points. Capability values must be narrow, typed contracts, not imports from a
|
||||||
@@ -761,6 +1019,16 @@ Decision: templates and reporting are separate modules.
|
|||||||
and export targets
|
and export targets
|
||||||
- report permissions, report execution history, generated report evidence, and
|
- report permissions, report execution history, generated report evidence, and
|
||||||
report-specific retention inputs
|
report-specific retention inputs
|
||||||
|
|
||||||
|
Cross-module reports use Core's versioned
|
||||||
|
`reporting.report_provider.<provider-id>` contract. Source modules own
|
||||||
|
authorization, parameters, source revisions, effective scope, result schema,
|
||||||
|
and privacy transforms; Reporting owns discovery, validation, governed
|
||||||
|
execution, provenance, export history, and the global `/reports` route. The
|
||||||
|
optional `policy.reporting_governance` capability can only tighten execution,
|
||||||
|
retention, export, and re-identification-risk handling. Reporting exposes
|
||||||
|
`reporting.retention` so the Policy-owned retention run can minimize expired
|
||||||
|
provider results without importing Reporting models.
|
||||||
- downstream export handoff to files, dataflow, connectors, or publication
|
- downstream export handoff to files, dataflow, connectors, or publication
|
||||||
surfaces
|
surfaces
|
||||||
|
|
||||||
@@ -790,7 +1058,7 @@ First slice:
|
|||||||
- `govoplan-files` owns file-backed governed locations and uploaded/stored file
|
- `govoplan-files` owns file-backed governed locations and uploaded/stored file
|
||||||
evidence.
|
evidence.
|
||||||
- `govoplan-reporting` owns report/data views and scheduled outputs.
|
- `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.
|
steps, and human review.
|
||||||
|
|
||||||
Future `govoplan-datasources` is justified when GovOPlaN needs a broad source
|
Future `govoplan-datasources` is justified when GovOPlaN needs a broad source
|
||||||
@@ -853,7 +1121,7 @@ from workflow semantics.
|
|||||||
- form definitions, schemas, validation rules, field visibility rules,
|
- form definitions, schemas, validation rules, field visibility rules,
|
||||||
localization, versioning, admin editing, and reusable form package fragments
|
localization, versioning, admin editing, and reusable form package fragments
|
||||||
|
|
||||||
`govoplan-forms-runtime` owns, when implemented:
|
`govoplan-forms-runtime` owns:
|
||||||
|
|
||||||
- public/internal submissions, drafts, submitted values, validation evidence,
|
- public/internal submissions, drafts, submitted values, validation evidence,
|
||||||
attachment references, submission receipts, and handoff events
|
attachment references, submission receipts, and handoff events
|
||||||
@@ -866,6 +1134,16 @@ Boundary:
|
|||||||
- Reporting/dataflow may consume submitted data through governed DTOs or
|
- Reporting/dataflow may consume submitted data through governed DTOs or
|
||||||
source lifecycle contracts.
|
source lifecycle contracts.
|
||||||
|
|
||||||
|
Implemented contract:
|
||||||
|
|
||||||
|
- Core owns the provider-neutral `FormDefinition`/`FormFieldDefinition` DTOs.
|
||||||
|
- Forms persists immutable exact definitions and provides `forms.definitions`.
|
||||||
|
- Forms Runtime resolves that capability, persists revisioned instances and
|
||||||
|
events, validates draft/final values, and provides
|
||||||
|
`forms_runtime.service_launcher`.
|
||||||
|
- Portal delegates exact `<form-id>/<revision>` bindings and never writes either
|
||||||
|
owner's tables.
|
||||||
|
|
||||||
### OpenDesk Integration Profile
|
### OpenDesk Integration Profile
|
||||||
|
|
||||||
Tracking: `govoplan-core#195`, `govoplan-connectors#5`,
|
Tracking: `govoplan-core#195`, `govoplan-connectors#5`,
|
||||||
@@ -1110,6 +1388,9 @@ the same restart/health set after restoring package and database snapshots.
|
|||||||
The installer preflight is intentionally conservative:
|
The installer preflight is intentionally conservative:
|
||||||
|
|
||||||
- maintenance mode must be active;
|
- maintenance mode must be active;
|
||||||
|
- the `shared` state profile blocks in-place package mutation; clustered
|
||||||
|
installations must roll one verified immutable module composition across all
|
||||||
|
replicas;
|
||||||
- installed module manifests must be compatible with the supported manifest
|
- installed module manifests must be compatible with the supported manifest
|
||||||
contract and current core version;
|
contract and current core version;
|
||||||
- uninstalling `tenancy`, `access`, or `admin` is blocked;
|
- uninstalling `tenancy`, `access`, or `admin` is blocked;
|
||||||
@@ -1210,6 +1491,22 @@ The first implementation is a platform access gate. It does not replace
|
|||||||
database backups, process supervision, migration checks, or external load
|
database backups, process supervision, migration checks, or external load
|
||||||
balancer maintenance pages.
|
balancer maintenance pages.
|
||||||
|
|
||||||
|
## Connector Runtime Contract
|
||||||
|
|
||||||
|
Core defines provider-neutral connector preview and diagnostic primitives in
|
||||||
|
`govoplan_core.core.connector_runtime`. The contract keeps optional modules
|
||||||
|
decoupled: Connectors owns transport, endpoint discovery, retries, and protocol
|
||||||
|
health; the consuming domain module owns mappings, validation, reconciliation,
|
||||||
|
and mutations of its records.
|
||||||
|
|
||||||
|
Every dry run is bounded and identifies the source revision, source fingerprint,
|
||||||
|
immutable input hash, effects, and redacted diagnostics. Its summary must match
|
||||||
|
the returned effect list exactly. An apply token is usable only when the preview
|
||||||
|
is complete, current, conflict-free, and contains no error diagnostic. Endpoint
|
||||||
|
URLs never contain credentials; only credential-envelope references cross the
|
||||||
|
contract. Provider-specific details belong in sanitized provenance rather than
|
||||||
|
in a shared domain schema.
|
||||||
|
|
||||||
## Build And Verification
|
## Build And Verification
|
||||||
|
|
||||||
Backend verification from core:
|
Backend verification from core:
|
||||||
|
|||||||
@@ -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. |
|
| RBAC/access policy | `govoplan-access` | access capabilities in `govoplan_core.core.access` | Permission decisions should use access capability contracts. Explain responses should adopt `PolicyDecision` when an API-level explanation is added. |
|
||||||
| Governance defaults | `govoplan-admin` plus `govoplan-access` materializer | admin settings, governance template routes, access materialization capability | System governance can block tenant-local groups, roles, and API keys. |
|
| Governance defaults | `govoplan-admin` plus `govoplan-access` materializer | admin settings, governance template routes, access materialization capability | System governance can block tenant-local groups, roles, and API keys. |
|
||||||
| 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. |
|
| 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
|
## Policy Decision
|
||||||
|
|
||||||
@@ -97,6 +98,34 @@ New backend code should import policy-owned retention behavior from
|
|||||||
`govoplan-policy` or request the capability, not add new implementation logic
|
`govoplan-policy` or request the capability, not add new implementation logic
|
||||||
to core.
|
to core.
|
||||||
|
|
||||||
|
The retention API DTOs live in `govoplan_core.privacy.schemas`.
|
||||||
|
`PrivacyRetentionPolicyItem`, `PrivacyRetentionPolicyPatchItem`,
|
||||||
|
`RETENTION_POLICY_FIELD_KEYS`, and `default_allow_lower_level_limits()` are
|
||||||
|
platform contracts because admin, access compatibility, and policy routes expose
|
||||||
|
the same stable retention payload shape. The policy engine's internal
|
||||||
|
`PrivacyRetentionPolicy` and `PrivacyRetentionPolicyPatch` models stay in
|
||||||
|
`govoplan-policy`, because they carry implementation validators and merge
|
||||||
|
behavior that are not generic API contracts.
|
||||||
|
|
||||||
|
Tenant administration DTOs remain owned by `govoplan-tenancy`; access keeps
|
||||||
|
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
|
## Frontend Contract
|
||||||
|
|
||||||
Policy UIs must:
|
Policy UIs must:
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ The trust layer should provide:
|
|||||||
- key rotation and epoch tracking
|
- key rotation and epoch tracking
|
||||||
- recovery policy hooks
|
- 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 And Function Postboxes
|
||||||
|
|
||||||
Role-bound access needs special handling. A postbox can be bound to an
|
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
|
Key epochs are required when role membership changes. Older messages may remain
|
||||||
readable according to policy, but new access must use the current epoch.
|
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
|
||||||
|
|
||||||
External recipients may need one-time or time-limited access without a full
|
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
|
strategy index. The executable connector catalogue lives in
|
||||||
`govoplan-connectors/docs/PUBLIC_SECTOR_INTEGRATION_CATALOGUE.md`.
|
`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
|
## Strategy Labels
|
||||||
|
|
||||||
Use one or more of these labels for every external system family:
|
Use one or more of these labels for every external system family:
|
||||||
@@ -172,8 +175,10 @@ connector or module issue.
|
|||||||
queries, untraceable manual transformations.
|
queries, untraceable manual transformations.
|
||||||
- MVP test path: publish one report/export as a governed file plus RSS/Atom
|
- MVP test path: publish one report/export as a governed file plus RSS/Atom
|
||||||
entry with checksum, timestamp, and permission check.
|
entry with checksum, timestamp, and permission check.
|
||||||
- Owner/priority: `govoplan-reporting`, `govoplan-connectors`, possible future
|
- Owner/priority: `govoplan-reporting`, `govoplan-connectors`,
|
||||||
`govoplan-datasources`/`govoplan-dataflow`, Wave 2.
|
`govoplan-datasources`, and `govoplan-dataflow`, Wave 2. Reporting owns
|
||||||
|
presentation/publication, Connectors owns transport, Datasources owns the
|
||||||
|
governed source/materialization catalogue, and Dataflow owns transformations.
|
||||||
|
|
||||||
### Public-Sector Protocols And Registries
|
### Public-Sector Protocols And Registries
|
||||||
|
|
||||||
|
|||||||
@@ -40,17 +40,26 @@ cd /mnt/DATA/git/govoplan
|
|||||||
Update those refs when cutting a release:
|
Update those refs when cutting a release:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
govoplan-access git@git.add-ideas.de:add-ideas/govoplan-access.git v0.1.6
|
govoplan-tenancy git@git.add-ideas.de:GovOPlaN/govoplan-tenancy.git v0.1.8
|
||||||
govoplan-admin git@git.add-ideas.de:add-ideas/govoplan-admin.git v0.1.6
|
govoplan-organizations git@git.add-ideas.de:GovOPlaN/govoplan-organizations.git v0.1.8
|
||||||
govoplan-tenancy git@git.add-ideas.de:add-ideas/govoplan-tenancy.git v0.1.6
|
govoplan-identity git@git.add-ideas.de:GovOPlaN/govoplan-identity.git v0.1.8
|
||||||
govoplan-organizations git@git.add-ideas.de:add-ideas/govoplan-organizations.git v0.1.6
|
govoplan-idm git@git.add-ideas.de:GovOPlaN/govoplan-idm.git v0.1.8
|
||||||
govoplan-identity git@git.add-ideas.de:add-ideas/govoplan-identity.git v0.1.6
|
govoplan-access git@git.add-ideas.de:GovOPlaN/govoplan-access.git v0.1.8
|
||||||
govoplan-policy git@git.add-ideas.de:add-ideas/govoplan-policy.git v0.1.6
|
govoplan-admin git@git.add-ideas.de:GovOPlaN/govoplan-admin.git v0.1.8
|
||||||
govoplan-audit git@git.add-ideas.de:add-ideas/govoplan-audit.git v0.1.6
|
govoplan-policy git@git.add-ideas.de:GovOPlaN/govoplan-policy.git v0.1.8
|
||||||
govoplan-files git@git.add-ideas.de:add-ideas/govoplan-files.git v0.1.6
|
govoplan-audit git@git.add-ideas.de:GovOPlaN/govoplan-audit.git v0.1.8
|
||||||
govoplan-mail git@git.add-ideas.de:add-ideas/govoplan-mail.git v0.1.6
|
govoplan-dashboard git@git.add-ideas.de:GovOPlaN/govoplan-dashboard.git v0.1.8
|
||||||
govoplan-campaign git@git.add-ideas.de:add-ideas/govoplan-campaign.git v0.1.6
|
govoplan-addresses git@git.add-ideas.de:GovOPlaN/govoplan-addresses.git v0.1.8
|
||||||
govoplan-calendar git@git.add-ideas.de:add-ideas/govoplan-calendar.git v0.1.6
|
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
|
## WebUI Packages
|
||||||
@@ -70,18 +79,18 @@ cd /mnt/DATA/git/govoplan-core/webui
|
|||||||
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run build
|
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
The module repositories include root-level npm package manifests so git
|
Module repositories with a frontend include root-level npm package manifests
|
||||||
installs can resolve `@govoplan/access-webui`, `@govoplan/admin-webui`,
|
so the `@govoplan/*-webui` dependencies in `webui/package.release.json` can be
|
||||||
`@govoplan/files-webui`, `@govoplan/mail-webui`,
|
resolved from repository roots even though their source lives below
|
||||||
`@govoplan/campaign-webui`, and `@govoplan/calendar-webui` from repository
|
`webui/src`.
|
||||||
roots even though their source lives below `webui/src`.
|
|
||||||
|
|
||||||
### Release Lockfile Strategy
|
### Release Lockfile Strategy
|
||||||
|
|
||||||
The supported release composition currently is the full GovOPlaN product: core
|
The supported backend release composition is the set pinned in the meta
|
||||||
plus access, admin, tenancy, organizations, identity, policy, audit,
|
repository's `requirements-release.txt`. The supported frontend composition
|
||||||
dashboard, files, mail, campaign, calendar, docs, and ops. Keep one committed
|
is the independently buildable module set pinned in
|
||||||
full-product release lockfile at
|
`webui/package.release.json`; backend-only modules do not need a frontend
|
||||||
|
package entry. Keep one committed full-product release lockfile at
|
||||||
`webui/package-lock.release.json`, generated from
|
`webui/package-lock.release.json`, generated from
|
||||||
`webui/package.release.json` in a clean release workspace. Development
|
`webui/package.release.json` in a clean release workspace. Development
|
||||||
`package-lock.json` may continue to point at local `file:` dependencies.
|
`package-lock.json` may continue to point at local `file:` dependencies.
|
||||||
@@ -104,7 +113,7 @@ working tree has already been bumped, pass the current version explicitly:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan
|
cd /mnt/DATA/git/govoplan
|
||||||
tools/release/push-release-tag.sh --version 0.1.6
|
tools/release/push-release-tag.sh --version 0.1.8
|
||||||
```
|
```
|
||||||
|
|
||||||
`/mnt/DATA/git/govoplan/tools/release/generate-release-catalog.py` reads installed/discovered
|
`/mnt/DATA/git/govoplan/tools/release/generate-release-catalog.py` reads installed/discovered
|
||||||
@@ -126,7 +135,6 @@ are not listed in `requirements-release.txt` or `webui/package.release.json`.
|
|||||||
|
|
||||||
Current tag-only module repositories:
|
Current tag-only module repositories:
|
||||||
|
|
||||||
- `govoplan-addresses`
|
|
||||||
- `govoplan-appointments`
|
- `govoplan-appointments`
|
||||||
- `govoplan-cases`
|
- `govoplan-cases`
|
||||||
- `govoplan-connectors`
|
- `govoplan-connectors`
|
||||||
@@ -135,17 +143,16 @@ Current tag-only module repositories:
|
|||||||
- `govoplan-fit-connect`
|
- `govoplan-fit-connect`
|
||||||
- `govoplan-forms`
|
- `govoplan-forms`
|
||||||
- `govoplan-identity-trust`
|
- `govoplan-identity-trust`
|
||||||
- `govoplan-idm`
|
|
||||||
- `govoplan-ledger`
|
- `govoplan-ledger`
|
||||||
- `govoplan-notifications`
|
|
||||||
- `govoplan-payments`
|
- `govoplan-payments`
|
||||||
- `govoplan-portal`
|
- `govoplan-portal`
|
||||||
|
- `govoplan-postbox`
|
||||||
- `govoplan-reporting`
|
- `govoplan-reporting`
|
||||||
- `govoplan-scheduling`
|
|
||||||
- `govoplan-search`
|
- `govoplan-search`
|
||||||
- `govoplan-tasks`
|
- `govoplan-tasks`
|
||||||
- `govoplan-templates`
|
- `govoplan-templates`
|
||||||
- `govoplan-workflow`
|
- `govoplan-workflow-engine` (headless definitions, migrations, and execution)
|
||||||
|
- `govoplan-workflow` (optional authoring and inspection WebUI)
|
||||||
- `govoplan-xoev`
|
- `govoplan-xoev`
|
||||||
- `govoplan-xrechnung`
|
- `govoplan-xrechnung`
|
||||||
- `govoplan-xta-osci`
|
- `govoplan-xta-osci`
|
||||||
@@ -782,10 +789,27 @@ tools/checks/postgres-integration-check.py \
|
|||||||
The script checks migrations and `/health` startup for core-only, files-only,
|
The script checks migrations and `/health` startup for core-only, files-only,
|
||||||
mail-only, campaign-only, campaign+files, campaign+mail, and full-product
|
mail-only, campaign-only, campaign+files, campaign+mail, and full-product
|
||||||
module sets. `--reset-schema` is destructive and must only be used against a
|
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
|
## 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.
|
Development migrations may be small and numerous while a feature is moving.
|
||||||
GovOPlaN keeps those detailed migrations on an explicit development track and
|
GovOPlaN keeps those detailed migrations on an explicit development track and
|
||||||
publishes reviewed release shortcuts on the release track. Before a stable
|
publishes reviewed release shortcuts on the release track. Before a stable
|
||||||
@@ -875,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
|
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 baseline and add a new release-track step-up instead of replacing prior
|
||||||
release shortcuts. The tracking issue is
|
release shortcuts. The tracking issue is
|
||||||
`add-ideas/govoplan-core#223`.
|
`GovOPlaN/govoplan-core#223`.
|
||||||
|
|
||||||
## Related Operator Documents
|
## Related Operator Documents
|
||||||
|
|
||||||
|
|||||||
@@ -40,10 +40,16 @@ set +a
|
|||||||
|
|
||||||
The command reports all known blockers at once. Production-like/self-hosted
|
The command reports all known blockers at once. Production-like/self-hosted
|
||||||
profiles require explicit `APP_ENV`, `DATABASE_URL`, `MASTER_KEY_B64`,
|
profiles require explicit `APP_ENV`, `DATABASE_URL`, `MASTER_KEY_B64`,
|
||||||
`ENABLED_MODULES`, and `CORS_ORIGINS`. Production rejects SQLite, development
|
`ENABLED_MODULES`, `CORS_ORIGINS`, `GOVOPLAN_TRUSTED_HOSTS`, and a deployment-wide decision for
|
||||||
|
`GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS`. Production rejects SQLite, development
|
||||||
bootstrap, insecure auth cookies, and unsigned catalog trust roots when a
|
bootstrap, insecure auth cookies, and unsigned catalog trust roots when a
|
||||||
catalog source is configured.
|
catalog source is configured.
|
||||||
|
|
||||||
|
Connector process-secret names and custom CA files are deployment-owned through
|
||||||
|
the exact, default-empty `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST` and
|
||||||
|
`GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST`; tenant/API configuration cannot widen
|
||||||
|
either boundary.
|
||||||
|
|
||||||
## Production-Like Dev Stack
|
## Production-Like Dev Stack
|
||||||
|
|
||||||
Use the local production-like wrapper for repeatable rehearsal:
|
Use the local production-like wrapper for repeatable rehearsal:
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# State And Recovery Contract
|
||||||
|
|
||||||
|
## State Profiles
|
||||||
|
|
||||||
|
Core accepts three runtime state profiles:
|
||||||
|
|
||||||
|
| Profile | Runtime placement | Durable storage |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `local` | One development process set | Local filesystem is permitted. |
|
||||||
|
| `host-shared` | Replicas on one host | One host-shared volume plus PostgreSQL and Redis. |
|
||||||
|
| `shared` | Independent hosts | PostgreSQL, Redis, and S3-compatible object storage. |
|
||||||
|
|
||||||
|
All replicas in one installation use one stable
|
||||||
|
`GOVOPLAN_INSTALLATION_ID`, one immutable module composition, and identical
|
||||||
|
database, broker, encryption-key, and object-storage bindings. Core rejects
|
||||||
|
replicas with the `local` profile and rejects `shared` without PostgreSQL,
|
||||||
|
Redis, S3, and a non-default installation identifier.
|
||||||
|
|
||||||
|
`FILE_STORAGE_S3_ENDPOINT_TRUSTED=true` is an explicit deployment trust
|
||||||
|
declaration for a clean HTTPS S3 origin. It does not authorize a
|
||||||
|
user-controlled connector endpoint and it is separate from installer-managed
|
||||||
|
Garage's exact endpoint trust.
|
||||||
|
|
||||||
|
## Object Storage
|
||||||
|
|
||||||
|
`govoplan_core.core.object_storage` is the shared backend contract for durable
|
||||||
|
module artifacts. It provides bounded read/write/list/stat/delete operations
|
||||||
|
for local and S3-compatible storage. Modules own their object-key namespace and
|
||||||
|
business metadata; Core does not interpret module files.
|
||||||
|
|
||||||
|
Rules for modules:
|
||||||
|
|
||||||
|
- Store only opaque object keys in business records, never local absolute
|
||||||
|
paths.
|
||||||
|
- Use node-local directories only for temporary materialization.
|
||||||
|
- Verify expected size and digest before consuming consequential artifacts.
|
||||||
|
- If object creation precedes database commit, compensate successfully created
|
||||||
|
objects on failure.
|
||||||
|
- If object deletion fails, retain the database reference and report a retryable
|
||||||
|
failure rather than claiming deletion.
|
||||||
|
- Define an orphan-inventory strategy for hard process loss between object
|
||||||
|
creation and metadata commit.
|
||||||
|
|
||||||
|
The Files module delegates its backend implementation to this Core contract.
|
||||||
|
Campaign generated EML artifacts use a Campaign-owned object prefix and are
|
||||||
|
read by workers through the same shared backend.
|
||||||
|
|
||||||
|
## Runtime Nodes And Leases
|
||||||
|
|
||||||
|
API and worker incarnations register in `core_runtime_nodes` with role,
|
||||||
|
software version, module-composition hash, queues, start time, and heartbeat.
|
||||||
|
The registration identity includes a process incarnation so a stale process
|
||||||
|
cannot update a replacement's row.
|
||||||
|
|
||||||
|
Worker metadata also records the orchestrator pool and declared concurrency.
|
||||||
|
Every Celery prefork child disposes the SQLAlchemy pool inherited from its
|
||||||
|
parent and creates a process-local pool before handling work. Deployment
|
||||||
|
rendering must therefore budget one database pool for the worker parent and
|
||||||
|
each child. Ops compares active queue ownership, software versions, and the
|
||||||
|
order-independent module-composition hash with the graph loaded by the API.
|
||||||
|
|
||||||
|
Drain is durable operator intent:
|
||||||
|
|
||||||
|
- an API enters not-ready state after observing drain;
|
||||||
|
- a worker cancels queue consumers after observing drain;
|
||||||
|
- cancellation returns an eligible draining node to active state;
|
||||||
|
- clean shutdown marks the matching incarnation stopped.
|
||||||
|
|
||||||
|
Coordination loss also fails closed. An API reports
|
||||||
|
`coordination_unavailable` from `/health/ready` until a heartbeat succeeds
|
||||||
|
again. A worker cancels its local queue consumers on any heartbeat or database
|
||||||
|
failure and only resumes them after its existing incarnation heartbeats
|
||||||
|
successfully. It never re-registers from the heartbeat path, so a stale worker
|
||||||
|
cannot reclaim a node identity from its replacement.
|
||||||
|
|
||||||
|
`core_distributed_leases` provides installation/resource uniqueness, expiry,
|
||||||
|
holder incarnation, and monotonically increasing fencing tokens. Lease expiry
|
||||||
|
does not make an old process harmless by itself: code performing an effect must
|
||||||
|
assert its exact fence before commit. `govoplan_core.commands.fenced_run`
|
||||||
|
renews a lease around a subprocess and terminates the child when the lease is
|
||||||
|
lost. The deployment profiles use it for the singleton scheduler.
|
||||||
|
|
||||||
|
## Migration Ordering
|
||||||
|
|
||||||
|
Only `govoplan_core.commands.init_db` mutates schema. On PostgreSQL it acquires
|
||||||
|
a deterministic installation/track advisory lock before pre-migration tasks,
|
||||||
|
Alembic, and post-migration tasks. The lock is session-scoped and therefore
|
||||||
|
released if the migration process dies.
|
||||||
|
|
||||||
|
Runtime roles run `govoplan_core.commands.wait_for_database`. It waits until
|
||||||
|
the database has exactly the configured Core/module Alembic heads and never
|
||||||
|
upgrades schema. This permits a migration Job and runtime Deployments to be
|
||||||
|
submitted together while keeping startup fail-closed.
|
||||||
|
|
||||||
|
## Recovery Ledger
|
||||||
|
|
||||||
|
`govoplan_core.core.recovery` provides a durable operation and evidence
|
||||||
|
contract. Recovery modes are:
|
||||||
|
|
||||||
|
- `atomic`: one database transaction, no external effect;
|
||||||
|
- `compensation`: explicit inverse actions;
|
||||||
|
- `snapshot_restore`: separately verified backup reference;
|
||||||
|
- `forward_recovery`: repair/resume the current version;
|
||||||
|
- `irreversible`: explicit approval, no automated recovery claim.
|
||||||
|
|
||||||
|
Every plan requires verification steps. Mode-specific evidence is mandatory.
|
||||||
|
Operations bind an idempotency key to a canonical request hash, may bind a
|
||||||
|
runtime fencing token, and emit append-only hash-chained checkpoints. Backup
|
||||||
|
and approval references are part of the hashed plan evidence. Every low-level
|
||||||
|
state transition and checkpoint append revalidates the operation's recorded
|
||||||
|
fence while holding the operation row lock. Plaintext secrets are rejected
|
||||||
|
from metadata and evidence.
|
||||||
|
|
||||||
|
The state machine makes partial and uncertain outcomes visible. A non-atomic
|
||||||
|
running operation cannot transition directly to ordinary failure, and success
|
||||||
|
or recovery requires explicit verified checks. Ops projects states requiring
|
||||||
|
attention, but module behavior gains this guarantee only after it adopts the
|
||||||
|
ledger around its own side effects.
|
||||||
|
|
||||||
|
## Recovery Boundary
|
||||||
|
|
||||||
|
Application/configuration rollback and database rollback are not equivalent.
|
||||||
|
Once an incompatible migration starts, old code may be unsafe even if its image
|
||||||
|
is available. Deployment automation must switch to forward recovery unless a
|
||||||
|
coordinated and verified database/object/key backup is restored.
|
||||||
|
|
||||||
|
Core does not create production database backups. The deployment owner must
|
||||||
|
provide backup, retention, encryption, restore verification, and recovery-point
|
||||||
|
coordination for PostgreSQL, object storage, and encryption keys. The canonical
|
||||||
|
operator procedure is documented in
|
||||||
|
`govoplan/docs/RECOVERY_AND_ROLLBACK_GUARANTEES.md`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Focused contracts are covered by:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/mnt/DATA/git/govoplan/.venv/bin/python -m pytest -q \
|
||||||
|
tests/test_object_storage.py \
|
||||||
|
tests/test_runtime_coordination.py \
|
||||||
|
tests/test_runtime_agents.py \
|
||||||
|
tests/test_fenced_run.py \
|
||||||
|
tests/test_migration_lock.py \
|
||||||
|
tests/test_wait_for_database.py \
|
||||||
|
tests/test_recovery_guarantees.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Production acceptance additionally requires multi-node failure and coordinated
|
||||||
|
restore drills against the actual PostgreSQL, Redis, object-store, ingress, and
|
||||||
|
secret-provider topology.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Template And Generated Artifact Capability Contracts
|
||||||
|
|
||||||
|
Core defines provider-neutral contracts for optional template libraries and
|
||||||
|
generated artifact storage. Core does not render templates or store generated
|
||||||
|
files itself.
|
||||||
|
|
||||||
|
## Templates
|
||||||
|
|
||||||
|
- `templates.catalog` lists typed, versioned template references and checks a
|
||||||
|
consumer's available fields, usage, and output format.
|
||||||
|
- `templates.renderer` accepts a `TemplateRenderRequest` containing pinned
|
||||||
|
input data and returns immutable render evidence plus an artifact reference.
|
||||||
|
|
||||||
|
The DTOs contain identifiers, hashes, plain mappings, and scalar metadata. They
|
||||||
|
do not expose Template ORM models or require Campaign, Distribution Lists,
|
||||||
|
Addresses, Reporting, Forms, or Mail.
|
||||||
|
|
||||||
|
## Generated Artifacts
|
||||||
|
|
||||||
|
`files.artifact_store` accepts a `ManagedArtifactWriteRequest` and returns a
|
||||||
|
`ManagedArtifactRef`. Producers supply bytes, a safe filename/content type,
|
||||||
|
idempotency key, and non-secret provenance. Files owns path normalization,
|
||||||
|
authorization, versions, storage, and download behavior.
|
||||||
|
|
||||||
|
Consumers must discover both contracts through the module registry and degrade
|
||||||
|
only the unavailable path. A template renderer may return a bounded download
|
||||||
|
when Files is absent. A caller must not infer successful external delivery from
|
||||||
|
successful rendering or artifact persistence.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# WebUI Theme Contract
|
||||||
|
|
||||||
|
GovOPlaN supports `system`, `light`, and `dark` as persisted user preferences.
|
||||||
|
`system` follows `prefers-color-scheme` live; it is not resolved permanently at
|
||||||
|
save time. Core applies the resolved mode through `data-theme` on the document
|
||||||
|
root and exposes the selected preference through `data-theme-preference`.
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
- Core owns semantic CSS tokens, native `color-scheme`, preference persistence,
|
||||||
|
the Settings selector, and the shared shell.
|
||||||
|
- Modules consume semantic tokens such as `--surface`, `--text`, `--line`, and
|
||||||
|
the status token families. They may define domain aliases whose values resolve
|
||||||
|
to shared tokens.
|
||||||
|
- User preference selects the mode. Tenant and system policy may provide a
|
||||||
|
future default, but must not silently replace an explicit user choice.
|
||||||
|
- Tenant branding is a separate policy surface and must preserve contrast and
|
||||||
|
status semantics in both modes.
|
||||||
|
|
||||||
|
Do not introduce fixed foreground/background colors in a module merely to make
|
||||||
|
one mode look correct. Add or reuse a semantic Core token, then define both
|
||||||
|
light and dark values. Bitmap content and externally authored HTML are exempt,
|
||||||
|
but their surrounding controls must still use the shared tokens.
|
||||||
|
|
||||||
|
`npm run test:theme-contract` verifies the root behavior and representative
|
||||||
|
Campaign, Calendar, Files, and Mail token consumption. The check runs before a
|
||||||
|
production WebUI build.
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -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
|
unless the decision is explicitly revised here and affected screens are updated
|
||||||
to match.
|
to match.
|
||||||
|
|
||||||
Active tracking issue: `add-ideas/govoplan-core#225`.
|
Active tracking issue: `GovOPlaN/govoplan-core#225`.
|
||||||
|
|
||||||
## Operating Rule
|
## Operating Rule
|
||||||
|
|
||||||
@@ -38,6 +38,18 @@ contestability, responsibility, and traceability at the point of action.
|
|||||||
| UX-012 | Automated actions must remain inspectable. The UI must show the system actor, trigger, policy result, observed effects, and failure/manual-intervention state when automation changes administrative state. | Accepted | Workflow, automation, connectors, tasks, audit |
|
| UX-012 | Automated actions must remain inspectable. The UI must show the system actor, trigger, policy result, observed effects, and failure/manual-intervention state when automation changes administrative state. | Accepted | Workflow, automation, connectors, tasks, audit |
|
||||||
| UX-013 | Contestable decisions must expose provenance. Denials, locks, generated outputs, calculated defaults, policy decisions, access decisions, and retention decisions need a reachable source path. | Accepted | Policy, access, templates, workflow, retention, records |
|
| UX-013 | Contestable decisions must expose provenance. Denials, locks, generated outputs, calculated defaults, policy decisions, access decisions, and retention decisions need a reachable source path. | Accepted | Policy, access, templates, workflow, retention, records |
|
||||||
| UX-014 | Retraction, expiry, undo, rollback, and delete controls must state the real limit of the operation. Corrective or future-only actions must not be described as if they undo already observed effects. | Accepted | Postbox, files, records, installer, workflow, payments |
|
| UX-014 | Retraction, expiry, undo, rollback, and delete controls must state the real limit of the operation. Corrective or future-only actions must not be described as if they undo already observed effects. | Accepted | Postbox, files, records, installer, workflow, payments |
|
||||||
|
| UX-015 | Core owns the platform appearance contract. Modules must use shared CSS tokens and shared controls for theme-aware UI; they must not define independent light/dark palette systems. | Accepted | Core shell and all module WebUIs |
|
||||||
|
| UX-016 | Full-page create/edit surfaces keep `Discard` and the named `Save …` action in the upper-right page action cluster, with Save at the far right. Their position remains stable through validation and loading states. | Accepted | All full-page create/edit surfaces |
|
||||||
|
| UX-017 | Table row actions use icon-only controls in a stable rightmost column and intent order: inspect/open, edit, copy, transfer/share/download, retry/restore, destructive action last. Every icon requires a translated accessible name and tooltip. | Accepted | All structured tables |
|
||||||
|
| UX-018 | A collapsible card containing only one table gives the table the card's full available body, without decorative inner wrappers, duplicate padding, max-widths, or nested scrolling. | Accepted | List, detail, workflow, and configuration surfaces |
|
||||||
|
| UX-019 | Focused-view precedence is manual session pin, current-task suggestion, user default, role/tenant default, then the full interface. The active source and a full-interface escape remain visible; a suggested view never changes authorization or implies consent. | Accepted | Shell, modules, future workflow composition |
|
||||||
|
| 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
|
## Confirmed Implementation Decisions
|
||||||
|
|
||||||
@@ -138,6 +150,140 @@ adaptive form, not force a linear wizard.
|
|||||||
- Wizard shells remain available for assisted setup, first-run guidance,
|
- Wizard shells remain available for assisted setup, first-run guidance,
|
||||||
imports, discovery-heavy flows, and operational preflight workflows.
|
imports, discovery-heavy flows, and operational preflight workflows.
|
||||||
|
|
||||||
|
### DUE-008: Platform Theme Contract
|
||||||
|
|
||||||
|
Decision: the WebUI shell exposes a small, stable appearance contract based on
|
||||||
|
shared CSS tokens and persisted user preference selection.
|
||||||
|
|
||||||
|
- Core applies `system`, `light`, and `dark` preferences at the document root.
|
||||||
|
- Core owns shared tokens such as `--bg`, `--bar`, `--panel`, `--surface`,
|
||||||
|
`--line`, `--line-dark`, `--text`, `--text-strong`, `--muted`, semantic
|
||||||
|
status colors, radii, shadows, and disabled-control colors.
|
||||||
|
- Modules must style new UI with these tokens and shared controls. Module-local
|
||||||
|
CSS may tune layout and spacing, but it must not introduce a separate
|
||||||
|
appearance system.
|
||||||
|
- Appearance controls live in user settings first. Tenant defaults and policy
|
||||||
|
enforcement can be added later without changing the token contract.
|
||||||
|
- Visual preview in settings is illustrative; it must reflect token families,
|
||||||
|
not become a second theme implementation.
|
||||||
|
|
||||||
|
### DUE-009: Central Component And Exception Contract
|
||||||
|
|
||||||
|
Decision: module interfaces are compositions of the components exported by
|
||||||
|
`@govoplan/core-webui`. When Core already owns the matching interaction, using
|
||||||
|
the central component is required rather than preferred.
|
||||||
|
|
||||||
|
A route or domain-specific page composed from central components is ordinary
|
||||||
|
module composition. A new reusable UI control, presentation primitive, or
|
||||||
|
module-local substitute is a custom component. Before one is implemented, the
|
||||||
|
product owner must explicitly authorize it and the owning decision or issue must
|
||||||
|
record:
|
||||||
|
|
||||||
|
- its single, narrowly defined purpose and intended consumers
|
||||||
|
- why central components or their composition cannot meet that purpose
|
||||||
|
- the permitted scope and the boundary it must not grow beyond
|
||||||
|
- accessibility, reachable states, theme behavior, and test expectations
|
||||||
|
- whether the component remains domain-owned or is a candidate for Core
|
||||||
|
|
||||||
|
Custom components must not duplicate, fork, or cosmetically replace a central
|
||||||
|
component. An existing local implementation does not grant an exception. If a
|
||||||
|
central contract later covers the need, migrate to it unless the product owner
|
||||||
|
explicitly retains the exception.
|
||||||
|
|
||||||
|
### DUE-010: Scheduling Request Reference Composition
|
||||||
|
|
||||||
|
Decision: Scheduling requests provide a concrete reference application of the
|
||||||
|
universal placement and component rules.
|
||||||
|
|
||||||
|
- 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
|
||||||
|
standard row-action placement and order.
|
||||||
|
- Calendar integration uses the central `ToggleSwitch`, with its dependent
|
||||||
|
controls shown when enabled.
|
||||||
|
- Each participant is edited as one structured row with name, email address,
|
||||||
|
and actions. The normal editor does not parse a free-form list of addresses;
|
||||||
|
that interaction requires a separate, explicitly designed bulk-import flow.
|
||||||
|
|
||||||
|
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
|
## Implementation Sequence
|
||||||
|
|
||||||
| Phase | Scope | Output |
|
| Phase | Scope | Output |
|
||||||
@@ -200,6 +346,22 @@ Every new or changed admin/configuration surface should answer:
|
|||||||
- Does the action surface show consequence, reversibility, and audit evidence
|
- Does the action surface show consequence, reversibility, and audit evidence
|
||||||
when rights, duties, records, money, communication, external systems, or
|
when rights, duties, records, money, communication, external systems, or
|
||||||
workflow state are affected?
|
workflow state are affected?
|
||||||
|
- Does the surface use every applicable central Core component? If it contains
|
||||||
|
a custom component, is the product-owner authorization, narrow purpose,
|
||||||
|
rationale, scope, and non-duplication evidence recorded?
|
||||||
|
- Is a collection-wide create action in the collection heading rather than
|
||||||
|
duplicated in a persistent side panel?
|
||||||
|
- 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,
|
- If automation is involved, can the user see the trigger, system actor,
|
||||||
observed effects, and failure/manual-intervention state?
|
observed effects, and failure/manual-intervention state?
|
||||||
- Are technical details available without being the first thing the user sees?
|
- Are technical details available without being the first thing the user sees?
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -45,7 +45,7 @@ Remediation applied on 2026-07-09:
|
|||||||
- upgraded the campaign ZIP dependency to `pyzipper>=0.4,<1`, resolving to
|
- upgraded the campaign ZIP dependency to `pyzipper>=0.4,<1`, resolving to
|
||||||
`pyzipper==0.4.0`
|
`pyzipper==0.4.0`
|
||||||
- upgraded the local audit environment to `pip==26.1.2`
|
- upgraded the local audit environment to `pip==26.1.2`
|
||||||
- removed the obsolete local `govoplan-module-multimailer` editable install
|
- removed the obsolete local mailer-module editable install
|
||||||
from the audit environment so the audit reflects the split module product
|
from the audit environment so the audit reflects the split module product
|
||||||
|
|
||||||
Post-remediation result:
|
Post-remediation result:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"description": "Portal form, case workflow, task creation, mail notification, payment setup, and access roles for a simple application process.",
|
"description": "Portal form, case workflow, task creation, mail notification, payment setup, and access roles for a simple application process.",
|
||||||
"publisher": "ADD ideas",
|
"publisher": "ADD ideas",
|
||||||
"category": "workflow",
|
"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>",
|
"artifact_sha256": "<sha256>",
|
||||||
"required_modules": [
|
"required_modules": [
|
||||||
{ "module_id": "portal", "version": ">=0.1.0" },
|
{ "module_id": "portal", "version": ">=0.1.0" },
|
||||||
|
|||||||
@@ -81,8 +81,588 @@
|
|||||||
],
|
],
|
||||||
"recorded_at": "2026-07-11T01:39:45Z",
|
"recorded_at": "2026-07-11T01:39:45Z",
|
||||||
"release": "0.1.7",
|
"release": "0.1.7",
|
||||||
"track": "release",
|
"squash_policy": "reviewed-manual",
|
||||||
"squash_policy": "reviewed-manual"
|
"track": "release"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revision": "2c3d4e5f7081"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revision": "3d4e5f708192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revision": "8f9a0b1c2d3e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revision": "9e0f1a2b3c4d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revision": "a7b8c9d0e1f3"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"owner_heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-access",
|
||||||
|
"revisions": [
|
||||||
|
"4a5b6c7d8e9f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revisions": [
|
||||||
|
"9e0f1a2b3c4d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revisions": [
|
||||||
|
"2c3d4e5f7081"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-core",
|
||||||
|
"revisions": [
|
||||||
|
"4f2a9c8e7b6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revisions": [
|
||||||
|
"a7b8c9d0e1f3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-identity",
|
||||||
|
"revisions": [
|
||||||
|
"5c6d7e8f9a10"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revisions": [
|
||||||
|
"8f9a0b1c2d3e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revisions": [
|
||||||
|
"3d4e5f708192"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-organizations",
|
||||||
|
"revisions": [
|
||||||
|
"6d7e8f9a0b1c"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recorded_at": "2026-07-20T02:45:41Z",
|
||||||
|
"release": "0.1.8",
|
||||||
|
"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
|
"version": 1
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"action": "install",
|
"action": "install",
|
||||||
"python_package": "govoplan-files",
|
"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_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_safety": "forward_only",
|
||||||
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
|
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
|
||||||
"migration_after": ["access"],
|
"migration_after": ["access"],
|
||||||
@@ -54,14 +54,14 @@
|
|||||||
],
|
],
|
||||||
"artifact_integrity": {
|
"artifact_integrity": {
|
||||||
"python": {
|
"python": {
|
||||||
"ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.4",
|
"ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.4",
|
||||||
"sha256": "<sha256 of the resolved Python artifact>",
|
"sha256": "<sha256 of the resolved Python artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-0.1.4.spdx.json",
|
"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",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-0.1.4.intoto.jsonl",
|
||||||
"git_ref": "refs/tags/v0.1.4"
|
"git_ref": "refs/tags/v0.1.4"
|
||||||
},
|
},
|
||||||
"webui": {
|
"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>",
|
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-webui-0.1.4.spdx.json",
|
"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",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-webui-0.1.4.intoto.jsonl",
|
||||||
@@ -78,25 +78,33 @@
|
|||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"action": "install",
|
"action": "install",
|
||||||
"python_package": "govoplan-mail",
|
"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_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": [
|
"provides_interfaces": [
|
||||||
{
|
{
|
||||||
"name": "mail.campaign_delivery",
|
"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": {
|
"artifact_integrity": {
|
||||||
"python": {
|
"python": {
|
||||||
"ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.4",
|
"ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.4",
|
||||||
"sha256": "<sha256 of the resolved Python artifact>",
|
"sha256": "<sha256 of the resolved Python artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-0.1.4.spdx.json",
|
"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",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-0.1.4.intoto.jsonl",
|
||||||
"git_ref": "refs/tags/v0.1.4"
|
"git_ref": "refs/tags/v0.1.4"
|
||||||
},
|
},
|
||||||
"webui": {
|
"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>",
|
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-webui-0.1.4.spdx.json",
|
"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",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-webui-0.1.4.intoto.jsonl",
|
||||||
@@ -113,19 +121,19 @@
|
|||||||
"version": "0.1.6",
|
"version": "0.1.6",
|
||||||
"action": "install",
|
"action": "install",
|
||||||
"python_package": "govoplan-dashboard",
|
"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_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": {
|
"artifact_integrity": {
|
||||||
"python": {
|
"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>",
|
"sha256": "<sha256 of the resolved Python artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-0.1.6.spdx.json",
|
"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",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-0.1.6.intoto.jsonl",
|
||||||
"git_ref": "refs/tags/v0.1.6"
|
"git_ref": "refs/tags/v0.1.6"
|
||||||
},
|
},
|
||||||
"webui": {
|
"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>",
|
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-webui-0.1.6.spdx.json",
|
"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",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-webui-0.1.6.intoto.jsonl",
|
||||||
|
|||||||
+12
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-core"
|
name = "govoplan-core"
|
||||||
version = "0.1.8"
|
version = "0.1.14"
|
||||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
@@ -19,6 +19,7 @@ dependencies = [
|
|||||||
"celery>=5,<6",
|
"celery>=5,<6",
|
||||||
"redis>=5,<6",
|
"redis>=5,<6",
|
||||||
"alembic>=1,<2",
|
"alembic>=1,<2",
|
||||||
|
"boto3>=1.34,<2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
@@ -27,6 +28,12 @@ where = ["src"]
|
|||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
govoplan_core = ["py.typed"]
|
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]
|
[project.scripts]
|
||||||
govoplan-config = "govoplan_core.commands.config:main"
|
govoplan-config = "govoplan_core.commands.config:main"
|
||||||
govoplan-devserver = "govoplan_core.devserver:main"
|
govoplan-devserver = "govoplan_core.devserver:main"
|
||||||
@@ -42,3 +49,7 @@ dev = [
|
|||||||
"httpx==0.28.1",
|
"httpx==0.28.1",
|
||||||
"httpx2>=2.5,<3",
|
"httpx2>=2.5,<3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/test_api_smoke.py" = ["E402"]
|
||||||
|
"tests/test_module_system.py" = ["E402"]
|
||||||
|
|||||||
@@ -39,6 +39,24 @@ class DeltaCollectionResponse(BaseModel):
|
|||||||
full: bool = False
|
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):
|
class LoginRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -55,6 +73,12 @@ class SwitchTenantRequest(BaseModel):
|
|||||||
tenant_id: str
|
tenant_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class SwitchActingContextRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
assignment_id: str | None = Field(default=None, max_length=36)
|
||||||
|
|
||||||
|
|
||||||
class TenantInfo(BaseModel):
|
class TenantInfo(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
slug: str
|
slug: str
|
||||||
@@ -94,6 +118,28 @@ class UserInfo(BaseModel):
|
|||||||
ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences)
|
ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSessionUserInfo(BaseModel):
|
||||||
|
id: str
|
||||||
|
account_id: str
|
||||||
|
email: str
|
||||||
|
display_name: str | None = None
|
||||||
|
tenant_display_name: str | None = None
|
||||||
|
is_tenant_admin: bool = False
|
||||||
|
password_reset_required: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSessionResponse(BaseModel):
|
||||||
|
authenticated: bool = True
|
||||||
|
auth_method: Literal["session", "api_key"] = "session"
|
||||||
|
user: AuthSessionUserInfo
|
||||||
|
# Backwards-compatible alias for the active tenant.
|
||||||
|
tenant: TenantInfo
|
||||||
|
active_tenant: TenantInfo
|
||||||
|
session_id: str | None = None
|
||||||
|
api_key_id: str | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProfileUpdateRequest(BaseModel):
|
class ProfileUpdateRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -138,14 +184,49 @@ class PrincipalContextInfo(BaseModel):
|
|||||||
api_key_id: str | None = None
|
api_key_id: str | None = None
|
||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
service_account_id: str | None = None
|
service_account_id: str | None = None
|
||||||
|
acting_assignment_id: str | None = None
|
||||||
acting_for_account_id: str | None = None
|
acting_for_account_id: str | None = None
|
||||||
email: str | None = None
|
email: str | None = None
|
||||||
display_name: str | None = None
|
display_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuthShellResponse(BaseModel):
|
||||||
|
user: UserInfo
|
||||||
|
# Backwards-compatible alias for the active tenant.
|
||||||
|
tenant: TenantInfo
|
||||||
|
active_tenant: TenantInfo
|
||||||
|
tenants: list[TenantMembershipInfo] = Field(default_factory=list)
|
||||||
|
scopes: list[str]
|
||||||
|
principal: PrincipalContextInfo | None = None
|
||||||
|
profile_loaded: bool = False
|
||||||
|
roles_loaded: bool = False
|
||||||
|
groups_loaded: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AuthProfileResponse(BaseModel):
|
||||||
|
user: UserInfo
|
||||||
|
# Backwards-compatible alias for the active tenant.
|
||||||
|
tenant: TenantInfo
|
||||||
|
active_tenant: TenantInfo
|
||||||
|
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
||||||
|
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||||
|
default_language: str = "en"
|
||||||
|
profile_loaded: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class AuthRolesResponse(BaseModel):
|
||||||
|
roles: list[RoleInfo] = Field(default_factory=list)
|
||||||
|
roles_loaded: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class AuthGroupsResponse(BaseModel):
|
||||||
|
groups: list[GroupInfo] = Field(default_factory=list)
|
||||||
|
groups_loaded: bool = True
|
||||||
|
|
||||||
|
|
||||||
class LoginResponse(BaseModel):
|
class LoginResponse(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer" # noqa: S105 - OAuth token type, not a credential.
|
||||||
expires_at: datetime
|
expires_at: datetime
|
||||||
user: UserInfo
|
user: UserInfo
|
||||||
# Backwards-compatible alias for the active tenant.
|
# Backwards-compatible alias for the active tenant.
|
||||||
@@ -159,6 +240,9 @@ class LoginResponse(BaseModel):
|
|||||||
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
||||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||||
default_language: str = "en"
|
default_language: str = "en"
|
||||||
|
profile_loaded: bool = True
|
||||||
|
roles_loaded: bool = True
|
||||||
|
groups_loaded: bool = True
|
||||||
|
|
||||||
|
|
||||||
class MeResponse(BaseModel):
|
class MeResponse(BaseModel):
|
||||||
@@ -174,3 +258,6 @@ class MeResponse(BaseModel):
|
|||||||
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
||||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||||
default_language: str = "en"
|
default_language: str = "en"
|
||||||
|
profile_loaded: bool = True
|
||||||
|
roles_loaded: bool = True
|
||||||
|
groups_loaded: bool = True
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ from govoplan_core.core.events import (
|
|||||||
PlatformEvent,
|
PlatformEvent,
|
||||||
current_event_trace,
|
current_event_trace,
|
||||||
normalize_trace_id,
|
normalize_trace_id,
|
||||||
publish_platform_event,
|
emit_platform_event,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.institutional import GovernedContextEnvelope
|
||||||
from govoplan_core.core.runtime import get_registry
|
from govoplan_core.core.runtime import get_registry
|
||||||
from govoplan_core.privacy.retention import sanitize_audit_details_for_policy
|
from govoplan_core.privacy.retention import sanitize_audit_details_for_policy
|
||||||
from govoplan_core.security.redaction import redact_secret_values
|
from govoplan_core.security.redaction import redact_secret_values
|
||||||
@@ -171,9 +172,23 @@ 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)
|
trace = _compact_trace(item.details.get("_trace") if isinstance(item.details, Mapping) else None)
|
||||||
publish_platform_event(
|
raw_context = (
|
||||||
|
item.details.get("_institutional_context")
|
||||||
|
if isinstance(item.details, Mapping)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
institutional_context = (
|
||||||
|
GovernedContextEnvelope.from_mapping(raw_context)
|
||||||
|
if isinstance(raw_context, Mapping)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
emit_platform_event(
|
||||||
|
session,
|
||||||
PlatformEvent(
|
PlatformEvent(
|
||||||
type=item.action,
|
type=item.action,
|
||||||
module_id=_module_id_for_audit_action(item.action),
|
module_id=_module_id_for_audit_action(item.action),
|
||||||
@@ -190,6 +205,7 @@ def _publish_audit_platform_event(item: AuditRecordRef) -> None:
|
|||||||
tenant=EventTenantRef(id=item.tenant_id) if item.tenant_id else None,
|
tenant=EventTenantRef(id=item.tenant_id) if item.tenant_id else None,
|
||||||
resource=EventObjectRef(type=item.object_type, id=item.object_id) if item.object_type else None,
|
resource=EventObjectRef(type=item.object_type, id=item.object_id) if item.object_type else None,
|
||||||
classification="internal",
|
classification="internal",
|
||||||
|
institutional_context=institutional_context,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -207,6 +223,7 @@ def audit_event(
|
|||||||
details: dict[str, Any] | None = None,
|
details: dict[str, Any] | None = None,
|
||||||
correlation_id: str | None = None,
|
correlation_id: str | None = None,
|
||||||
causation_id: str | None = None,
|
causation_id: str | None = None,
|
||||||
|
institutional_context: GovernedContextEnvelope | None = None,
|
||||||
commit: bool = False,
|
commit: bool = False,
|
||||||
) -> AuditRecordRef:
|
) -> AuditRecordRef:
|
||||||
"""Persist one audit event.
|
"""Persist one audit event.
|
||||||
@@ -219,8 +236,17 @@ def audit_event(
|
|||||||
if scope not in {"tenant", "system"}:
|
if scope not in {"tenant", "system"}:
|
||||||
raise ValueError(f"Unsupported audit scope: {scope}")
|
raise ValueError(f"Unsupported audit scope: {scope}")
|
||||||
|
|
||||||
|
if (
|
||||||
|
institutional_context is not None
|
||||||
|
and tenant_id is not None
|
||||||
|
and institutional_context.tenant_id != tenant_id
|
||||||
|
):
|
||||||
|
raise ValueError("Audit institutional context belongs to another tenant")
|
||||||
|
raw_details = dict(details or {})
|
||||||
|
if institutional_context is not None:
|
||||||
|
raw_details["_institutional_context"] = institutional_context.to_dict()
|
||||||
traced_details, trace = _trace_details(
|
traced_details, trace = _trace_details(
|
||||||
_sanitize_details(details or {}),
|
_sanitize_details(raw_details),
|
||||||
correlation_id=correlation_id,
|
correlation_id=correlation_id,
|
||||||
causation_id=causation_id,
|
causation_id=causation_id,
|
||||||
)
|
)
|
||||||
@@ -238,6 +264,7 @@ def audit_event(
|
|||||||
api_key_id=api_key_id,
|
api_key_id=api_key_id,
|
||||||
resource_type=object_type,
|
resource_type=object_type,
|
||||||
resource_id=object_id,
|
resource_id=object_id,
|
||||||
|
institutional_context=institutional_context,
|
||||||
details=stored_details,
|
details=stored_details,
|
||||||
))
|
))
|
||||||
record_change(
|
record_change(
|
||||||
@@ -252,7 +279,7 @@ def audit_event(
|
|||||||
actor_id=user_id or api_key_id,
|
actor_id=user_id or api_key_id,
|
||||||
payload={"scope": scope, "action": action, "object_type": object_type, "object_id": object_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:
|
if commit:
|
||||||
session.commit()
|
session.commit()
|
||||||
return item
|
return item
|
||||||
@@ -269,6 +296,7 @@ def audit_from_principal(
|
|||||||
details: dict[str, Any] | None = None,
|
details: dict[str, Any] | None = None,
|
||||||
correlation_id: str | None = None,
|
correlation_id: str | None = None,
|
||||||
causation_id: str | None = None,
|
causation_id: str | None = None,
|
||||||
|
institutional_context: GovernedContextEnvelope | None = None,
|
||||||
commit: bool = False,
|
commit: bool = False,
|
||||||
) -> AuditRecordRef:
|
) -> AuditRecordRef:
|
||||||
return audit_event(
|
return audit_event(
|
||||||
@@ -283,5 +311,6 @@ def audit_from_principal(
|
|||||||
details=details,
|
details=details,
|
||||||
correlation_id=correlation_id,
|
correlation_id=correlation_id,
|
||||||
causation_id=causation_id,
|
causation_id=causation_id,
|
||||||
|
institutional_context=institutional_context,
|
||||||
commit=commit,
|
commit=commit,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
"""Core auth dependency facade.
|
"""Core auth dependency facade.
|
||||||
|
|
||||||
Routers depend on this module instead of a concrete access-provider package.
|
Routers depend on this module instead of a concrete access-provider package.
|
||||||
@@ -7,6 +5,8 @@ The active auth module provides the request principal through the platform
|
|||||||
capability registry.
|
capability registry.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from fastapi import Depends, Header, HTTPException, Request, status
|
from fastapi import Depends, Header, HTTPException, Request, status
|
||||||
@@ -81,6 +81,10 @@ class ApiPrincipal:
|
|||||||
def acting_for_account_id(self) -> str | None:
|
def acting_for_account_id(self) -> str | None:
|
||||||
return self.principal.acting_for_account_id
|
return self.principal.acting_for_account_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def acting_assignment_id(self) -> str | None:
|
||||||
|
return self.principal.acting_assignment_id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def auth_method(self) -> str:
|
def auth_method(self) -> str:
|
||||||
return self.principal.auth_method
|
return self.principal.auth_method
|
||||||
@@ -118,7 +122,7 @@ def _registry_from_request(request: Request) -> PlatformRegistry | None:
|
|||||||
def _api_principal_provider_from_request(request: Request) -> ApiPrincipalProvider:
|
def _api_principal_provider_from_request(request: Request) -> ApiPrincipalProvider:
|
||||||
registry = _registry_from_request(request)
|
registry = _registry_from_request(request)
|
||||||
if registry is None or not registry.has_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER):
|
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)
|
capability = registry.require_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER)
|
||||||
if not isinstance(capability, ApiPrincipalProvider):
|
if not isinstance(capability, ApiPrincipalProvider):
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid auth provider capability")
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid auth provider capability")
|
||||||
|
|||||||
+889
-17
@@ -1,19 +1,89 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from celery import Celery
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider
|
from celery import Celery
|
||||||
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
from celery.signals import (
|
||||||
|
heartbeat_sent,
|
||||||
|
worker_process_init,
|
||||||
|
worker_ready,
|
||||||
|
worker_shutdown,
|
||||||
|
)
|
||||||
|
|
||||||
|
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_BOUNCE_PROCESSING,
|
||||||
|
CAPABILITY_MAIL_DELIVERY_OUTBOX,
|
||||||
|
MailBounceProcessingProvider,
|
||||||
|
MailDeliveryOutboxProvider,
|
||||||
|
)
|
||||||
from govoplan_core.core.modules import ModuleContext
|
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,
|
||||||
|
CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER,
|
||||||
|
WorkflowRuntimeWorker,
|
||||||
|
WorkflowTriggerDispatcher,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
from govoplan_core.core.runtime import configure_runtime
|
from govoplan_core.core.runtime import configure_runtime
|
||||||
|
from govoplan_core.core.runtime_coordination import (
|
||||||
|
RuntimeIdentity,
|
||||||
|
heartbeat_runtime_node,
|
||||||
|
register_runtime_node,
|
||||||
|
runtime_identity,
|
||||||
|
stop_runtime_node,
|
||||||
|
)
|
||||||
from govoplan_core.settings import settings
|
from govoplan_core.settings import settings
|
||||||
from govoplan_core.db.session import configure_database
|
from govoplan_core.db.session import configure_database, get_database
|
||||||
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
from govoplan_core.server.registry import (
|
||||||
|
available_module_manifests,
|
||||||
|
build_platform_registry,
|
||||||
|
)
|
||||||
|
|
||||||
configure_database(settings.database_url)
|
configure_database(settings.database_url)
|
||||||
|
|
||||||
celery = Celery(
|
celery = Celery(
|
||||||
"multimailer",
|
"govoplan",
|
||||||
broker=settings.redis_url,
|
broker=settings.redis_url,
|
||||||
backend=settings.redis_url,
|
backend=settings.redis_url,
|
||||||
)
|
)
|
||||||
@@ -21,36 +91,444 @@ celery = Celery(
|
|||||||
celery.conf.update(
|
celery.conf.update(
|
||||||
task_default_queue="default",
|
task_default_queue="default",
|
||||||
task_routes={
|
task_routes={
|
||||||
"multimailer.send_email": {"queue": "send_email"},
|
"govoplan.campaigns.send_email": {"queue": "send_email"},
|
||||||
"multimailer.append_sent": {"queue": "append_sent"},
|
"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.mail.scan_bounces": {"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,
|
worker_prefetch_multiplier=1,
|
||||||
task_acks_late=True,
|
task_acks_late=True,
|
||||||
task_reject_on_worker_lost=True,
|
task_reject_on_worker_lost=True,
|
||||||
|
task_track_started=True,
|
||||||
|
broker_transport_options={
|
||||||
|
"visibility_timeout": settings.celery_visibility_timeout_seconds,
|
||||||
|
},
|
||||||
|
result_backend_transport_options={
|
||||||
|
"visibility_timeout": settings.celery_visibility_timeout_seconds,
|
||||||
|
},
|
||||||
|
visibility_timeout=settings.celery_visibility_timeout_seconds,
|
||||||
|
beat_schedule={
|
||||||
|
"calendar-outbox-every-minute": {
|
||||||
|
"task": "govoplan.calendar.dispatch_outbox",
|
||||||
|
"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,),
|
||||||
|
},
|
||||||
|
"mail-bounces-every-five-minutes": {
|
||||||
|
"task": "govoplan.mail.scan_bounces",
|
||||||
|
"schedule": 5 * 60.0,
|
||||||
|
"args": (None, 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),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
_worker_identity: RuntimeIdentity | None = None
|
||||||
|
_worker_draining = False
|
||||||
|
_worker_consumer: object | None = None
|
||||||
|
logger = logging.getLogger("govoplan.worker.runtime")
|
||||||
|
|
||||||
|
|
||||||
|
def _core_version() -> str:
|
||||||
|
try:
|
||||||
|
return version("govoplan-core")
|
||||||
|
except PackageNotFoundError:
|
||||||
|
return "development"
|
||||||
|
|
||||||
|
|
||||||
|
def _worker_runtime_identity(sender: object | None = None) -> RuntimeIdentity:
|
||||||
|
global _worker_identity
|
||||||
|
if _worker_identity is None:
|
||||||
|
hostname = str(getattr(sender, "hostname", "") or "").strip() or None
|
||||||
|
module_ids = tuple(load_startup_enabled_modules(settings.enabled_modules))
|
||||||
|
_worker_identity = runtime_identity(
|
||||||
|
settings,
|
||||||
|
software_version=_core_version(),
|
||||||
|
module_ids=module_ids,
|
||||||
|
role="worker",
|
||||||
|
node_id=hostname,
|
||||||
|
)
|
||||||
|
return _worker_identity
|
||||||
|
|
||||||
|
|
||||||
|
def _worker_metadata() -> dict[str, object]:
|
||||||
|
raw_concurrency = str(os.getenv("CELERY_WORKER_CONCURRENCY") or "").strip()
|
||||||
|
return {
|
||||||
|
"process": "celery-worker",
|
||||||
|
"worker_pool": str(os.getenv("GOVOPLAN_WORKER_POOL") or "default"),
|
||||||
|
"concurrency": int(raw_concurrency) if raw_concurrency.isdigit() else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@worker_process_init.connect
|
||||||
|
def _reset_worker_process_database(**_kwargs) -> None:
|
||||||
|
# SQLAlchemy pools must not be shared across prefork child processes.
|
||||||
|
configure_database(settings.database_url, dispose_previous=True)
|
||||||
|
|
||||||
|
|
||||||
|
@worker_ready.connect
|
||||||
|
def _register_worker_runtime(sender=None, **_kwargs) -> None:
|
||||||
|
global _worker_consumer
|
||||||
|
_worker_consumer = sender
|
||||||
|
identity = _worker_runtime_identity(sender)
|
||||||
|
try:
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
node = register_runtime_node(
|
||||||
|
session,
|
||||||
|
identity,
|
||||||
|
metadata=_worker_metadata(),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except Exception:
|
||||||
|
_set_worker_consumers(identity, draining=True)
|
||||||
|
logger.exception(
|
||||||
|
"worker runtime registration failed; queues were disabled node_id=%s",
|
||||||
|
identity.node_id,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
_set_worker_consumers(identity, draining=node.state == "draining")
|
||||||
|
|
||||||
|
|
||||||
|
@heartbeat_sent.connect
|
||||||
|
def _heartbeat_worker_runtime(sender=None, **_kwargs) -> None:
|
||||||
|
identity = _worker_runtime_identity(sender)
|
||||||
|
try:
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
node = heartbeat_runtime_node(
|
||||||
|
session,
|
||||||
|
identity,
|
||||||
|
metadata=_worker_metadata(),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except Exception: # noqa: BLE001 - uncertain authority must fail closed
|
||||||
|
_set_worker_consumers(identity, draining=True)
|
||||||
|
logger.exception(
|
||||||
|
"worker runtime heartbeat failed; queues were disabled node_id=%s",
|
||||||
|
identity.node_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
_set_worker_consumers(identity, draining=node.state == "draining")
|
||||||
|
|
||||||
|
|
||||||
|
def _set_worker_consumers(
|
||||||
|
identity: RuntimeIdentity,
|
||||||
|
*,
|
||||||
|
draining: bool,
|
||||||
|
) -> None:
|
||||||
|
global _worker_draining
|
||||||
|
if draining == _worker_draining:
|
||||||
|
return
|
||||||
|
consumer = _worker_consumer
|
||||||
|
if consumer is not None:
|
||||||
|
method_name = "cancel_task_queue" if draining else "add_task_queue"
|
||||||
|
method = getattr(consumer, method_name)
|
||||||
|
destination = None
|
||||||
|
else:
|
||||||
|
method = (
|
||||||
|
celery.control.cancel_consumer if draining else celery.control.add_consumer
|
||||||
|
)
|
||||||
|
destination = [identity.node_id]
|
||||||
|
for queue in identity.queues:
|
||||||
|
if destination is None:
|
||||||
|
method(queue)
|
||||||
|
else:
|
||||||
|
method(queue, destination=destination)
|
||||||
|
_worker_draining = draining
|
||||||
|
|
||||||
|
|
||||||
|
@worker_shutdown.connect
|
||||||
|
def _stop_worker_runtime(sender=None, **_kwargs) -> None:
|
||||||
|
identity = _worker_identity
|
||||||
|
if identity is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
stop_runtime_node(session, identity)
|
||||||
|
session.commit()
|
||||||
|
except Exception: # noqa: BLE001 - shutdown must continue
|
||||||
|
logger.exception(
|
||||||
|
"worker runtime stop marker failed node_id=%s",
|
||||||
|
identity.node_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@celery.task(name="multimailer.ping")
|
@celery.task(name="govoplan.ping")
|
||||||
def ping():
|
def ping():
|
||||||
return "pong"
|
return "pong"
|
||||||
|
|
||||||
|
|
||||||
def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider:
|
@celery.task(
|
||||||
|
bind=True,
|
||||||
|
name="govoplan.worker.acceptance",
|
||||||
|
max_retries=1,
|
||||||
|
acks_late=True,
|
||||||
|
reject_on_worker_lost=True,
|
||||||
|
track_started=True,
|
||||||
|
)
|
||||||
|
def worker_acceptance_probe(
|
||||||
|
task,
|
||||||
|
probe_id: str,
|
||||||
|
*,
|
||||||
|
mode: str = "complete",
|
||||||
|
delay_seconds: float = 0.0,
|
||||||
|
track_delivery: bool = False,
|
||||||
|
):
|
||||||
|
"""Exercise broker delivery semantics without touching business data."""
|
||||||
|
|
||||||
|
if mode not in {"complete", "retry_once"}:
|
||||||
|
raise ValueError(f"Unsupported worker acceptance mode: {mode}")
|
||||||
|
normalized_probe_id = str(probe_id).strip()
|
||||||
|
if not normalized_probe_id or len(normalized_probe_id) > 120:
|
||||||
|
raise ValueError("Worker acceptance probe_id must contain 1-120 characters")
|
||||||
|
bounded_delay = max(0.0, min(float(delay_seconds), 120.0))
|
||||||
|
delivery_count = _worker_acceptance_delivery_count(
|
||||||
|
task,
|
||||||
|
normalized_probe_id,
|
||||||
|
) if track_delivery else None
|
||||||
|
task.update_state(
|
||||||
|
state="PROGRESS",
|
||||||
|
meta={
|
||||||
|
"phase": "started",
|
||||||
|
"probe_id": normalized_probe_id,
|
||||||
|
"retries": int(task.request.retries or 0),
|
||||||
|
"delivery_count": delivery_count,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if mode == "retry_once" and int(task.request.retries or 0) == 0:
|
||||||
|
raise task.retry(countdown=0.2)
|
||||||
|
if bounded_delay:
|
||||||
|
time.sleep(bounded_delay)
|
||||||
|
delivery_info = task.request.delivery_info or {}
|
||||||
|
return {
|
||||||
|
"probe_id": normalized_probe_id,
|
||||||
|
"mode": mode,
|
||||||
|
"retries": int(task.request.retries or 0),
|
||||||
|
"redelivered": bool(delivery_info.get("redelivered")),
|
||||||
|
"delivery_count": delivery_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _worker_acceptance_delivery_count(task, probe_id: str) -> int:
|
||||||
|
client = getattr(task.backend, "client", None)
|
||||||
|
if client is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Worker acceptance delivery tracking requires a Redis result backend"
|
||||||
|
)
|
||||||
|
key = f"govoplan:worker-acceptance:{probe_id}"
|
||||||
|
count = int(client.incr(key))
|
||||||
|
client.expire(key, 60 * 60)
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def _platform_registry() -> PlatformRegistry:
|
||||||
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
|
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
|
||||||
candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules)
|
candidate_modules = startup_candidate_module_ids(
|
||||||
available_modules = available_module_manifests(enabled_modules=candidate_modules, ignore_load_errors=True)
|
settings.enabled_modules, raw_enabled_modules
|
||||||
enabled_modules = load_startup_enabled_modules(settings.enabled_modules, available=available_modules)
|
)
|
||||||
|
available_modules = available_module_manifests(
|
||||||
|
enabled_modules=candidate_modules, ignore_load_errors=True
|
||||||
|
)
|
||||||
|
enabled_modules = load_startup_enabled_modules(
|
||||||
|
settings.enabled_modules, available=available_modules
|
||||||
|
)
|
||||||
registry = build_platform_registry(enabled_modules)
|
registry = build_platform_registry(enabled_modules)
|
||||||
context = ModuleContext(registry=registry, settings=settings)
|
context = ModuleContext(registry=registry, settings=settings)
|
||||||
configure_runtime(context)
|
configure_runtime(context)
|
||||||
registry.configure_capability_context(context)
|
registry.configure_capability_context(context)
|
||||||
|
return registry
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider:
|
||||||
|
registry = _platform_registry()
|
||||||
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_DELIVERY_TASKS)
|
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_DELIVERY_TASKS)
|
||||||
if not isinstance(capability, CampaignDeliveryTaskProvider):
|
if not isinstance(capability, CampaignDeliveryTaskProvider):
|
||||||
raise RuntimeError("Campaign delivery task capability is invalid")
|
raise RuntimeError("Campaign delivery task capability is invalid")
|
||||||
return capability
|
return capability
|
||||||
|
|
||||||
|
|
||||||
@celery.task(name="multimailer.send_email", bind=True, max_retries=0)
|
def _notification_dispatch() -> NotificationDispatchProvider:
|
||||||
|
registry = _platform_registry()
|
||||||
|
capability = registry.require_capability(CAPABILITY_NOTIFICATIONS_DISPATCH)
|
||||||
|
if not isinstance(capability, NotificationDispatchProvider):
|
||||||
|
raise RuntimeError("Notification dispatch capability is invalid")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _calendar_outbox() -> CalendarOutboxProvider | None:
|
||||||
|
registry = _platform_registry()
|
||||||
|
if not registry.has_capability(CAPABILITY_CALENDAR_OUTBOX):
|
||||||
|
return None
|
||||||
|
capability = registry.require_capability(CAPABILITY_CALENDAR_OUTBOX)
|
||||||
|
if not isinstance(capability, CalendarOutboxProvider):
|
||||||
|
raise RuntimeError("Calendar outbox capability is invalid")
|
||||||
|
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 _mail_bounce_processing() -> MailBounceProcessingProvider | None:
|
||||||
|
registry = _platform_registry()
|
||||||
|
if not registry.has_capability(CAPABILITY_MAIL_BOUNCE_PROCESSING):
|
||||||
|
return None
|
||||||
|
capability = registry.require_capability(CAPABILITY_MAIL_BOUNCE_PROCESSING)
|
||||||
|
if not isinstance(capability, MailBounceProcessingProvider):
|
||||||
|
raise RuntimeError("Mail bounce-processing 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 _workflow_trigger_dispatcher(
|
||||||
|
registry: PlatformRegistry | None = None,
|
||||||
|
) -> WorkflowTriggerDispatcher | None:
|
||||||
|
registry = registry or _platform_registry()
|
||||||
|
if not registry.has_capability(CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER):
|
||||||
|
return None
|
||||||
|
capability = registry.require_capability(CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER)
|
||||||
|
if not isinstance(capability, WorkflowTriggerDispatcher):
|
||||||
|
raise RuntimeError("Workflow trigger dispatcher 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):
|
def send_email(self, job_id: str):
|
||||||
"""Send one explicitly queued campaign job.
|
"""Send one explicitly queued campaign job.
|
||||||
|
|
||||||
@@ -62,10 +540,14 @@ def send_email(self, job_id: str):
|
|||||||
from govoplan_core.db.session import get_database
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
with get_database().SessionLocal() as session:
|
with get_database().SessionLocal() as session:
|
||||||
return dict(_campaign_delivery_tasks().send_campaign_job(session, job_id=job_id, enqueue_imap_task=True))
|
return dict(
|
||||||
|
_campaign_delivery_tasks().send_campaign_job(
|
||||||
|
session, job_id=job_id, enqueue_imap_task=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@celery.task(name="multimailer.append_sent", bind=True, max_retries=None)
|
@celery.task(name="govoplan.campaigns.append_sent", bind=True, max_retries=None)
|
||||||
def append_sent(self, job_id: str):
|
def append_sent(self, job_id: str):
|
||||||
"""Append the exact sent MIME to the configured IMAP Sent folder."""
|
"""Append the exact sent MIME to the configured IMAP Sent folder."""
|
||||||
|
|
||||||
@@ -73,8 +555,398 @@ def append_sent(self, job_id: str):
|
|||||||
|
|
||||||
with get_database().SessionLocal() as session:
|
with get_database().SessionLocal() as session:
|
||||||
try:
|
try:
|
||||||
return dict(_campaign_delivery_tasks().append_sent_for_job(session, job_id=job_id))
|
return dict(
|
||||||
|
_campaign_delivery_tasks().append_sent_for_job(session, job_id=job_id)
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if getattr(exc, "temporary", None) is True:
|
if getattr(exc, "temporary", None) is True:
|
||||||
raise self.retry(exc=exc, countdown=300)
|
raise self.retry(exc=exc, countdown=300)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(name="govoplan.notifications.deliver", bind=True, max_retries=0)
|
||||||
|
def deliver_notification(self, notification_id: str):
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
result = dict(
|
||||||
|
_notification_dispatch().deliver_notification(
|
||||||
|
session, notification_id=notification_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(name="govoplan.notifications.deliver_pending", bind=True, max_retries=0)
|
||||||
|
def deliver_pending_notifications(self, tenant_id: str | None = None, limit: int = 50):
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
result = dict(
|
||||||
|
_notification_dispatch().deliver_pending(
|
||||||
|
session, tenant_id=tenant_id, limit=limit
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
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.mail.scan_bounces", bind=True, max_retries=0)
|
||||||
|
def scan_mail_bounces(
|
||||||
|
self,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
limit: int = 250,
|
||||||
|
):
|
||||||
|
"""Read configured DSN folders without mutating provider mailbox flags."""
|
||||||
|
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
provider = _mail_bounce_processing()
|
||||||
|
if provider is None:
|
||||||
|
return {
|
||||||
|
"sources": 0,
|
||||||
|
"processed_messages": 0,
|
||||||
|
"observations": 0,
|
||||||
|
"failures": [],
|
||||||
|
}
|
||||||
|
result = dict(provider.scan_due(session, tenant_id=tenant_id, limit=limit))
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@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."""
|
||||||
|
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
provider = _calendar_outbox()
|
||||||
|
if provider is None:
|
||||||
|
return {
|
||||||
|
"processed": 0,
|
||||||
|
"succeeded": 0,
|
||||||
|
"retrying": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"operations": [],
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
workflow_dispatcher = _workflow_trigger_dispatcher(registry)
|
||||||
|
consumers: list[DurableEventConsumer] = []
|
||||||
|
if dataflow_dispatcher is not None:
|
||||||
|
|
||||||
|
def deliver_to_dataflow(
|
||||||
|
event: PlatformEvent,
|
||||||
|
_delivery_key: str,
|
||||||
|
) -> None:
|
||||||
|
dataflow_dispatcher.ingest_event(
|
||||||
|
session,
|
||||||
|
event=event,
|
||||||
|
)
|
||||||
|
|
||||||
|
consumers.append(
|
||||||
|
DurableEventConsumer(
|
||||||
|
consumer_id="dataflow.event-triggers.v1",
|
||||||
|
event_types=frozenset({"*"}),
|
||||||
|
classifications=frozenset({"public", "internal"}),
|
||||||
|
handler=deliver_to_dataflow,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if workflow_dispatcher is not None:
|
||||||
|
|
||||||
|
def deliver_to_workflow(
|
||||||
|
event: PlatformEvent,
|
||||||
|
_delivery_key: str,
|
||||||
|
) -> None:
|
||||||
|
workflow_dispatcher.ingest_event(
|
||||||
|
session,
|
||||||
|
event=event,
|
||||||
|
)
|
||||||
|
|
||||||
|
consumers.append(
|
||||||
|
DurableEventConsumer(
|
||||||
|
consumer_id="workflow.event-triggers.v1",
|
||||||
|
event_types=frozenset({"*"}),
|
||||||
|
classifications=frozenset({"public", "internal"}),
|
||||||
|
handler=deliver_to_workflow,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = dict(
|
||||||
|
outbox.dispatch_pending(
|
||||||
|
session,
|
||||||
|
consumers=tuple(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
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from govoplan_core.core.runtime_coordination import (
|
||||||
|
LeaseClaim,
|
||||||
|
acquire_lease,
|
||||||
|
release_lease,
|
||||||
|
renew_lease,
|
||||||
|
runtime_identity,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.session import configure_database, get_database
|
||||||
|
from govoplan_core.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Run one process while holding a database-fenced deployment lease."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--resource", required=True, help="Stable cluster-wide lease key."
|
||||||
|
)
|
||||||
|
parser.add_argument("--ttl-seconds", type=int, default=60)
|
||||||
|
parser.add_argument("--renew-seconds", type=int, default=15)
|
||||||
|
parser.add_argument("--wait-seconds", type=int, default=0)
|
||||||
|
parser.add_argument("command", nargs=argparse.REMAINDER)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
command = list(args.command)
|
||||||
|
if command and command[0] == "--":
|
||||||
|
command.pop(0)
|
||||||
|
if not command:
|
||||||
|
raise SystemExit("fenced-run requires a command after --")
|
||||||
|
if args.ttl_seconds < 10:
|
||||||
|
raise SystemExit("--ttl-seconds must be at least 10")
|
||||||
|
if args.renew_seconds < 2 or args.renew_seconds * 2 >= args.ttl_seconds:
|
||||||
|
raise SystemExit("--renew-seconds must be less than half the lease TTL")
|
||||||
|
configure_database(settings.database_url)
|
||||||
|
identity = runtime_identity(
|
||||||
|
settings,
|
||||||
|
software_version=_core_version(),
|
||||||
|
role=str(settings.runtime_role or "deployment"),
|
||||||
|
)
|
||||||
|
claim = _wait_for_lease(
|
||||||
|
resource=args.resource,
|
||||||
|
identity=identity,
|
||||||
|
ttl_seconds=args.ttl_seconds,
|
||||||
|
wait_seconds=max(0, args.wait_seconds),
|
||||||
|
)
|
||||||
|
if claim is None:
|
||||||
|
print(
|
||||||
|
f"lease unavailable: {args.resource}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 75
|
||||||
|
|
||||||
|
process = subprocess.Popen(command) # noqa: S603 - argv is an operator-owned container command
|
||||||
|
stop = threading.Event()
|
||||||
|
fence_lost = threading.Event()
|
||||||
|
renewer = threading.Thread(
|
||||||
|
target=_renew_loop,
|
||||||
|
kwargs={
|
||||||
|
"claim": claim,
|
||||||
|
"ttl_seconds": args.ttl_seconds,
|
||||||
|
"renew_seconds": args.renew_seconds,
|
||||||
|
"stop": stop,
|
||||||
|
"fence_lost": fence_lost,
|
||||||
|
"process": process,
|
||||||
|
},
|
||||||
|
daemon=True,
|
||||||
|
name=f"govoplan-fence:{args.resource}",
|
||||||
|
)
|
||||||
|
renewer.start()
|
||||||
|
previous_handlers = _forward_signals(process)
|
||||||
|
try:
|
||||||
|
return_code = process.wait()
|
||||||
|
finally:
|
||||||
|
stop.set()
|
||||||
|
renewer.join(timeout=args.renew_seconds + 2)
|
||||||
|
_restore_signals(previous_handlers)
|
||||||
|
_release(claim)
|
||||||
|
if fence_lost.is_set():
|
||||||
|
return 74
|
||||||
|
return int(return_code)
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_lease(
|
||||||
|
*,
|
||||||
|
resource: str,
|
||||||
|
identity,
|
||||||
|
ttl_seconds: int,
|
||||||
|
wait_seconds: int,
|
||||||
|
) -> LeaseClaim | None:
|
||||||
|
deadline = time.monotonic() + wait_seconds
|
||||||
|
while True:
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
claim = acquire_lease(
|
||||||
|
session,
|
||||||
|
installation_id=identity.installation_id,
|
||||||
|
resource_key=resource,
|
||||||
|
holder_node_id=identity.node_id,
|
||||||
|
holder_incarnation=identity.incarnation,
|
||||||
|
ttl_seconds=ttl_seconds,
|
||||||
|
metadata={"role": identity.role},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
if claim is not None or time.monotonic() >= deadline:
|
||||||
|
return claim
|
||||||
|
time.sleep(min(2, max(0.1, deadline - time.monotonic())))
|
||||||
|
|
||||||
|
|
||||||
|
def _renew_loop(
|
||||||
|
*,
|
||||||
|
claim: LeaseClaim,
|
||||||
|
ttl_seconds: int,
|
||||||
|
renew_seconds: int,
|
||||||
|
stop: threading.Event,
|
||||||
|
fence_lost: threading.Event,
|
||||||
|
process: subprocess.Popen[bytes],
|
||||||
|
) -> None:
|
||||||
|
active_claim = claim
|
||||||
|
while not stop.wait(renew_seconds):
|
||||||
|
try:
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
active_claim = renew_lease(
|
||||||
|
session,
|
||||||
|
active_claim,
|
||||||
|
ttl_seconds=ttl_seconds,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except Exception: # noqa: BLE001 - any renewal failure loses authority
|
||||||
|
fence_lost.set()
|
||||||
|
_terminate_process(process)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _terminate_process(
|
||||||
|
process: subprocess.Popen[bytes],
|
||||||
|
*,
|
||||||
|
timeout_seconds: float = 5.0,
|
||||||
|
) -> None:
|
||||||
|
if process.poll() is not None:
|
||||||
|
return
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
process.wait(timeout=timeout_seconds)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
process.wait(timeout=timeout_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def _release(claim: LeaseClaim) -> None:
|
||||||
|
try:
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
release_lease(session, claim)
|
||||||
|
session.commit()
|
||||||
|
except Exception: # noqa: BLE001 - authority is already lost; release is best effort
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _forward_signals(
|
||||||
|
process: subprocess.Popen[bytes],
|
||||||
|
) -> dict[int, signal.Handlers]:
|
||||||
|
previous: dict[int, signal.Handlers] = {}
|
||||||
|
|
||||||
|
def forward(signum, _frame) -> None:
|
||||||
|
if process.poll() is None:
|
||||||
|
process.send_signal(signum)
|
||||||
|
|
||||||
|
for signum in (signal.SIGTERM, signal.SIGINT):
|
||||||
|
previous[signum] = signal.getsignal(signum)
|
||||||
|
signal.signal(signum, forward)
|
||||||
|
return previous
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_signals(previous: dict[int, signal.Handlers]) -> None:
|
||||||
|
for signum, handler in previous.items():
|
||||||
|
signal.signal(signum, handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _core_version() -> str:
|
||||||
|
try:
|
||||||
|
return version("govoplan-core")
|
||||||
|
except PackageNotFoundError:
|
||||||
|
return "development"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -12,6 +12,7 @@ from govoplan_core.db.migrations import (
|
|||||||
migrate_database,
|
migrate_database,
|
||||||
run_registered_module_migration_tasks,
|
run_registered_module_migration_tasks,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.db.migration_lock import deployment_migration_lock
|
||||||
from govoplan_core.db.session import configure_database, get_database
|
from govoplan_core.db.session import configure_database, get_database
|
||||||
from govoplan_core.settings import settings
|
from govoplan_core.settings import settings
|
||||||
|
|
||||||
@@ -28,6 +29,12 @@ def main() -> None:
|
|||||||
parser.add_argument("--enabled-module", action="append", default=[], help="Target enabled module id used to discover module migrations; may be repeated.")
|
parser.add_argument("--enabled-module", action="append", default=[], help="Target enabled module id used to discover module migrations; may be repeated.")
|
||||||
parser.add_argument("--migration-module", action="append", default=[], help="Module id whose migration heads should be upgraded in this order before final heads.")
|
parser.add_argument("--migration-module", action="append", default=[], help="Module id whose migration heads should be upgraded in this order before final heads.")
|
||||||
parser.add_argument("--migration-task-record-output", type=Path, help="Write executed module migration task records to this JSON file.")
|
parser.add_argument("--migration-task-record-output", type=Path, help="Write executed module migration task records to this JSON file.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--migration-lock-timeout-seconds",
|
||||||
|
type=float,
|
||||||
|
default=900.0,
|
||||||
|
help="Maximum wait for the deployment-wide PostgreSQL advisory lock.",
|
||||||
|
)
|
||||||
parser.add_argument("--with-dev-data", action="store_true", help="Create default tenant/user/roles and a development API key")
|
parser.add_argument("--with-dev-data", action="store_true", help="Create default tenant/user/roles and a development API key")
|
||||||
parser.add_argument("--dev-api-key", default=settings.dev_bootstrap_api_key, help="Development API key secret to create")
|
parser.add_argument("--dev-api-key", default=settings.dev_bootstrap_api_key, help="Development API key secret to create")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
@@ -37,6 +44,12 @@ def main() -> None:
|
|||||||
migration_order = tuple(args.migration_module) if args.migration_module else None
|
migration_order = tuple(args.migration_module) if args.migration_module else None
|
||||||
task_records: list[dict[str, object]] = []
|
task_records: list[dict[str, object]] = []
|
||||||
try:
|
try:
|
||||||
|
with deployment_migration_lock(
|
||||||
|
args.database_url,
|
||||||
|
installation_id=settings.installation_id,
|
||||||
|
migration_track=args.migration_track,
|
||||||
|
timeout_seconds=args.migration_lock_timeout_seconds,
|
||||||
|
):
|
||||||
_run_migration_tasks(
|
_run_migration_tasks(
|
||||||
task_records,
|
task_records,
|
||||||
database_url=args.database_url,
|
database_url=args.database_url,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from govoplan_core.core.module_installer import (
|
from govoplan_core.core.module_installer import (
|
||||||
ModuleInstallerError,
|
ModuleInstallerError,
|
||||||
@@ -27,6 +26,13 @@ from govoplan_core.core.module_installer import (
|
|||||||
update_module_installer_request,
|
update_module_installer_request,
|
||||||
update_module_installer_daemon_status,
|
update_module_installer_daemon_status,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.module_installer_notifications import (
|
||||||
|
build_runtime_notification_registry,
|
||||||
|
emit_module_installer_notification,
|
||||||
|
installer_notification_body,
|
||||||
|
installer_notification_priority,
|
||||||
|
installer_notification_subject,
|
||||||
|
)
|
||||||
from govoplan_core.core.module_license import issue_module_license, module_license_diagnostics
|
from govoplan_core.core.module_license import issue_module_license, module_license_diagnostics
|
||||||
from govoplan_core.core.module_package_catalog import sign_module_package_catalog, validate_module_package_catalog
|
from govoplan_core.core.module_package_catalog import sign_module_package_catalog, validate_module_package_catalog
|
||||||
from govoplan_core.core.module_management import (
|
from govoplan_core.core.module_management import (
|
||||||
@@ -39,7 +45,7 @@ from govoplan_core.server.registry import available_module_manifests
|
|||||||
from govoplan_core.settings import settings
|
from govoplan_core.settings import settings
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def _build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description="Preflight, apply, or roll back a GovOPlaN module package install plan.")
|
parser = argparse.ArgumentParser(description="Preflight, apply, or roll back a GovOPlaN module package install plan.")
|
||||||
parser.add_argument("--database-url", default=settings.database_url, help="Database URL containing system_settings.")
|
parser.add_argument("--database-url", default=settings.database_url, help="Database URL containing system_settings.")
|
||||||
parser.add_argument("--runtime-dir", type=Path, help="Directory for installer locks and run snapshots.")
|
parser.add_argument("--runtime-dir", type=Path, help="Directory for installer locks and run snapshots.")
|
||||||
@@ -95,11 +101,43 @@ def main() -> int:
|
|||||||
parser.add_argument("--license-signing-private-key", type=Path, help="PEM Ed25519 private key for --issue-license.")
|
parser.add_argument("--license-signing-private-key", type=Path, help="PEM Ed25519 private key for --issue-license.")
|
||||||
parser.add_argument("--license-issuer", help="Optional issuer string for --issue-license.")
|
parser.add_argument("--license-issuer", help="Optional issuer string for --issue-license.")
|
||||||
parser.add_argument("--license-notes", help="Optional operator note for --issue-license.")
|
parser.add_argument("--license-notes", help="Optional operator note for --issue-license.")
|
||||||
args = parser.parse_args()
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = _build_parser().parse_args()
|
||||||
runtime_dir = args.runtime_dir or default_installer_runtime_dir(args.database_url)
|
runtime_dir = args.runtime_dir or default_installer_runtime_dir(args.database_url)
|
||||||
try:
|
try:
|
||||||
|
return _dispatch_command(args=args, runtime_dir=runtime_dir)
|
||||||
|
except ModuleInstallerError as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
|
||||||
|
for handler in (
|
||||||
|
_handle_license_command,
|
||||||
|
_handle_catalog_command,
|
||||||
|
_handle_runtime_command,
|
||||||
|
_handle_queue_command,
|
||||||
|
_handle_run_history_command,
|
||||||
|
):
|
||||||
|
result = handler(args=args, runtime_dir=runtime_dir)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
return _handle_install_command(args=args, runtime_dir=runtime_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_license_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
|
||||||
|
del runtime_dir
|
||||||
if args.issue_license:
|
if args.issue_license:
|
||||||
|
return _issue_license_command(args)
|
||||||
|
if args.validate_license is not None:
|
||||||
|
return _validate_license_command(args)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_license_command(args: argparse.Namespace) -> int:
|
||||||
if not args.license_id or not args.license_subject or not args.license_valid_until:
|
if not args.license_id or not args.license_subject or not args.license_valid_until:
|
||||||
raise ModuleInstallerError("--issue-license requires --license-id, --license-subject, and --license-valid-until.")
|
raise ModuleInstallerError("--issue-license requires --license-id, --license-subject, and --license-valid-until.")
|
||||||
if not args.license_signing_key_id or not args.license_signing_private_key:
|
if not args.license_signing_key_id or not args.license_signing_private_key:
|
||||||
@@ -134,7 +172,9 @@ def main() -> int:
|
|||||||
output_format=args.format,
|
output_format=args.format,
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
if args.validate_license is not None:
|
|
||||||
|
|
||||||
|
def _validate_license_command(args: argparse.Namespace) -> int:
|
||||||
path = Path(args.validate_license).expanduser() if args.validate_license else None
|
path = Path(args.validate_license).expanduser() if args.validate_license else None
|
||||||
result = module_license_diagnostics(
|
result = module_license_diagnostics(
|
||||||
path,
|
path,
|
||||||
@@ -144,7 +184,18 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
_print_result(result, output_format=args.format)
|
_print_result(result, output_format=args.format)
|
||||||
return 0 if result.get("valid") and not result.get("missing_features") else 1
|
return 0 if result.get("valid") and not result.get("missing_features") else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_catalog_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
|
||||||
|
del runtime_dir
|
||||||
if args.sign_package_catalog:
|
if args.sign_package_catalog:
|
||||||
|
return _sign_package_catalog_command(args)
|
||||||
|
if args.validate_package_catalog is not None:
|
||||||
|
return _validate_package_catalog_command(args)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sign_package_catalog_command(args: argparse.Namespace) -> int:
|
||||||
if not args.catalog_signing_key_id or not args.catalog_signing_private_key:
|
if not args.catalog_signing_key_id or not args.catalog_signing_private_key:
|
||||||
raise ModuleInstallerError("--sign-package-catalog requires --catalog-signing-key-id and --catalog-signing-private-key.")
|
raise ModuleInstallerError("--sign-package-catalog requires --catalog-signing-key-id and --catalog-signing-private-key.")
|
||||||
path = sign_module_package_catalog(
|
path = sign_module_package_catalog(
|
||||||
@@ -155,7 +206,9 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
_print_result({"signed": True, "path": str(path), "key_id": args.catalog_signing_key_id}, output_format=args.format)
|
_print_result({"signed": True, "path": str(path), "key_id": args.catalog_signing_key_id}, output_format=args.format)
|
||||||
return 0
|
return 0
|
||||||
if args.validate_package_catalog is not None:
|
|
||||||
|
|
||||||
|
def _validate_package_catalog_command(args: argparse.Namespace) -> int:
|
||||||
path = Path(args.validate_package_catalog).expanduser() if args.validate_package_catalog else None
|
path = Path(args.validate_package_catalog).expanduser() if args.validate_package_catalog else None
|
||||||
result = validate_module_package_catalog(
|
result = validate_module_package_catalog(
|
||||||
path,
|
path,
|
||||||
@@ -165,11 +218,18 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
_print_result(result, output_format=args.format)
|
_print_result(result, output_format=args.format)
|
||||||
return 0 if result.get("valid") else 1
|
return 0 if result.get("valid") else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_runtime_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
|
||||||
if args.daemon_status:
|
if args.daemon_status:
|
||||||
_print_result(module_installer_daemon_status(runtime_dir=runtime_dir), output_format=args.format)
|
_print_result(module_installer_daemon_status(runtime_dir=runtime_dir), output_format=args.format)
|
||||||
return 0
|
return 0
|
||||||
if args.daemon or args.daemon_once:
|
if args.daemon or args.daemon_once:
|
||||||
return _run_daemon(args=args, runtime_dir=runtime_dir)
|
return _run_daemon(args=args, runtime_dir=runtime_dir)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_queue_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
|
||||||
if args.list_requests:
|
if args.list_requests:
|
||||||
_print_result({
|
_print_result({
|
||||||
"requests": list(list_module_installer_requests(runtime_dir=runtime_dir)),
|
"requests": list(list_module_installer_requests(runtime_dir=runtime_dir)),
|
||||||
@@ -186,13 +246,13 @@ def main() -> int:
|
|||||||
_print_result(retry_module_installer_request(runtime_dir=runtime_dir, request_id=args.retry_request, requested_by="cli"), output_format=args.format)
|
_print_result(retry_module_installer_request(runtime_dir=runtime_dir, request_id=args.retry_request, requested_by="cli"), output_format=args.format)
|
||||||
return 0
|
return 0
|
||||||
if args.enqueue_supervised:
|
if args.enqueue_supervised:
|
||||||
request = queue_module_installer_request(
|
request = queue_module_installer_request(runtime_dir=runtime_dir, requested_by="cli", options=_request_options_from_args(args))
|
||||||
runtime_dir=runtime_dir,
|
|
||||||
requested_by="cli",
|
|
||||||
options=_request_options_from_args(args),
|
|
||||||
)
|
|
||||||
_print_result(request, output_format=args.format)
|
_print_result(request, output_format=args.format)
|
||||||
return 0
|
return 0
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_run_history_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
|
||||||
if args.list_runs:
|
if args.list_runs:
|
||||||
_print_result({
|
_print_result({
|
||||||
"runs": list(list_module_installer_runs(runtime_dir=runtime_dir)),
|
"runs": list(list_module_installer_runs(runtime_dir=runtime_dir)),
|
||||||
@@ -206,6 +266,11 @@ def main() -> int:
|
|||||||
_print_result(module_installer_lock_status(runtime_dir=runtime_dir), output_format=args.format)
|
_print_result(module_installer_lock_status(runtime_dir=runtime_dir), output_format=args.format)
|
||||||
return 0
|
return 0
|
||||||
if args.rollback:
|
if args.rollback:
|
||||||
|
return _rollback_command(args=args, runtime_dir=runtime_dir)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _rollback_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
|
||||||
result = rollback_module_install_run(
|
result = rollback_module_install_run(
|
||||||
run_id=args.rollback,
|
run_id=args.rollback,
|
||||||
runtime_dir=runtime_dir,
|
runtime_dir=runtime_dir,
|
||||||
@@ -217,6 +282,8 @@ def main() -> int:
|
|||||||
_print_result(result.as_dict(), output_format=args.format)
|
_print_result(result.as_dict(), output_format=args.format)
|
||||||
return 0 if result.return_code == 0 else 1
|
return 0 if result.return_code == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_install_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
|
||||||
configure_database(str(args.database_url))
|
configure_database(str(args.database_url))
|
||||||
available = available_module_manifests(ignore_load_errors=True)
|
available = available_module_manifests(ignore_load_errors=True)
|
||||||
with get_database().session() as session:
|
with get_database().session() as session:
|
||||||
@@ -224,6 +291,21 @@ def main() -> int:
|
|||||||
desired = saved_desired_enabled_modules(session, configured)
|
desired = saved_desired_enabled_modules(session, configured)
|
||||||
plan = saved_module_install_plan(session)
|
plan = saved_module_install_plan(session)
|
||||||
if args.supervise:
|
if args.supervise:
|
||||||
|
return _supervise_install_command(args=args, runtime_dir=runtime_dir, session=session, available=available, desired=desired, plan=plan)
|
||||||
|
if args.apply or args.dry_run:
|
||||||
|
return _apply_install_command(args=args, runtime_dir=runtime_dir, session=session, available=available, desired=desired, plan=plan)
|
||||||
|
return _preflight_install_command(args=args, runtime_dir=runtime_dir, session=session, available=available, desired=desired, plan=plan)
|
||||||
|
|
||||||
|
|
||||||
|
def _supervise_install_command(
|
||||||
|
*,
|
||||||
|
args: argparse.Namespace,
|
||||||
|
runtime_dir: Path,
|
||||||
|
session: object,
|
||||||
|
available: object,
|
||||||
|
desired: object,
|
||||||
|
plan: object,
|
||||||
|
) -> int:
|
||||||
result = supervise_module_install_plan(
|
result = supervise_module_install_plan(
|
||||||
session=session,
|
session=session,
|
||||||
plan=plan,
|
plan=plan,
|
||||||
@@ -248,7 +330,17 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
_print_result(result.as_dict(), output_format=args.format)
|
_print_result(result.as_dict(), output_format=args.format)
|
||||||
return 0 if result.return_code == 0 else 1
|
return 0 if result.return_code == 0 else 1
|
||||||
if args.apply or args.dry_run:
|
|
||||||
|
|
||||||
|
def _apply_install_command(
|
||||||
|
*,
|
||||||
|
args: argparse.Namespace,
|
||||||
|
runtime_dir: Path,
|
||||||
|
session: object,
|
||||||
|
available: object,
|
||||||
|
desired: object,
|
||||||
|
plan: object,
|
||||||
|
) -> int:
|
||||||
result = run_module_install_plan(
|
result = run_module_install_plan(
|
||||||
session=session,
|
session=session,
|
||||||
plan=plan,
|
plan=plan,
|
||||||
@@ -271,6 +363,16 @@ def main() -> int:
|
|||||||
_print_result(result.as_dict(), output_format=args.format)
|
_print_result(result.as_dict(), output_format=args.format)
|
||||||
return 0 if result.return_code == 0 else 1
|
return 0 if result.return_code == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _preflight_install_command(
|
||||||
|
*,
|
||||||
|
args: argparse.Namespace,
|
||||||
|
runtime_dir: Path,
|
||||||
|
session: object,
|
||||||
|
available: object,
|
||||||
|
desired: object,
|
||||||
|
plan: object,
|
||||||
|
) -> int:
|
||||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
from govoplan_core.core.maintenance import saved_maintenance_mode
|
||||||
|
|
||||||
maintenance = saved_maintenance_mode(session)
|
maintenance = saved_maintenance_mode(session)
|
||||||
@@ -286,9 +388,6 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
_print_preflight(preflight.as_dict(), output_format=args.format)
|
_print_preflight(preflight.as_dict(), output_format=args.format)
|
||||||
return 0 if preflight.allowed else 1
|
return 0 if preflight.allowed else 1
|
||||||
except ModuleInstallerError as exc:
|
|
||||||
print(f"error: {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
def _default_webui_root() -> Path:
|
def _default_webui_root() -> Path:
|
||||||
@@ -355,6 +454,7 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
|
|||||||
request_id = str(request["request_id"])
|
request_id = str(request["request_id"])
|
||||||
options = request.get("options") if isinstance(request.get("options"), dict) else {}
|
options = request.get("options") if isinstance(request.get("options"), dict) else {}
|
||||||
configure_database(str(args.database_url))
|
configure_database(str(args.database_url))
|
||||||
|
_emit_installer_daemon_notification(request, event_kind="module_installer.request.running")
|
||||||
try:
|
try:
|
||||||
available = available_module_manifests(ignore_load_errors=True)
|
available = available_module_manifests(ignore_load_errors=True)
|
||||||
with get_database().session() as session:
|
with get_database().session() as session:
|
||||||
@@ -384,7 +484,7 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
|
|||||||
health_interval_seconds=_float_option(options, "health_interval_seconds", args.health_interval_seconds),
|
health_interval_seconds=_float_option(options, "health_interval_seconds", args.health_interval_seconds),
|
||||||
request_context=_request_context(request),
|
request_context=_request_context(request),
|
||||||
)
|
)
|
||||||
update_module_installer_request(
|
updated_request = update_module_installer_request(
|
||||||
runtime_dir=runtime_dir,
|
runtime_dir=runtime_dir,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
patch={
|
patch={
|
||||||
@@ -393,8 +493,12 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
|
|||||||
"result": result.as_dict(),
|
"result": result.as_dict(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
_emit_installer_daemon_notification(
|
||||||
|
updated_request,
|
||||||
|
event_kind="module_installer.request.completed" if result.return_code == 0 else "module_installer.request.failed",
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
update_module_installer_request(
|
updated_request = update_module_installer_request(
|
||||||
runtime_dir=runtime_dir,
|
runtime_dir=runtime_dir,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
patch={
|
patch={
|
||||||
@@ -403,6 +507,34 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
|
|||||||
"error": str(exc),
|
"error": str(exc),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
_emit_installer_daemon_notification(updated_request, event_kind="module_installer.request.failed")
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_installer_daemon_notification(request: dict[str, object], *, event_kind: str) -> None:
|
||||||
|
tenant_id = str(request.get("tenant_id") or "")
|
||||||
|
if not tenant_id:
|
||||||
|
return
|
||||||
|
registry = build_runtime_notification_registry(settings)
|
||||||
|
if registry is None:
|
||||||
|
return
|
||||||
|
status_value = str(request.get("status") or event_kind.rsplit(".", 1)[-1])
|
||||||
|
try:
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
emitted = emit_module_installer_notification(
|
||||||
|
session=session,
|
||||||
|
registry=registry,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
request=request,
|
||||||
|
event_kind=event_kind,
|
||||||
|
subject=installer_notification_subject(event_kind, request),
|
||||||
|
body_text=installer_notification_body(event_kind, request),
|
||||||
|
recipient_id=str(request.get("requested_by") or "") or None,
|
||||||
|
priority=installer_notification_priority(status_value),
|
||||||
|
)
|
||||||
|
if emitted:
|
||||||
|
session.commit()
|
||||||
|
except Exception: # noqa: BLE001 - notification bridge must not block installer work.
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def _request_options_from_args(args: argparse.Namespace) -> dict[str, object]:
|
def _request_options_from_args(args: argparse.Namespace) -> dict[str, object]:
|
||||||
@@ -427,6 +559,7 @@ def _request_context(request: dict[str, object]) -> dict[str, object]:
|
|||||||
context: dict[str, object] = {
|
context: dict[str, object] = {
|
||||||
"request_id": request.get("request_id"),
|
"request_id": request.get("request_id"),
|
||||||
"requested_by": request.get("requested_by"),
|
"requested_by": request.get("requested_by"),
|
||||||
|
"tenant_id": request.get("tenant_id"),
|
||||||
}
|
}
|
||||||
if request.get("retry_of"):
|
if request.get("retry_of"):
|
||||||
context["retry_of"] = request["retry_of"]
|
context["retry_of"] = request["retry_of"]
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from govoplan_core.db.migrations import (
|
||||||
|
configured_migration_heads,
|
||||||
|
database_migration_heads,
|
||||||
|
)
|
||||||
|
from govoplan_core.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Wait until the database is at this release's migration heads."
|
||||||
|
)
|
||||||
|
parser.add_argument("--database-url", default=settings.database_url)
|
||||||
|
parser.add_argument(
|
||||||
|
"--migration-track",
|
||||||
|
default=settings.migration_track,
|
||||||
|
choices=("release", "dev"),
|
||||||
|
)
|
||||||
|
parser.add_argument("--timeout-seconds", type=float, default=900.0)
|
||||||
|
parser.add_argument("--poll-seconds", type=float, default=2.0)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
if args.timeout_seconds < 0 or args.poll_seconds <= 0:
|
||||||
|
raise SystemExit("timeouts must be non-negative and polling must be positive")
|
||||||
|
expected = configured_migration_heads(
|
||||||
|
args.database_url,
|
||||||
|
migration_track=args.migration_track,
|
||||||
|
)
|
||||||
|
deadline = time.monotonic() + args.timeout_seconds
|
||||||
|
last_error = ""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
actual = database_migration_heads(args.database_url)
|
||||||
|
if actual == expected:
|
||||||
|
print("Database migration heads are ready: " + ",".join(actual))
|
||||||
|
return 0
|
||||||
|
last_error = (
|
||||||
|
f"database heads={','.join(actual) or '<none>'}; "
|
||||||
|
f"expected={','.join(expected) or '<none>'}"
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - connection may become ready later
|
||||||
|
last_error = f"{type(exc).__name__}: {exc}"
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
print(
|
||||||
|
"Database did not reach configured migration heads: " + last_error,
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 75
|
||||||
|
time.sleep(min(args.poll_seconds, max(0.05, deadline - time.monotonic())))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -6,6 +6,7 @@ from datetime import datetime
|
|||||||
from typing import Literal, Protocol, cast, runtime_checkable
|
from typing import Literal, Protocol, cast, runtime_checkable
|
||||||
|
|
||||||
from govoplan_core.core.modules import AccessDecision
|
from govoplan_core.core.modules import AccessDecision
|
||||||
|
from govoplan_core.core.institutional import GovernedContextEnvelope
|
||||||
|
|
||||||
|
|
||||||
ACCESS_MODULE_ID = "access"
|
ACCESS_MODULE_ID = "access"
|
||||||
@@ -28,6 +29,9 @@ CAPABILITY_AUDIT_RECORDER = "audit.recorder"
|
|||||||
CAPABILITY_AUDIT_RETENTION = "audit.retention"
|
CAPABILITY_AUDIT_RETENTION = "audit.retention"
|
||||||
|
|
||||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER = "auth.apiPrincipalProvider"
|
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER = "auth.apiPrincipalProvider"
|
||||||
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER = (
|
||||||
|
"auth.automationPrincipalProvider"
|
||||||
|
)
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER = "auth.principalResolver"
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER = "auth.principalResolver"
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR = "auth.permissionEvaluator"
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR = "auth.permissionEvaluator"
|
||||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER = "auth.tenantContextSwitcher"
|
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER = "auth.tenantContextSwitcher"
|
||||||
@@ -48,6 +52,7 @@ ACCESS_CAPABILITY_NAMES = frozenset(
|
|||||||
CAPABILITY_AUDIT_RECORDER,
|
CAPABILITY_AUDIT_RECORDER,
|
||||||
CAPABILITY_AUDIT_RETENTION,
|
CAPABILITY_AUDIT_RETENTION,
|
||||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
||||||
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
||||||
@@ -57,12 +62,19 @@ ACCESS_CAPABILITY_NAMES = frozenset(
|
|||||||
AUTH_CAPABILITY_NAMES = frozenset(
|
AUTH_CAPABILITY_NAMES = frozenset(
|
||||||
{
|
{
|
||||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
||||||
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
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"]
|
AuthMethod = Literal["session", "api_key", "service_account"]
|
||||||
AccessSubjectKind = Literal[
|
AccessSubjectKind = Literal[
|
||||||
"identity",
|
"identity",
|
||||||
@@ -114,6 +126,7 @@ class PrincipalRef:
|
|||||||
api_key_id: str | None = None
|
api_key_id: str | None = None
|
||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
service_account_id: str | None = None
|
service_account_id: str | None = None
|
||||||
|
acting_assignment_id: str | None = None
|
||||||
acting_for_account_id: str | None = None
|
acting_for_account_id: str | None = None
|
||||||
email: str | None = None
|
email: str | None = None
|
||||||
display_name: str | None = None
|
display_name: str | None = None
|
||||||
@@ -133,6 +146,7 @@ class PrincipalRef:
|
|||||||
"api_key_id": self.api_key_id,
|
"api_key_id": self.api_key_id,
|
||||||
"session_id": self.session_id,
|
"session_id": self.session_id,
|
||||||
"service_account_id": self.service_account_id,
|
"service_account_id": self.service_account_id,
|
||||||
|
"acting_assignment_id": self.acting_assignment_id,
|
||||||
"acting_for_account_id": self.acting_for_account_id,
|
"acting_for_account_id": self.acting_for_account_id,
|
||||||
"email": self.email,
|
"email": self.email,
|
||||||
"display_name": self.display_name,
|
"display_name": self.display_name,
|
||||||
@@ -154,6 +168,7 @@ class PrincipalRef:
|
|||||||
api_key_id=_optional_str(value.get("api_key_id")),
|
api_key_id=_optional_str(value.get("api_key_id")),
|
||||||
session_id=_optional_str(value.get("session_id")),
|
session_id=_optional_str(value.get("session_id")),
|
||||||
service_account_id=_optional_str(value.get("service_account_id")),
|
service_account_id=_optional_str(value.get("service_account_id")),
|
||||||
|
acting_assignment_id=_optional_str(value.get("acting_assignment_id")),
|
||||||
acting_for_account_id=_optional_str(value.get("acting_for_account_id")),
|
acting_for_account_id=_optional_str(value.get("acting_for_account_id")),
|
||||||
email=_optional_str(value.get("email")),
|
email=_optional_str(value.get("email")),
|
||||||
display_name=_optional_str(value.get("display_name")),
|
display_name=_optional_str(value.get("display_name")),
|
||||||
@@ -363,6 +378,7 @@ class AuditEvent:
|
|||||||
occurred_at: datetime | None = None
|
occurred_at: datetime | None = None
|
||||||
correlation_id: str | None = None
|
correlation_id: str | None = None
|
||||||
causation_id: str | None = None
|
causation_id: str | None = None
|
||||||
|
institutional_context: GovernedContextEnvelope | None = None
|
||||||
details: Mapping[str, object] = field(default_factory=dict)
|
details: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_APPROVAL_REQUESTS = "approvals.requests"
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalCapabilityError(ValueError):
|
||||||
|
"""Stable error raised by Approval capability implementations."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ApprovalActorSelector:
|
||||||
|
kind: str
|
||||||
|
value: str
|
||||||
|
label: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ApprovalStepDefinition:
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
selectors: tuple[ApprovalActorSelector, ...]
|
||||||
|
required_approvals: int = 1
|
||||||
|
rejection_policy: str = "fail_fast"
|
||||||
|
due_at: datetime | None = None
|
||||||
|
signature_required: bool = False
|
||||||
|
forbidden_evidence_roles: tuple[str, ...] = ()
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ApprovalRequestCreateCommand:
|
||||||
|
title: str
|
||||||
|
subject_module: str
|
||||||
|
subject_type: str
|
||||||
|
subject_id: str
|
||||||
|
subject_version: str | None
|
||||||
|
subject_digest: str
|
||||||
|
steps: tuple[ApprovalStepDefinition, ...]
|
||||||
|
description: str | None = None
|
||||||
|
separation_of_duties: bool = True
|
||||||
|
unique_actors_across_steps: bool = False
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
policy_refs: tuple[str, ...] = ()
|
||||||
|
evidence_actors: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
|
||||||
|
template_id: str | None = None
|
||||||
|
template_revision: int | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ApprovalTemplateCreateCommand:
|
||||||
|
key: str
|
||||||
|
title: str
|
||||||
|
steps: tuple[ApprovalStepDefinition, ...]
|
||||||
|
description: str | None = None
|
||||||
|
separation_of_duties: bool = True
|
||||||
|
unique_actors_across_steps: bool = False
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ApprovalTemplateRef:
|
||||||
|
id: str
|
||||||
|
key: str
|
||||||
|
revision: int
|
||||||
|
state: str
|
||||||
|
content_sha256: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ApprovalDecisionCommand:
|
||||||
|
outcome: str
|
||||||
|
reason: str
|
||||||
|
expected_revision: int
|
||||||
|
idempotency_key: str
|
||||||
|
delegated_for_account_id: str | None = None
|
||||||
|
signature_ref: Mapping[str, object] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ApprovalRequestRef:
|
||||||
|
id: str
|
||||||
|
revision: int
|
||||||
|
state: str
|
||||||
|
current_step_key: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ApprovalDecisionReceipt:
|
||||||
|
request_id: str
|
||||||
|
revision: int
|
||||||
|
state: str
|
||||||
|
step_key: str
|
||||||
|
outcome: str
|
||||||
|
actor_id: str
|
||||||
|
recorded_at: datetime
|
||||||
|
receipt_sha256: str
|
||||||
|
authority_provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
replayed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ApprovalCheck:
|
||||||
|
request_id: str
|
||||||
|
revision: int
|
||||||
|
state: str
|
||||||
|
approved: bool
|
||||||
|
subject_module: str
|
||||||
|
subject_type: str
|
||||||
|
subject_id: str
|
||||||
|
subject_version: str | None
|
||||||
|
subject_digest: str
|
||||||
|
completed_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ApprovalRequestProvider(Protocol):
|
||||||
|
def create_template(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
command: ApprovalTemplateCreateCommand,
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> ApprovalTemplateRef: ...
|
||||||
|
|
||||||
|
def revise_template(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
template_id: str,
|
||||||
|
command: ApprovalTemplateCreateCommand,
|
||||||
|
expected_revision: int,
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> ApprovalTemplateRef: ...
|
||||||
|
|
||||||
|
def publish_template(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
template_id: str,
|
||||||
|
expected_revision: int,
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> ApprovalTemplateRef: ...
|
||||||
|
|
||||||
|
def create_request(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
command: ApprovalRequestCreateCommand,
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> ApprovalRequestRef: ...
|
||||||
|
|
||||||
|
def get_request(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request_id: str,
|
||||||
|
) -> Mapping[str, object] | None: ...
|
||||||
|
|
||||||
|
def decide(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request_id: str,
|
||||||
|
command: ApprovalDecisionCommand,
|
||||||
|
) -> ApprovalDecisionReceipt: ...
|
||||||
|
|
||||||
|
def check_approved(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request_id: str,
|
||||||
|
subject_module: str,
|
||||||
|
subject_type: str,
|
||||||
|
subject_id: str,
|
||||||
|
subject_version: str | None,
|
||||||
|
subject_digest: str,
|
||||||
|
) -> ApprovalCheck: ...
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ApprovalActorSelector",
|
||||||
|
"ApprovalCapabilityError",
|
||||||
|
"ApprovalCheck",
|
||||||
|
"ApprovalDecisionCommand",
|
||||||
|
"ApprovalDecisionReceipt",
|
||||||
|
"ApprovalRequestCreateCommand",
|
||||||
|
"ApprovalRequestProvider",
|
||||||
|
"ApprovalRequestRef",
|
||||||
|
"ApprovalStepDefinition",
|
||||||
|
"ApprovalTemplateCreateCommand",
|
||||||
|
"ApprovalTemplateRef",
|
||||||
|
"CAPABILITY_APPROVAL_REQUESTS",
|
||||||
|
]
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
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,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.institutional import GovernedContextEnvelope
|
||||||
|
|
||||||
|
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
|
||||||
|
institutional_context: GovernedContextEnvelope | None = None
|
||||||
|
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",
|
||||||
|
]
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_CALENDAR_SCHEDULING = "calendar.scheduling"
|
||||||
|
CAPABILITY_CALENDAR_OUTBOX = "calendar.outbox"
|
||||||
|
CAPABILITY_CALENDAR_INVITATIONS = "calendar.invitations"
|
||||||
|
CAPABILITY_CALENDAR_EXTERNAL_PROFILES = "calendar.externalProfiles"
|
||||||
|
CALENDAR_AVAILABILITY_READ_SCOPE = "calendar:availability:read"
|
||||||
|
CALENDAR_EVENT_WRITE_SCOPE = "calendar:event:write"
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarCapabilityError(ValueError):
|
||||||
|
"""Stable error raised by Calendar capability implementations."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CalendarEventRequest:
|
||||||
|
summary: str
|
||||||
|
start_at: datetime
|
||||||
|
calendar_id: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
location: str | None = None
|
||||||
|
status: str = "CONFIRMED"
|
||||||
|
transparency: str = "OPAQUE"
|
||||||
|
classification: str = "PUBLIC"
|
||||||
|
end_at: datetime | None = None
|
||||||
|
timezone: str | None = None
|
||||||
|
attendees: tuple[Mapping[str, object], ...] = ()
|
||||||
|
categories: tuple[str, ...] = ()
|
||||||
|
related_to: tuple[Mapping[str, object], ...] = ()
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CalendarEventRef:
|
||||||
|
id: str
|
||||||
|
calendar_id: str
|
||||||
|
uid: str
|
||||||
|
external_state: str = "local"
|
||||||
|
outbox_operation_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CalendarInvitationAttendeeRequest:
|
||||||
|
address: str
|
||||||
|
name: str | None = None
|
||||||
|
role: str = "REQ-PARTICIPANT"
|
||||||
|
participation_status: str = "NEEDS-ACTION"
|
||||||
|
rsvp: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CalendarInvitationRequest:
|
||||||
|
correlation_id: str
|
||||||
|
source_module: str
|
||||||
|
source_resource_type: str
|
||||||
|
source_resource_id: str | None
|
||||||
|
summary: str
|
||||||
|
start_at: datetime
|
||||||
|
attendees: tuple[CalendarInvitationAttendeeRequest, ...]
|
||||||
|
calendar_id: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
location: str | None = None
|
||||||
|
end_at: datetime | None = None
|
||||||
|
timezone: str | None = None
|
||||||
|
organizer: Mapping[str, object] | None = None
|
||||||
|
classification: str = "PUBLIC"
|
||||||
|
categories: tuple[str, ...] = ()
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CalendarInvitationRef:
|
||||||
|
event_id: str
|
||||||
|
calendar_id: str
|
||||||
|
uid: str
|
||||||
|
correlation_id: str
|
||||||
|
source_module: str
|
||||||
|
source_resource_type: str
|
||||||
|
source_resource_id: str | None
|
||||||
|
attendees: tuple[Mapping[str, object], ...] = ()
|
||||||
|
external_state: str = "local"
|
||||||
|
outbox_operation_id: str | None = None
|
||||||
|
reply_ingress: str = "capability"
|
||||||
|
recurrence_supported: bool = False
|
||||||
|
degraded_reasons: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CalendarInvitationCalendarRef:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
color: str | None = None
|
||||||
|
timezone: str = "UTC"
|
||||||
|
source_kind: str = "local"
|
||||||
|
writable: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CalendarExternalProfileRequest:
|
||||||
|
"""Connector-neutral request for a Calendar-owned external profile."""
|
||||||
|
|
||||||
|
profile_kind: str
|
||||||
|
calendar_id: str
|
||||||
|
endpoint_url: str
|
||||||
|
display_name: str | None = None
|
||||||
|
auth_type: str = "none"
|
||||||
|
username: str | None = None
|
||||||
|
credential_ref: str | None = None
|
||||||
|
sync_enabled: bool = True
|
||||||
|
sync_interval_seconds: int = 900
|
||||||
|
sync_direction: str = "two_way"
|
||||||
|
conflict_policy: str = "etag"
|
||||||
|
connector_profile_ref: str | None = None
|
||||||
|
identity_mapping_ref: str | None = None
|
||||||
|
resource_calendar_ref: str | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CalendarExternalProfileRef:
|
||||||
|
source_id: str
|
||||||
|
calendar_id: str
|
||||||
|
profile_kind: str
|
||||||
|
transport_kind: str
|
||||||
|
connector_profile_ref: str | None = None
|
||||||
|
identity_mapping_ref: str | None = None
|
||||||
|
resource_calendar_ref: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class CalendarSchedulingProvider(Protocol):
|
||||||
|
def list_freebusy(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
start_at: datetime,
|
||||||
|
end_at: datetime,
|
||||||
|
calendar_ids: Sequence[str] | None = None,
|
||||||
|
) -> Sequence[Mapping[str, object]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def create_event(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str | None,
|
||||||
|
request: CalendarEventRequest,
|
||||||
|
) -> CalendarEventRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class CalendarOutboxProvider(Protocol):
|
||||||
|
"""Stable worker boundary for Calendar-owned external side effects."""
|
||||||
|
|
||||||
|
def dispatch_due(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class CalendarInvitationProvider(Protocol):
|
||||||
|
"""Correlation-aware invitation boundary for Campaign and Mail adapters."""
|
||||||
|
|
||||||
|
def list_calendars(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str | None = None,
|
||||||
|
group_ids: Sequence[str] = (),
|
||||||
|
can_admin: bool = False,
|
||||||
|
) -> Sequence[CalendarInvitationCalendarRef]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def render_invitation(self, request: CalendarInvitationRequest) -> str:
|
||||||
|
...
|
||||||
|
|
||||||
|
def upsert_invitation(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str | None,
|
||||||
|
request: CalendarInvitationRequest,
|
||||||
|
) -> CalendarInvitationRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_invitation(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
correlation_id: str,
|
||||||
|
) -> CalendarInvitationRef | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_invitations(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
correlation_ids: Sequence[str],
|
||||||
|
) -> Mapping[str, CalendarInvitationRef]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def summarize_invitations(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
source_module: str,
|
||||||
|
source_resource_type: str,
|
||||||
|
source_resource_id: str | None,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def record_response(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
attendee_address: str,
|
||||||
|
participation_status: str,
|
||||||
|
correlation_id: str | None = None,
|
||||||
|
uid: str | None = None,
|
||||||
|
responded_at: datetime | None = None,
|
||||||
|
evidence: Mapping[str, object] | None = None,
|
||||||
|
) -> CalendarInvitationRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def record_icalendar_reply(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
icalendar: str,
|
||||||
|
received_at: datetime | None = None,
|
||||||
|
evidence: Mapping[str, object] | None = None,
|
||||||
|
) -> Sequence[CalendarInvitationRef]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class CalendarExternalProfileProvider(Protocol):
|
||||||
|
"""Optional connector route for Calendar-owned groupware adapters."""
|
||||||
|
|
||||||
|
def supported_profiles(self) -> Sequence[Mapping[str, object]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def configure_profile(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str | None,
|
||||||
|
request: CalendarExternalProfileRequest,
|
||||||
|
) -> CalendarExternalProfileRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def calendar_scheduling_provider(registry: object | None) -> CalendarSchedulingProvider | None:
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_CALENDAR_SCHEDULING):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_CALENDAR_SCHEDULING)
|
||||||
|
return capability if isinstance(capability, CalendarSchedulingProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def calendar_outbox_provider(registry: object | None) -> CalendarOutboxProvider | None:
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_CALENDAR_OUTBOX):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_CALENDAR_OUTBOX)
|
||||||
|
return capability if isinstance(capability, CalendarOutboxProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def calendar_invitation_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> CalendarInvitationProvider | None:
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_CALENDAR_INVITATIONS):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_CALENDAR_INVITATIONS)
|
||||||
|
return capability if isinstance(capability, CalendarInvitationProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def calendar_external_profile_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> CalendarExternalProfileProvider | None:
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_CALENDAR_EXTERNAL_PROFILES):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_CALENDAR_EXTERNAL_PROFILES)
|
||||||
|
return (
|
||||||
|
capability
|
||||||
|
if isinstance(capability, CalendarExternalProfileProvider)
|
||||||
|
else None
|
||||||
|
)
|
||||||
@@ -6,7 +6,7 @@ from typing import Any, Iterable, Sequence
|
|||||||
from sqlalchemy import BigInteger, DateTime, Index, Integer, JSON, String, UniqueConstraint, func
|
from sqlalchemy import BigInteger, DateTime, Index, Integer, JSON, String, UniqueConstraint, func
|
||||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
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
|
from govoplan_core.db.base import Base, utcnow
|
||||||
|
|
||||||
WATERMARK_PREFIX = "seq:"
|
WATERMARK_PREFIX = "seq:"
|
||||||
@@ -96,13 +96,14 @@ def record_change(
|
|||||||
payload=payload or {},
|
payload=payload or {},
|
||||||
)
|
)
|
||||||
session.add(entry)
|
session.add(entry)
|
||||||
_publish_change_event(entry)
|
_publish_change_event(session, entry)
|
||||||
return 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)
|
event_type = _change_event_type(entry.module_id, entry.resource_type, entry.operation)
|
||||||
publish_platform_event(
|
emit_platform_event(
|
||||||
|
session,
|
||||||
PlatformEvent(
|
PlatformEvent(
|
||||||
type=event_type,
|
type=event_type,
|
||||||
module_id=entry.module_id,
|
module_id=entry.module_id,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -3,13 +3,11 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import Any, Literal, Protocol, runtime_checkable
|
import re
|
||||||
import urllib.error
|
from typing import Any, Literal, Protocol, cast, runtime_checkable
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
from govoplan_core.core.module_package_catalog import (
|
from govoplan_core.core.module_package_catalog import (
|
||||||
_canonical_catalog_bytes,
|
_canonical_catalog_bytes,
|
||||||
@@ -22,13 +20,195 @@ from govoplan_core.core.module_package_catalog import (
|
|||||||
_is_http_url,
|
_is_http_url,
|
||||||
_load_private_key,
|
_load_private_key,
|
||||||
_parse_trusted_keys,
|
_parse_trusted_keys,
|
||||||
|
_record_catalog_acceptance,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.external_references import (
|
||||||
|
IntegrationMaturity,
|
||||||
|
SOURCE_AUTHORITY_MODES,
|
||||||
|
SourceAuthorityMode,
|
||||||
|
integration_maturity_rank,
|
||||||
|
)
|
||||||
|
from govoplan_core.security.http_fetch import fetch_http_text
|
||||||
|
|
||||||
|
|
||||||
CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider"
|
CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider"
|
||||||
|
|
||||||
DiagnosticSeverity = Literal["blocker", "warning", "info"]
|
DiagnosticSeverity = Literal["blocker", "warning", "info"]
|
||||||
PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"]
|
PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"]
|
||||||
|
ConfigurationPackageClass = Literal[
|
||||||
|
"reference",
|
||||||
|
"product",
|
||||||
|
"sector",
|
||||||
|
"deployment",
|
||||||
|
"integration",
|
||||||
|
]
|
||||||
|
ConfigurationPackageEvidenceKind = Literal[
|
||||||
|
"target_test",
|
||||||
|
"migration",
|
||||||
|
"upgrade",
|
||||||
|
"recovery",
|
||||||
|
"security",
|
||||||
|
"operations",
|
||||||
|
"accessibility",
|
||||||
|
"privacy",
|
||||||
|
"documentation",
|
||||||
|
]
|
||||||
|
|
||||||
|
CONFIGURATION_PACKAGE_CLASSES: tuple[ConfigurationPackageClass, ...] = (
|
||||||
|
"reference",
|
||||||
|
"product",
|
||||||
|
"sector",
|
||||||
|
"deployment",
|
||||||
|
"integration",
|
||||||
|
)
|
||||||
|
CONFIGURATION_PACKAGE_EVIDENCE_KINDS: tuple[
|
||||||
|
ConfigurationPackageEvidenceKind, ...
|
||||||
|
] = (
|
||||||
|
"target_test",
|
||||||
|
"migration",
|
||||||
|
"upgrade",
|
||||||
|
"recovery",
|
||||||
|
"security",
|
||||||
|
"operations",
|
||||||
|
"accessibility",
|
||||||
|
"privacy",
|
||||||
|
"documentation",
|
||||||
|
)
|
||||||
|
_SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConfigurationPackageParent:
|
||||||
|
package_id: str
|
||||||
|
version: str
|
||||||
|
relation: Literal["derived_from", "specializes", "extends"] = "derived_from"
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.package_id.strip() or not self.version.strip():
|
||||||
|
raise ValueError("Configuration package parent id and version are required.")
|
||||||
|
if self.relation not in {"derived_from", "specializes", "extends"}:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported configuration package parent relation: {self.relation!r}."
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationPackageParent":
|
||||||
|
relation = _optional_str(value, "relation") or "derived_from"
|
||||||
|
if relation not in {"derived_from", "specializes", "extends"}:
|
||||||
|
raise ValueError(f"Unsupported configuration package parent relation: {relation!r}.")
|
||||||
|
return cls(
|
||||||
|
package_id=_required_str(value, "package_id"),
|
||||||
|
version=_required_str(value, "version"),
|
||||||
|
relation=relation,
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"package_id": self.package_id,
|
||||||
|
"version": self.version,
|
||||||
|
"relation": self.relation,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConfigurationPackageEvidence:
|
||||||
|
kind: ConfigurationPackageEvidenceKind
|
||||||
|
reference: str
|
||||||
|
summary: str
|
||||||
|
checksum: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.kind not in CONFIGURATION_PACKAGE_EVIDENCE_KINDS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported configuration package evidence kind: {self.kind!r}."
|
||||||
|
)
|
||||||
|
if not self.reference.strip() or not self.summary.strip():
|
||||||
|
raise ValueError(
|
||||||
|
"Configuration package evidence reference and summary are required."
|
||||||
|
)
|
||||||
|
if self.checksum is not None and not _SHA256_RE.fullmatch(self.checksum):
|
||||||
|
raise ValueError(
|
||||||
|
"Configuration package evidence checksum must use sha256:<64 lowercase hex>."
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationPackageEvidence":
|
||||||
|
kind = _required_str(value, "kind")
|
||||||
|
if kind not in CONFIGURATION_PACKAGE_EVIDENCE_KINDS:
|
||||||
|
raise ValueError(f"Unsupported configuration package evidence kind: {kind!r}.")
|
||||||
|
return cls(
|
||||||
|
kind=kind,
|
||||||
|
reference=_required_str(value, "reference"),
|
||||||
|
summary=_required_str(value, "summary"),
|
||||||
|
checksum=_optional_str(value, "checksum"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"kind": self.kind,
|
||||||
|
"reference": self.reference,
|
||||||
|
"summary": self.summary,
|
||||||
|
"checksum": self.checksum,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConfigurationProviderExpectation:
|
||||||
|
provider_id: str
|
||||||
|
authority_mode: SourceAuthorityMode
|
||||||
|
minimum_maturity: IntegrationMaturity
|
||||||
|
binding_ref: str | None = None
|
||||||
|
health_expectation: str = "healthy"
|
||||||
|
freshness_expectation: str | None = None
|
||||||
|
recovery_expectation: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.provider_id.strip():
|
||||||
|
raise ValueError("Configuration provider expectation id is required.")
|
||||||
|
if self.authority_mode not in SOURCE_AUTHORITY_MODES:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported provider authority mode: {self.authority_mode!r}."
|
||||||
|
)
|
||||||
|
integration_maturity_rank(self.minimum_maturity)
|
||||||
|
if not self.health_expectation.strip():
|
||||||
|
raise ValueError("Provider health expectation is required.")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationProviderExpectation":
|
||||||
|
return cls(
|
||||||
|
provider_id=_required_str(value, "provider_id"),
|
||||||
|
authority_mode=_required_str(value, "authority_mode"),
|
||||||
|
minimum_maturity=_required_str(value, "minimum_maturity"),
|
||||||
|
binding_ref=_optional_str(value, "binding_ref"),
|
||||||
|
health_expectation=_optional_str(value, "health_expectation") or "healthy",
|
||||||
|
freshness_expectation=_optional_str(value, "freshness_expectation"),
|
||||||
|
recovery_expectation=_optional_str(value, "recovery_expectation"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"provider_id": self.provider_id,
|
||||||
|
"authority_mode": self.authority_mode,
|
||||||
|
"minimum_maturity": self.minimum_maturity,
|
||||||
|
"binding_ref": self.binding_ref,
|
||||||
|
"health_expectation": self.health_expectation,
|
||||||
|
"freshness_expectation": self.freshness_expectation,
|
||||||
|
"recovery_expectation": self.recovery_expectation,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _ConfigurationCatalogValidationState:
|
||||||
|
packages: tuple[dict[str, object], ...]
|
||||||
|
channel: str | None
|
||||||
|
sequence: int | None
|
||||||
|
generated_at: str | None
|
||||||
|
not_before: str | None
|
||||||
|
expires_at: str | None
|
||||||
|
signature_state: dict[str, object]
|
||||||
|
freshness: dict[str, object]
|
||||||
|
replay: dict[str, object]
|
||||||
|
read_state: dict[str, object]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -62,6 +242,7 @@ class ConfigurationPackageManifest:
|
|||||||
package_id: str
|
package_id: str
|
||||||
name: str
|
name: str
|
||||||
version: str
|
version: str
|
||||||
|
package_class: ConfigurationPackageClass = "product"
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
publisher: str | None = None
|
publisher: str | None = None
|
||||||
category: str | None = None
|
category: str | None = None
|
||||||
@@ -75,6 +256,29 @@ class ConfigurationPackageManifest:
|
|||||||
artifact_ref: str | None = None
|
artifact_ref: str | None = None
|
||||||
artifact_sha256: str | None = None
|
artifact_sha256: str | None = None
|
||||||
signature: Mapping[str, Any] | None = None
|
signature: Mapping[str, Any] | None = None
|
||||||
|
parents: tuple[ConfigurationPackageParent, ...] = ()
|
||||||
|
evidence: tuple[ConfigurationPackageEvidence, ...] = ()
|
||||||
|
provider_expectations: tuple[ConfigurationProviderExpectation, ...] = ()
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.package_class not in CONFIGURATION_PACKAGE_CLASSES:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported configuration package class: {self.package_class!r}."
|
||||||
|
)
|
||||||
|
parent_keys = {(item.package_id, item.version) for item in self.parents}
|
||||||
|
if len(parent_keys) != len(self.parents):
|
||||||
|
raise ValueError("Configuration package parents must be unique.")
|
||||||
|
evidence_keys = {(item.kind, item.reference) for item in self.evidence}
|
||||||
|
if len(evidence_keys) != len(self.evidence):
|
||||||
|
raise ValueError("Configuration package evidence must be unique.")
|
||||||
|
provider_ids = [item.provider_id for item in self.provider_expectations]
|
||||||
|
if len(provider_ids) != len(set(provider_ids)):
|
||||||
|
raise ValueError("Configuration package provider expectations must be unique.")
|
||||||
|
for expectation in self.provider_expectations:
|
||||||
|
integration_maturity_rank(expectation.minimum_maturity)
|
||||||
|
issues = configuration_package_claim_issues(self)
|
||||||
|
if issues:
|
||||||
|
raise ValueError("Invalid configuration package claim: " + "; ".join(issues))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationPackageManifest":
|
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationPackageManifest":
|
||||||
@@ -82,6 +286,7 @@ class ConfigurationPackageManifest:
|
|||||||
package_id=_required_str(value, "package_id"),
|
package_id=_required_str(value, "package_id"),
|
||||||
name=_required_str(value, "name"),
|
name=_required_str(value, "name"),
|
||||||
version=_required_str(value, "version"),
|
version=_required_str(value, "version"),
|
||||||
|
package_class=_optional_str(value, "package_class") or "product",
|
||||||
description=_optional_str(value, "description"),
|
description=_optional_str(value, "description"),
|
||||||
publisher=_optional_str(value, "publisher"),
|
publisher=_optional_str(value, "publisher"),
|
||||||
category=_optional_str(value, "category"),
|
category=_optional_str(value, "category"),
|
||||||
@@ -95,6 +300,21 @@ class ConfigurationPackageManifest:
|
|||||||
artifact_ref=_optional_str(value, "artifact_ref"),
|
artifact_ref=_optional_str(value, "artifact_ref"),
|
||||||
artifact_sha256=_optional_str(value, "artifact_sha256"),
|
artifact_sha256=_optional_str(value, "artifact_sha256"),
|
||||||
signature=value.get("signature") if isinstance(value.get("signature"), Mapping) else None,
|
signature=value.get("signature") if isinstance(value.get("signature"), Mapping) else None,
|
||||||
|
parents=tuple(
|
||||||
|
ConfigurationPackageParent.from_mapping(item)
|
||||||
|
for item in _object_list(value.get("parents"), field_name="parents")
|
||||||
|
),
|
||||||
|
evidence=tuple(
|
||||||
|
ConfigurationPackageEvidence.from_mapping(item)
|
||||||
|
for item in _object_list(value.get("evidence"), field_name="evidence")
|
||||||
|
),
|
||||||
|
provider_expectations=tuple(
|
||||||
|
ConfigurationProviderExpectation.from_mapping(item)
|
||||||
|
for item in _object_list(
|
||||||
|
value.get("provider_expectations"),
|
||||||
|
field_name="provider_expectations",
|
||||||
|
)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, object]:
|
def to_dict(self) -> dict[str, object]:
|
||||||
@@ -102,12 +322,18 @@ class ConfigurationPackageManifest:
|
|||||||
"package_id": self.package_id,
|
"package_id": self.package_id,
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"version": self.version,
|
"version": self.version,
|
||||||
|
"package_class": self.package_class,
|
||||||
"required_modules": [item.to_dict() for item in self.required_modules],
|
"required_modules": [item.to_dict() for item in self.required_modules],
|
||||||
"required_capabilities": list(self.required_capabilities),
|
"required_capabilities": list(self.required_capabilities),
|
||||||
"optional_modules": [item.to_dict() for item in self.optional_modules],
|
"optional_modules": [item.to_dict() for item in self.optional_modules],
|
||||||
"fragments": [item.to_dict() for item in self.fragments],
|
"fragments": [item.to_dict() for item in self.fragments],
|
||||||
"data_requirements": [dict(item) for item in self.data_requirements],
|
"data_requirements": [dict(item) for item in self.data_requirements],
|
||||||
"tags": list(self.tags),
|
"tags": list(self.tags),
|
||||||
|
"parents": [item.to_dict() for item in self.parents],
|
||||||
|
"evidence": [item.to_dict() for item in self.evidence],
|
||||||
|
"provider_expectations": [
|
||||||
|
item.to_dict() for item in self.provider_expectations
|
||||||
|
],
|
||||||
}
|
}
|
||||||
for key, value in (
|
for key, value in (
|
||||||
("description", self.description),
|
("description", self.description),
|
||||||
@@ -208,6 +434,12 @@ class ConfigurationPreflightContext:
|
|||||||
supplied_data: Mapping[str, Any] = field(default_factory=dict)
|
supplied_data: Mapping[str, Any] = field(default_factory=dict)
|
||||||
installed_modules: Mapping[str, str] = field(default_factory=dict)
|
installed_modules: Mapping[str, str] = field(default_factory=dict)
|
||||||
capabilities: frozenset[str] = frozenset()
|
capabilities: frozenset[str] = frozenset()
|
||||||
|
external_provider_declarations: Mapping[str, Mapping[str, Any]] = field(
|
||||||
|
default_factory=dict
|
||||||
|
)
|
||||||
|
external_provider_states: Mapping[str, Mapping[str, Any]] = field(
|
||||||
|
default_factory=dict
|
||||||
|
)
|
||||||
dry_run: bool = True
|
dry_run: bool = True
|
||||||
|
|
||||||
|
|
||||||
@@ -273,6 +505,7 @@ def dry_run_configuration_package(
|
|||||||
|
|
||||||
diagnostics.extend(_module_requirement_diagnostics(manifest, context))
|
diagnostics.extend(_module_requirement_diagnostics(manifest, context))
|
||||||
diagnostics.extend(_capability_requirement_diagnostics(manifest, context))
|
diagnostics.extend(_capability_requirement_diagnostics(manifest, context))
|
||||||
|
diagnostics.extend(_provider_expectation_diagnostics(manifest, context))
|
||||||
for item in manifest.data_requirements:
|
for item in manifest.data_requirements:
|
||||||
requirement = ConfigurationRequiredData.from_mapping(item)
|
requirement = ConfigurationRequiredData.from_mapping(item)
|
||||||
required_data.append(requirement)
|
required_data.append(requirement)
|
||||||
@@ -356,6 +589,8 @@ def apply_configuration_package(
|
|||||||
supplied_data=supplied_data if supplied_data is not None else context.supplied_data,
|
supplied_data=supplied_data if supplied_data is not None else context.supplied_data,
|
||||||
installed_modules=context.installed_modules,
|
installed_modules=context.installed_modules,
|
||||||
capabilities=context.capabilities,
|
capabilities=context.capabilities,
|
||||||
|
external_provider_declarations=context.external_provider_declarations,
|
||||||
|
external_provider_states=context.external_provider_states,
|
||||||
dry_run=False,
|
dry_run=False,
|
||||||
)
|
)
|
||||||
preflight = dry_run_configuration_package(manifest, providers, apply_context)
|
preflight = dry_run_configuration_package(manifest, providers, apply_context)
|
||||||
@@ -461,55 +696,148 @@ def validate_configuration_package_catalog(
|
|||||||
configured = source is not None and _catalog_source_exists(source)
|
configured = source is not None and _catalog_source_exists(source)
|
||||||
if source is not None and not _catalog_source_exists(source):
|
if source is not None and not _catalog_source_exists(source):
|
||||||
return _validation_result(source, configured=True, packages=(), error=f"Configuration package catalog does not exist: {source}")
|
return _validation_result(source, configured=True, packages=(), error=f"Configuration package catalog does not exist: {source}")
|
||||||
read_state = {"cache_used": False, "cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() else None}
|
read_state = _configuration_catalog_default_read_state()
|
||||||
try:
|
try:
|
||||||
payload, read_state = _read_catalog_payload_with_metadata(source)
|
|
||||||
packages = _normalize_catalog_packages(payload)
|
|
||||||
channel = _catalog_channel(payload)
|
|
||||||
sequence = _catalog_sequence(payload)
|
|
||||||
generated_at = _catalog_optional_text(payload, "generated_at")
|
|
||||||
not_before = _catalog_optional_text(payload, "not_before")
|
|
||||||
expires_at = _catalog_optional_text(payload, "expires_at")
|
|
||||||
effective_require_trusted = _configured_require_signature() if require_trusted is None else require_trusted
|
effective_require_trusted = _configured_require_signature() if require_trusted is None else require_trusted
|
||||||
effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels
|
effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels
|
||||||
effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys()
|
effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys()
|
||||||
signature_state = _catalog_signature_state(payload, trusted_keys=effective_trusted_keys)
|
state = _configuration_catalog_validation_state(source, trusted_keys=effective_trusted_keys)
|
||||||
freshness = _catalog_freshness_state(payload)
|
read_state = state.read_state
|
||||||
replay = _catalog_replay_state(channel=channel, sequence=sequence)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), error=str(exc))
|
return _validation_result(source, configured=configured, read_state=read_state, packages=(), error=str(exc))
|
||||||
if signature_state.get("fatal"):
|
policy_error = _configuration_catalog_policy_error(
|
||||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(signature_state["error"]))
|
source,
|
||||||
if effective_approved_channels and channel not in effective_approved_channels:
|
configured=configured,
|
||||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=f"Configuration package catalog channel {channel!r} is not approved. Approved channels: {', '.join(effective_approved_channels)}.")
|
state=state,
|
||||||
if effective_require_trusted and not signature_state["trusted"]:
|
require_trusted=effective_require_trusted,
|
||||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(signature_state["error"] or "Configuration package catalog must be signed by a trusted key."))
|
approved_channels=effective_approved_channels,
|
||||||
if not freshness["valid"]:
|
)
|
||||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(freshness["error"]))
|
if policy_error is not None:
|
||||||
if not replay["valid"]:
|
return policy_error
|
||||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(replay["error"]))
|
|
||||||
warnings = [str(item) for item in freshness.get("warnings", ()) if item]
|
|
||||||
warnings.extend(str(item) for item in replay.get("warnings", ()) if item)
|
|
||||||
if not signature_state["signed"]:
|
|
||||||
warnings.append("Configuration package catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
|
|
||||||
elif not signature_state["trusted"]:
|
|
||||||
warnings.append(str(signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
|
|
||||||
return _validation_result(
|
return _validation_result(
|
||||||
source,
|
source,
|
||||||
valid=True,
|
valid=True,
|
||||||
configured=configured,
|
configured=configured,
|
||||||
read_state=read_state,
|
read_state=state.read_state,
|
||||||
packages=packages,
|
packages=state.packages,
|
||||||
|
channel=state.channel,
|
||||||
|
sequence=state.sequence,
|
||||||
|
generated_at=state.generated_at,
|
||||||
|
not_before=state.not_before,
|
||||||
|
expires_at=state.expires_at,
|
||||||
|
signature_state=state.signature_state,
|
||||||
|
warnings=_configuration_catalog_warnings(state),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_catalog_validation_state(
|
||||||
|
source: Path | str | None,
|
||||||
|
*,
|
||||||
|
trusted_keys: dict[str, str],
|
||||||
|
) -> _ConfigurationCatalogValidationState:
|
||||||
|
payload, read_state = _read_catalog_payload_with_metadata(source)
|
||||||
|
channel = _catalog_channel(payload)
|
||||||
|
sequence = _catalog_sequence(payload)
|
||||||
|
return _ConfigurationCatalogValidationState(
|
||||||
|
packages=_normalize_catalog_packages(payload),
|
||||||
channel=channel,
|
channel=channel,
|
||||||
sequence=sequence,
|
sequence=sequence,
|
||||||
generated_at=generated_at,
|
generated_at=_catalog_optional_text(payload, "generated_at"),
|
||||||
not_before=not_before,
|
not_before=_catalog_optional_text(payload, "not_before"),
|
||||||
expires_at=expires_at,
|
expires_at=_catalog_optional_text(payload, "expires_at"),
|
||||||
signature_state=signature_state,
|
signature_state=_catalog_signature_state(payload, trusted_keys=trusted_keys),
|
||||||
warnings=warnings,
|
freshness=_catalog_freshness_state(payload),
|
||||||
|
replay=_catalog_replay_state(channel=channel, sequence=sequence),
|
||||||
|
read_state=read_state,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_catalog_policy_error(
|
||||||
|
source: Path | str | None,
|
||||||
|
*,
|
||||||
|
configured: bool,
|
||||||
|
state: _ConfigurationCatalogValidationState,
|
||||||
|
require_trusted: bool,
|
||||||
|
approved_channels: tuple[str, ...],
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
if state.signature_state.get("fatal"):
|
||||||
|
return _configuration_catalog_invalid_result(
|
||||||
|
source,
|
||||||
|
configured=configured,
|
||||||
|
state=state,
|
||||||
|
error=str(state.signature_state["error"]),
|
||||||
|
)
|
||||||
|
if approved_channels and state.channel not in approved_channels:
|
||||||
|
return _configuration_catalog_invalid_result(
|
||||||
|
source,
|
||||||
|
configured=configured,
|
||||||
|
state=state,
|
||||||
|
error=(
|
||||||
|
f"Configuration package catalog channel {state.channel!r} is not approved. "
|
||||||
|
f"Approved channels: {', '.join(approved_channels)}."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if require_trusted and not state.signature_state["trusted"]:
|
||||||
|
return _configuration_catalog_invalid_result(
|
||||||
|
source,
|
||||||
|
configured=configured,
|
||||||
|
state=state,
|
||||||
|
error=str(state.signature_state["error"] or "Configuration package catalog must be signed by a trusted key."),
|
||||||
|
)
|
||||||
|
if not state.freshness["valid"]:
|
||||||
|
return _configuration_catalog_invalid_result(
|
||||||
|
source,
|
||||||
|
configured=configured,
|
||||||
|
state=state,
|
||||||
|
error=str(state.freshness["error"]),
|
||||||
|
)
|
||||||
|
if not state.replay["valid"]:
|
||||||
|
return _configuration_catalog_invalid_result(
|
||||||
|
source,
|
||||||
|
configured=configured,
|
||||||
|
state=state,
|
||||||
|
error=str(state.replay["error"]),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_catalog_invalid_result(
|
||||||
|
source: Path | str | None,
|
||||||
|
*,
|
||||||
|
configured: bool,
|
||||||
|
state: _ConfigurationCatalogValidationState,
|
||||||
|
error: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return _validation_result(
|
||||||
|
source,
|
||||||
|
configured=configured,
|
||||||
|
read_state=state.read_state,
|
||||||
|
packages=(),
|
||||||
|
channel=state.channel,
|
||||||
|
sequence=state.sequence,
|
||||||
|
generated_at=state.generated_at,
|
||||||
|
not_before=state.not_before,
|
||||||
|
expires_at=state.expires_at,
|
||||||
|
signature_state=state.signature_state,
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_catalog_warnings(state: _ConfigurationCatalogValidationState) -> list[str]:
|
||||||
|
warnings = [str(item) for item in state.freshness.get("warnings", ()) if item]
|
||||||
|
warnings.extend(str(item) for item in state.replay.get("warnings", ()) if item)
|
||||||
|
if not state.signature_state["signed"]:
|
||||||
|
warnings.append("Configuration package catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
|
||||||
|
elif not state.signature_state["trusted"]:
|
||||||
|
warnings.append(str(state.signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_catalog_default_read_state() -> dict[str, object]:
|
||||||
|
cache_path = _configured_catalog_cache_path()
|
||||||
|
return {"cache_used": False, "cache_path": str(cache_path) if cache_path else None}
|
||||||
|
|
||||||
|
|
||||||
def sign_configuration_package_catalog(*, path: Path, key_id: str, private_key_path: Path, output_path: Path | None = None) -> Path:
|
def sign_configuration_package_catalog(*, path: Path, key_id: str, private_key_path: Path, output_path: Path | None = None) -> Path:
|
||||||
payload = _read_catalog_payload(path)
|
payload = _read_catalog_payload(path)
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
@@ -530,33 +858,158 @@ def sign_configuration_package_catalog(*, path: Path, key_id: str, private_key_p
|
|||||||
|
|
||||||
|
|
||||||
def record_configuration_package_catalog_acceptance(validation: dict[str, object]) -> None:
|
def record_configuration_package_catalog_acceptance(validation: dict[str, object]) -> None:
|
||||||
state_path = _configured_sequence_state_path()
|
_record_catalog_acceptance(
|
||||||
if state_path is None or validation.get("valid") is not True:
|
validation,
|
||||||
return
|
state_path=_configured_sequence_state_path(),
|
||||||
channel = validation.get("channel")
|
)
|
||||||
sequence = validation.get("sequence")
|
|
||||||
if not isinstance(channel, str) or not isinstance(sequence, int):
|
|
||||||
return
|
def configuration_package_claim_issues(
|
||||||
try:
|
manifest: ConfigurationPackageManifest,
|
||||||
state = json.loads(state_path.read_text(encoding="utf-8")) if state_path.exists() else {}
|
) -> tuple[str, ...]:
|
||||||
except json.JSONDecodeError:
|
evidence_kinds = {item.kind for item in manifest.evidence}
|
||||||
state = {}
|
required_evidence: dict[str, frozenset[str]] = {
|
||||||
if not isinstance(state, dict):
|
"reference": frozenset(
|
||||||
state = {}
|
{
|
||||||
channels = state.get("channels")
|
"target_test",
|
||||||
if not isinstance(channels, dict):
|
"recovery",
|
||||||
channels = {}
|
"security",
|
||||||
channel_state = channels.get(channel)
|
"operations",
|
||||||
if not isinstance(channel_state, dict):
|
"accessibility",
|
||||||
channel_state = {}
|
"privacy",
|
||||||
channel_state["last_sequence"] = max(int(channel_state.get("last_sequence") or 0), sequence)
|
"documentation",
|
||||||
channel_state["accepted_at"] = datetime.now(tz=UTC).isoformat().replace("+00:00", "Z")
|
}
|
||||||
channel_state["key_id"] = validation.get("key_id")
|
),
|
||||||
channel_state["source"] = validation.get("source") or validation.get("path")
|
"product": frozenset(),
|
||||||
channels[channel] = channel_state
|
"sector": frozenset({"documentation"}),
|
||||||
state["channels"] = channels
|
"deployment": frozenset(
|
||||||
state_path.parent.mkdir(parents=True, exist_ok=True)
|
{"target_test", "recovery", "security", "operations"}
|
||||||
state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
),
|
||||||
|
"integration": frozenset(
|
||||||
|
{"target_test", "recovery", "operations", "documentation"}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
issues: list[str] = []
|
||||||
|
missing = sorted(required_evidence[manifest.package_class] - evidence_kinds)
|
||||||
|
if missing:
|
||||||
|
issues.append(
|
||||||
|
f"{manifest.package_class} package is missing evidence: "
|
||||||
|
+ ", ".join(missing)
|
||||||
|
)
|
||||||
|
if manifest.package_class in {"reference", "deployment", "integration"}:
|
||||||
|
unbound = sorted(
|
||||||
|
item.kind
|
||||||
|
for item in manifest.evidence
|
||||||
|
if item.kind != "documentation" and item.checksum is None
|
||||||
|
)
|
||||||
|
if unbound:
|
||||||
|
issues.append(
|
||||||
|
f"{manifest.package_class} package has evidence without checksums: "
|
||||||
|
+ ", ".join(unbound)
|
||||||
|
)
|
||||||
|
if manifest.package_class == "sector" and not manifest.parents:
|
||||||
|
issues.append("sector packages must declare a parent package/version")
|
||||||
|
if manifest.package_class == "integration" and not manifest.provider_expectations:
|
||||||
|
issues.append("integration packages must declare external provider expectations")
|
||||||
|
return tuple(issues)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_configuration_package_derivation(
|
||||||
|
child: ConfigurationPackageManifest,
|
||||||
|
parent: ConfigurationPackageManifest,
|
||||||
|
) -> tuple[ConfigurationDiagnostic, ...]:
|
||||||
|
diagnostics: list[ConfigurationDiagnostic] = []
|
||||||
|
if not any(
|
||||||
|
item.package_id == parent.package_id and item.version == parent.version
|
||||||
|
for item in child.parents
|
||||||
|
):
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="package_parent_provenance_missing",
|
||||||
|
message=(
|
||||||
|
f"Package {child.package_id!r} does not declare parent "
|
||||||
|
f"{parent.package_id}@{parent.version}."
|
||||||
|
),
|
||||||
|
object_ref=parent.package_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
child_modules = {item.module_id: item for item in child.required_modules}
|
||||||
|
for requirement in parent.required_modules:
|
||||||
|
candidate = child_modules.get(requirement.module_id)
|
||||||
|
if candidate is None or (
|
||||||
|
requirement.version is not None
|
||||||
|
and candidate.version != requirement.version
|
||||||
|
):
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="package_parent_module_constraint_loosened",
|
||||||
|
message=(
|
||||||
|
f"Derived package loosens parent module requirement "
|
||||||
|
f"{requirement.module_id!r}."
|
||||||
|
),
|
||||||
|
module_id=requirement.module_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for capability in set(parent.required_capabilities) - set(
|
||||||
|
child.required_capabilities
|
||||||
|
):
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="package_parent_capability_constraint_loosened",
|
||||||
|
message=(
|
||||||
|
f"Derived package removes required capability {capability!r}."
|
||||||
|
),
|
||||||
|
object_ref=capability,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
child_providers = {
|
||||||
|
item.provider_id: item for item in child.provider_expectations
|
||||||
|
}
|
||||||
|
for expectation in parent.provider_expectations:
|
||||||
|
candidate = child_providers.get(expectation.provider_id)
|
||||||
|
if candidate is None:
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="package_parent_provider_constraint_removed",
|
||||||
|
message=(
|
||||||
|
f"Derived package removes provider expectation "
|
||||||
|
f"{expectation.provider_id!r}."
|
||||||
|
),
|
||||||
|
object_ref=expectation.provider_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if candidate.authority_mode != expectation.authority_mode:
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="package_parent_authority_mode_changed",
|
||||||
|
message=(
|
||||||
|
f"Derived package changes authority mode for provider "
|
||||||
|
f"{expectation.provider_id!r}."
|
||||||
|
),
|
||||||
|
object_ref=expectation.provider_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if integration_maturity_rank(
|
||||||
|
candidate.minimum_maturity
|
||||||
|
) < integration_maturity_rank(expectation.minimum_maturity):
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="package_parent_provider_maturity_loosened",
|
||||||
|
message=(
|
||||||
|
f"Derived package lowers provider maturity for "
|
||||||
|
f"{expectation.provider_id!r}."
|
||||||
|
),
|
||||||
|
object_ref=expectation.provider_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(diagnostics)
|
||||||
|
|
||||||
|
|
||||||
def _configuration_package_manifest(package: ConfigurationPackageManifest | Mapping[str, Any]) -> ConfigurationPackageManifest:
|
def _configuration_package_manifest(package: ConfigurationPackageManifest | Mapping[str, Any]) -> ConfigurationPackageManifest:
|
||||||
@@ -610,6 +1063,194 @@ def _capability_requirement_diagnostics(manifest: ConfigurationPackageManifest,
|
|||||||
return diagnostics
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_expectation_diagnostics(
|
||||||
|
manifest: ConfigurationPackageManifest,
|
||||||
|
context: ConfigurationPreflightContext,
|
||||||
|
) -> list[ConfigurationDiagnostic]:
|
||||||
|
diagnostics: list[ConfigurationDiagnostic] = []
|
||||||
|
for expectation in manifest.provider_expectations:
|
||||||
|
declaration = context.external_provider_declarations.get(
|
||||||
|
expectation.provider_id
|
||||||
|
)
|
||||||
|
if declaration is None:
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="external_provider_missing",
|
||||||
|
message=(
|
||||||
|
f"Required external provider {expectation.provider_id!r} "
|
||||||
|
"is not installed or declared."
|
||||||
|
),
|
||||||
|
object_ref=expectation.provider_id,
|
||||||
|
resolution=(
|
||||||
|
"Install and enable a module exposing the declared provider."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
supported_modes = {
|
||||||
|
str(item)
|
||||||
|
for item in declaration.get("authority_modes", ())
|
||||||
|
if str(item).strip()
|
||||||
|
}
|
||||||
|
if expectation.authority_mode not in supported_modes:
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="external_provider_authority_mode_unsupported",
|
||||||
|
message=(
|
||||||
|
f"Provider {expectation.provider_id!r} does not support "
|
||||||
|
f"authority mode {expectation.authority_mode!r}."
|
||||||
|
),
|
||||||
|
object_ref=expectation.provider_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
actual_maturity = str(declaration.get("maturity") or "discover")
|
||||||
|
try:
|
||||||
|
maturity_sufficient = integration_maturity_rank(
|
||||||
|
cast(IntegrationMaturity, actual_maturity)
|
||||||
|
) >= integration_maturity_rank(expectation.minimum_maturity)
|
||||||
|
except ValueError:
|
||||||
|
maturity_sufficient = False
|
||||||
|
if not maturity_sufficient:
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="external_provider_maturity_insufficient",
|
||||||
|
message=(
|
||||||
|
f"Provider {expectation.provider_id!r} has maturity "
|
||||||
|
f"{actual_maturity!r}; {expectation.minimum_maturity!r} is required."
|
||||||
|
),
|
||||||
|
object_ref=expectation.provider_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
provider_state = context.external_provider_states.get(
|
||||||
|
expectation.provider_id
|
||||||
|
)
|
||||||
|
if provider_state is None:
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="warning",
|
||||||
|
code="external_provider_health_unverified",
|
||||||
|
message=(
|
||||||
|
f"Provider {expectation.provider_id!r} has no current "
|
||||||
|
"health/freshness observation."
|
||||||
|
),
|
||||||
|
object_ref=expectation.provider_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
state = provider_state
|
||||||
|
if expectation.binding_ref is not None:
|
||||||
|
bindings = provider_state.get("bindings")
|
||||||
|
matching_binding = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in bindings
|
||||||
|
if isinstance(item, Mapping)
|
||||||
|
and str(item.get("binding_ref") or "")
|
||||||
|
== expectation.binding_ref
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
) if isinstance(bindings, Sequence) and not isinstance(
|
||||||
|
bindings, (str, bytes)
|
||||||
|
) else None
|
||||||
|
if matching_binding is None and str(
|
||||||
|
provider_state.get("binding_ref") or ""
|
||||||
|
) == expectation.binding_ref:
|
||||||
|
matching_binding = provider_state
|
||||||
|
if matching_binding is None:
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="external_provider_binding_mismatch",
|
||||||
|
message=(
|
||||||
|
f"Provider {expectation.provider_id!r} is not observed through "
|
||||||
|
f"required binding {expectation.binding_ref!r}."
|
||||||
|
),
|
||||||
|
object_ref=expectation.provider_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
state = matching_binding
|
||||||
|
health = str(state.get("health") or state.get("health_state") or "unknown")
|
||||||
|
accepted_health = (
|
||||||
|
{"ok", "healthy"}
|
||||||
|
if expectation.health_expectation == "healthy"
|
||||||
|
else {expectation.health_expectation}
|
||||||
|
)
|
||||||
|
if health not in accepted_health:
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="external_provider_unhealthy",
|
||||||
|
message=(
|
||||||
|
f"Provider {expectation.provider_id!r} health is {health!r}."
|
||||||
|
),
|
||||||
|
object_ref=expectation.provider_id,
|
||||||
|
resolution="Restore provider health or use a documented degraded path.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
observed_authority_mode = str(state.get("authority_mode") or "")
|
||||||
|
if (
|
||||||
|
observed_authority_mode
|
||||||
|
and observed_authority_mode != expectation.authority_mode
|
||||||
|
):
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="external_provider_binding_authority_mismatch",
|
||||||
|
message=(
|
||||||
|
f"Provider {expectation.provider_id!r} is configured as "
|
||||||
|
f"{observed_authority_mode!r}; {expectation.authority_mode!r} "
|
||||||
|
"is required."
|
||||||
|
),
|
||||||
|
object_ref=expectation.provider_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if expectation.freshness_expectation is not None:
|
||||||
|
freshness = str(
|
||||||
|
state.get("freshness")
|
||||||
|
or state.get("freshness_state")
|
||||||
|
or "unknown"
|
||||||
|
)
|
||||||
|
if freshness != expectation.freshness_expectation:
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="external_provider_freshness_expectation_failed",
|
||||||
|
message=(
|
||||||
|
f"Provider {expectation.provider_id!r} freshness is "
|
||||||
|
f"{freshness!r}; {expectation.freshness_expectation!r} is required."
|
||||||
|
),
|
||||||
|
object_ref=expectation.provider_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if expectation.recovery_expectation is not None:
|
||||||
|
behavior = declaration.get("behavior")
|
||||||
|
declared_recovery = (
|
||||||
|
behavior.get(expectation.recovery_expectation)
|
||||||
|
if isinstance(behavior, Mapping)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
observed_recovery = state.get("recovery") or state.get(
|
||||||
|
"recovery_state"
|
||||||
|
)
|
||||||
|
if not declared_recovery and observed_recovery != expectation.recovery_expectation:
|
||||||
|
diagnostics.append(
|
||||||
|
ConfigurationDiagnostic(
|
||||||
|
severity="blocker",
|
||||||
|
code="external_provider_recovery_expectation_failed",
|
||||||
|
message=(
|
||||||
|
f"Provider {expectation.provider_id!r} does not satisfy "
|
||||||
|
f"recovery expectation {expectation.recovery_expectation!r}."
|
||||||
|
),
|
||||||
|
object_ref=expectation.provider_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
def _dedupe_diagnostics(items: Sequence[ConfigurationDiagnostic]) -> list[ConfigurationDiagnostic]:
|
def _dedupe_diagnostics(items: Sequence[ConfigurationDiagnostic]) -> list[ConfigurationDiagnostic]:
|
||||||
seen: set[tuple[object, ...]] = set()
|
seen: set[tuple[object, ...]] = set()
|
||||||
result: list[ConfigurationDiagnostic] = []
|
result: list[ConfigurationDiagnostic] = []
|
||||||
@@ -686,17 +1327,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
|
|||||||
|
|
||||||
|
|
||||||
def _read_trusted_keys_url(url: str) -> str:
|
def _read_trusted_keys_url(url: str) -> str:
|
||||||
if not _is_http_url(url):
|
|
||||||
raise ValueError("Trusted configuration catalog key URL must use http:// or https://.")
|
|
||||||
cache_path = _configured_trusted_keys_cache_path()
|
cache_path = _configured_trusted_keys_cache_path()
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(url, timeout=15) as response:
|
body = fetch_http_text(url, timeout=15, label="Trusted configuration catalog key URL")
|
||||||
body = response.read().decode("utf-8")
|
|
||||||
if cache_path is not None:
|
if cache_path is not None:
|
||||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
cache_path.write_text(body, encoding="utf-8")
|
cache_path.write_text(body, encoding="utf-8")
|
||||||
return body
|
return body
|
||||||
except (OSError, urllib.error.URLError):
|
except OSError:
|
||||||
if cache_path is not None and cache_path.exists():
|
if cache_path is not None and cache_path.exists():
|
||||||
return cache_path.read_text(encoding="utf-8")
|
return cache_path.read_text(encoding="utf-8")
|
||||||
raise
|
raise
|
||||||
@@ -714,13 +1352,12 @@ def _read_catalog_payload_with_metadata(source: Path | str | None) -> tuple[obje
|
|||||||
return {"packages": []}, metadata
|
return {"packages": []}, metadata
|
||||||
if isinstance(source, str) and _is_http_url(source):
|
if isinstance(source, str) and _is_http_url(source):
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(source, timeout=15) as response:
|
body = fetch_http_text(source, timeout=15, label="Configuration package catalog URL")
|
||||||
body = response.read().decode("utf-8")
|
|
||||||
if cache_path is not None:
|
if cache_path is not None:
|
||||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
cache_path.write_text(body, encoding="utf-8")
|
cache_path.write_text(body, encoding="utf-8")
|
||||||
return json.loads(body), metadata
|
return json.loads(body), metadata
|
||||||
except (OSError, urllib.error.URLError):
|
except OSError:
|
||||||
if cache_path is not None and cache_path.exists():
|
if cache_path is not None and cache_path.exists():
|
||||||
metadata["cache_used"] = True
|
metadata["cache_used"] = True
|
||||||
return json.loads(cache_path.read_text(encoding="utf-8")), metadata
|
return json.loads(cache_path.read_text(encoding="utf-8")), metadata
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from collections.abc import Mapping
|
from typing import Literal
|
||||||
from typing import Any, Literal
|
|
||||||
|
|
||||||
from govoplan_core.security.permissions import scopes_grant
|
from govoplan_core.security.permissions import scopes_grant
|
||||||
from govoplan_core.security.redaction import contains_plain_secret
|
from govoplan_core.security.redaction import contains_plain_secret
|
||||||
@@ -22,7 +21,7 @@ class ConfigurationFieldSafety:
|
|||||||
storage: str
|
storage: str
|
||||||
ui_managed: bool
|
ui_managed: bool
|
||||||
risk: ConfigurationRisk
|
risk: ConfigurationRisk
|
||||||
secret_handling: SecretHandling = "none"
|
secret_handling: SecretHandling = "none" # noqa: S105 - policy vocabulary.
|
||||||
required_scopes: tuple[str, ...] = ()
|
required_scopes: tuple[str, ...] = ()
|
||||||
dry_run_required: bool = False
|
dry_run_required: bool = False
|
||||||
validation_required: bool = True
|
validation_required: bool = True
|
||||||
@@ -69,7 +68,7 @@ class ConfigurationChangeSafetyPlan:
|
|||||||
maintenance_required: bool = False
|
maintenance_required: bool = False
|
||||||
maintenance_satisfied: bool = False
|
maintenance_satisfied: bool = False
|
||||||
rollback_history_required: bool = False
|
rollback_history_required: bool = False
|
||||||
secret_handling: SecretHandling = "none"
|
secret_handling: SecretHandling = "none" # noqa: S105 - policy vocabulary.
|
||||||
audit_event: str | None = None
|
audit_event: str | None = None
|
||||||
policy_explanation: str | None = None
|
policy_explanation: str | None = None
|
||||||
blockers: tuple[str, ...] = ()
|
blockers: tuple[str, ...] = ()
|
||||||
@@ -97,6 +96,16 @@ class ConfigurationChangeSafetyPlan:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _ConfigurationChangeSafetyState:
|
||||||
|
field: ConfigurationFieldSafety
|
||||||
|
missing_scopes: tuple[str, ...]
|
||||||
|
blockers: tuple[str, ...]
|
||||||
|
warnings: tuple[str, ...]
|
||||||
|
approval_required: bool
|
||||||
|
approval_satisfied: bool
|
||||||
|
|
||||||
|
|
||||||
_CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
_CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||||
ConfigurationFieldSafety(
|
ConfigurationFieldSafety(
|
||||||
key="module_management.desired_enabled",
|
key="module_management.desired_enabled",
|
||||||
@@ -230,7 +239,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
|||||||
storage="module_settings",
|
storage="module_settings",
|
||||||
ui_managed=True,
|
ui_managed=True,
|
||||||
risk="high",
|
risk="high",
|
||||||
secret_handling="reference_only",
|
secret_handling="reference_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
required_scopes=("mail_servers:manage_credentials",),
|
required_scopes=("mail_servers:manage_credentials",),
|
||||||
validation_required=True,
|
validation_required=True,
|
||||||
audit_event="mail_server_profile.credential_updated",
|
audit_event="mail_server_profile.credential_updated",
|
||||||
@@ -246,14 +255,14 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
|||||||
storage="module_settings",
|
storage="module_settings",
|
||||||
ui_managed=True,
|
ui_managed=True,
|
||||||
risk="high",
|
risk="high",
|
||||||
secret_handling="reference_only",
|
secret_handling="reference_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
required_scopes=("files:file:admin",),
|
required_scopes=("files:file:admin",),
|
||||||
dry_run_required=True,
|
dry_run_required=True,
|
||||||
policy_explanation_required=True,
|
policy_explanation_required=True,
|
||||||
audit_event="files.connector_profile.updated",
|
audit_event="files.connector_profile.updated",
|
||||||
two_person_approval_required=True,
|
two_person_approval_required=True,
|
||||||
rollback_history_required=True,
|
rollback_history_required=True,
|
||||||
notes="Connector endpoints are UI-manageable, but passwords/tokens remain env or secret refs.",
|
notes="Connector endpoints are UI-manageable. API-managed credentials are encrypted or use scoped secret refs; process-environment references remain deployment-owned.",
|
||||||
),
|
),
|
||||||
ConfigurationFieldSafety(
|
ConfigurationFieldSafety(
|
||||||
key="DATABASE_URL",
|
key="DATABASE_URL",
|
||||||
@@ -263,7 +272,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
|||||||
storage="environment",
|
storage="environment",
|
||||||
ui_managed=False,
|
ui_managed=False,
|
||||||
risk="destructive",
|
risk="destructive",
|
||||||
secret_handling="env_only",
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
maintenance_required=True,
|
maintenance_required=True,
|
||||||
notes="Database connectivity remains deployment-managed and must not be changed from the running UI.",
|
notes="Database connectivity remains deployment-managed and must not be changed from the running UI.",
|
||||||
),
|
),
|
||||||
@@ -275,11 +284,191 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
|||||||
storage="environment",
|
storage="environment",
|
||||||
ui_managed=False,
|
ui_managed=False,
|
||||||
risk="destructive",
|
risk="destructive",
|
||||||
secret_handling="env_only",
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
maintenance_required=True,
|
maintenance_required=True,
|
||||||
two_person_approval_required=True,
|
two_person_approval_required=True,
|
||||||
notes="Encryption roots remain out of band; UI may only report missing/rotated state.",
|
notes="Encryption roots remain out of band; UI may only report missing/rotated state.",
|
||||||
),
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
|
||||||
|
label="Private-network connector access",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
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",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="A deployment-wide memory-safety limit applied consistently by API and worker processes.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES",
|
||||||
|
label="Connector file-transfer limit",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="A deployment-wide hard ceiling; individual module upload policies may impose lower limits.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
|
||||||
|
label="Connector secret environment allowlist",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="destructive",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Exact environment names available only to deployment-owned connector profiles; tenant/API profiles cannot select process variables.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST",
|
||||||
|
label="Connector CA bundle allowlist",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Exact absolute CA bundle paths approved by the deployment and mounted consistently on every connector worker.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES",
|
||||||
|
label="HTTP request-body limit",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="A deployment-wide hard ceiling; endpoint-specific upload policies may impose lower limits.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="GOVOPLAN_HTTP_HSTS_SECONDS",
|
||||||
|
label="HTTP Strict Transport Security duration",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Deployment-managed browser transport policy; use only after the public service is HTTPS-only.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="AUTH_LOGIN_THROTTLE_ENABLED",
|
||||||
|
label="Interactive login throttling",
|
||||||
|
owner_module="access",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Disabling the deployment login throttle weakens protection against password guessing.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT",
|
||||||
|
label="Login failures per identity",
|
||||||
|
owner_module="access",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Shared through Redis when available, with a bounded process-local fallback.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="AUTH_LOGIN_THROTTLE_CLIENT_LIMIT",
|
||||||
|
label="Login failures per client",
|
||||||
|
owner_module="access",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Counts the direct peer after the deployment's trusted-proxy boundary is applied.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="AUTH_LOGIN_THROTTLE_WINDOW_SECONDS",
|
||||||
|
label="Login throttle window",
|
||||||
|
owner_module="access",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Fixed counter window shared by identity and direct-client limits.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS",
|
||||||
|
label="Login throttle Redis retry interval",
|
||||||
|
owner_module="access",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="medium",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Controls how quickly a worker retries the distributed counter after falling back locally.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="GOVOPLAN_TRUSTED_HOSTS",
|
||||||
|
label="Trusted HTTP hosts",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Host-header validation is deployment-managed and must cover every public API host.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="FORWARDED_ALLOW_IPS",
|
||||||
|
label="Trusted reverse proxy addresses",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Uvicorn trusts forwarded client and scheme data only from this deployment-managed boundary.",
|
||||||
|
),
|
||||||
ConfigurationFieldSafety(
|
ConfigurationFieldSafety(
|
||||||
key="GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS",
|
key="GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS",
|
||||||
label="Module package catalog trusted keys",
|
label="Module package catalog trusted keys",
|
||||||
@@ -288,7 +477,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
|||||||
storage="environment",
|
storage="environment",
|
||||||
ui_managed=False,
|
ui_managed=False,
|
||||||
risk="high",
|
risk="high",
|
||||||
secret_handling="env_only",
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
notes="Trust roots are deployment-managed; UI can validate catalogs but should not edit key material.",
|
notes="Trust roots are deployment-managed; UI can validate catalogs but should not edit key material.",
|
||||||
),
|
),
|
||||||
ConfigurationFieldSafety(
|
ConfigurationFieldSafety(
|
||||||
@@ -299,7 +488,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
|||||||
storage="environment",
|
storage="environment",
|
||||||
ui_managed=False,
|
ui_managed=False,
|
||||||
risk="high",
|
risk="high",
|
||||||
secret_handling="env_only",
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
notes="Configuration package trust roots are deployment-managed.",
|
notes="Configuration package trust roots are deployment-managed.",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -339,6 +528,26 @@ def plan_configuration_change(
|
|||||||
) -> ConfigurationChangeSafetyPlan:
|
) -> ConfigurationChangeSafetyPlan:
|
||||||
field = classify_configuration_field(key)
|
field = classify_configuration_field(key)
|
||||||
if field is None:
|
if field is None:
|
||||||
|
return _unknown_configuration_plan(key)
|
||||||
|
if not include_env_only and not field.ui_managed:
|
||||||
|
return _deployment_managed_configuration_plan(key, field)
|
||||||
|
state = _configuration_change_safety_state(
|
||||||
|
field,
|
||||||
|
actor_scopes=actor_scopes,
|
||||||
|
value=value,
|
||||||
|
dry_run=dry_run,
|
||||||
|
maintenance_mode=maintenance_mode,
|
||||||
|
approval_count=approval_count,
|
||||||
|
)
|
||||||
|
return _configuration_change_safety_plan(
|
||||||
|
field,
|
||||||
|
state=state,
|
||||||
|
dry_run=dry_run,
|
||||||
|
maintenance_mode=maintenance_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unknown_configuration_plan(key: str) -> ConfigurationChangeSafetyPlan:
|
||||||
return ConfigurationChangeSafetyPlan(
|
return ConfigurationChangeSafetyPlan(
|
||||||
key=key,
|
key=key,
|
||||||
allowed=False,
|
allowed=False,
|
||||||
@@ -346,7 +555,9 @@ def plan_configuration_change(
|
|||||||
blockers=("unknown_configuration_field",),
|
blockers=("unknown_configuration_field",),
|
||||||
policy_explanation="This setting is not in the configuration safety catalog.",
|
policy_explanation="This setting is not in the configuration safety catalog.",
|
||||||
)
|
)
|
||||||
if not include_env_only and not field.ui_managed:
|
|
||||||
|
|
||||||
|
def _deployment_managed_configuration_plan(key: str, field: ConfigurationFieldSafety) -> ConfigurationChangeSafetyPlan:
|
||||||
return ConfigurationChangeSafetyPlan(
|
return ConfigurationChangeSafetyPlan(
|
||||||
key=key,
|
key=key,
|
||||||
allowed=False,
|
allowed=False,
|
||||||
@@ -356,6 +567,17 @@ def plan_configuration_change(
|
|||||||
blockers=("deployment_managed",),
|
blockers=("deployment_managed",),
|
||||||
policy_explanation="This setting is deployment-managed and cannot be edited through the UI.",
|
policy_explanation="This setting is deployment-managed and cannot be edited through the UI.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_change_safety_state(
|
||||||
|
field: ConfigurationFieldSafety,
|
||||||
|
*,
|
||||||
|
actor_scopes: tuple[str, ...] | list[str],
|
||||||
|
value: object,
|
||||||
|
dry_run: bool,
|
||||||
|
maintenance_mode: bool,
|
||||||
|
approval_count: int,
|
||||||
|
) -> _ConfigurationChangeSafetyState:
|
||||||
blockers: list[str] = []
|
blockers: list[str] = []
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
missing_scopes = tuple(scope for scope in field.required_scopes if not scopes_grant(actor_scopes, scope))
|
missing_scopes = tuple(scope for scope in field.required_scopes if not scopes_grant(actor_scopes, scope))
|
||||||
@@ -371,30 +593,47 @@ def plan_configuration_change(
|
|||||||
approval_satisfied = not approval_required or approval_count >= 2
|
approval_satisfied = not approval_required or approval_count >= 2
|
||||||
if approval_required and not approval_satisfied:
|
if approval_required and not approval_satisfied:
|
||||||
blockers.append("two_person_approval_required")
|
blockers.append("two_person_approval_required")
|
||||||
if field.secret_handling == "reference_only" and _contains_plain_secret(value):
|
if field.secret_handling == "reference_only" and _contains_plain_secret(value): # noqa: S105 # nosec B105 - policy vocabulary.
|
||||||
blockers.append("secret_reference_required")
|
blockers.append("secret_reference_required")
|
||||||
if field.secret_handling == "env_only" and value is not None:
|
if field.secret_handling == "env_only" and value is not None: # noqa: S105 # nosec B105 - policy vocabulary.
|
||||||
blockers.append("env_only_secret")
|
blockers.append("env_only_secret")
|
||||||
if field.rollback_history_required:
|
if field.rollback_history_required:
|
||||||
warnings.append("rollback_history_required")
|
warnings.append("rollback_history_required")
|
||||||
return ConfigurationChangeSafetyPlan(
|
return _ConfigurationChangeSafetyState(
|
||||||
key=field.key,
|
|
||||||
allowed=not blockers,
|
|
||||||
field=field,
|
field=field,
|
||||||
risk=field.risk,
|
|
||||||
missing_scopes=missing_scopes,
|
missing_scopes=missing_scopes,
|
||||||
dry_run_required=field.dry_run_required,
|
blockers=tuple(dict.fromkeys(blockers)),
|
||||||
dry_run_satisfied=not field.dry_run_required or dry_run,
|
warnings=tuple(dict.fromkeys(warnings)),
|
||||||
approval_required=approval_required,
|
approval_required=approval_required,
|
||||||
approval_satisfied=approval_satisfied,
|
approval_satisfied=approval_satisfied,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_change_safety_plan(
|
||||||
|
field: ConfigurationFieldSafety,
|
||||||
|
*,
|
||||||
|
state: _ConfigurationChangeSafetyState,
|
||||||
|
dry_run: bool,
|
||||||
|
maintenance_mode: bool,
|
||||||
|
) -> ConfigurationChangeSafetyPlan:
|
||||||
|
return ConfigurationChangeSafetyPlan(
|
||||||
|
key=field.key,
|
||||||
|
allowed=not state.blockers,
|
||||||
|
field=field,
|
||||||
|
risk=field.risk,
|
||||||
|
missing_scopes=state.missing_scopes,
|
||||||
|
dry_run_required=field.dry_run_required,
|
||||||
|
dry_run_satisfied=not field.dry_run_required or dry_run,
|
||||||
|
approval_required=state.approval_required,
|
||||||
|
approval_satisfied=state.approval_satisfied,
|
||||||
maintenance_required=field.maintenance_required,
|
maintenance_required=field.maintenance_required,
|
||||||
maintenance_satisfied=not field.maintenance_required or maintenance_mode,
|
maintenance_satisfied=not field.maintenance_required or maintenance_mode,
|
||||||
rollback_history_required=field.rollback_history_required,
|
rollback_history_required=field.rollback_history_required,
|
||||||
secret_handling=field.secret_handling,
|
secret_handling=field.secret_handling,
|
||||||
audit_event=field.audit_event,
|
audit_event=field.audit_event,
|
||||||
policy_explanation=_policy_explanation(field),
|
policy_explanation=_policy_explanation(field),
|
||||||
blockers=tuple(dict.fromkeys(blockers)),
|
blockers=state.blockers,
|
||||||
warnings=tuple(dict.fromkeys(warnings)),
|
warnings=state.warnings,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -406,7 +645,7 @@ def _policy_explanation(field: ConfigurationFieldSafety) -> str:
|
|||||||
parts.append("requires two-person approval")
|
parts.append("requires two-person approval")
|
||||||
if field.maintenance_required:
|
if field.maintenance_required:
|
||||||
parts.append("requires maintenance mode")
|
parts.append("requires maintenance mode")
|
||||||
if field.secret_handling != "none":
|
if field.secret_handling != "none": # noqa: S105 # nosec B105 - policy vocabulary.
|
||||||
parts.append(f"uses {field.secret_handling} secret handling")
|
parts.append(f"uses {field.secret_handling} secret handling")
|
||||||
return "; ".join(parts) + "."
|
return "; ".join(parts) + "."
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
CONNECTOR_RUNTIME_CONTRACT_VERSION = "1.0"
|
||||||
|
|
||||||
|
ConnectorDiagnosticSeverity = Literal["info", "warning", "error"]
|
||||||
|
ConnectorDiagnosticStage = Literal[
|
||||||
|
"configuration",
|
||||||
|
"authentication",
|
||||||
|
"discovery",
|
||||||
|
"read",
|
||||||
|
"mapping",
|
||||||
|
"planning",
|
||||||
|
"apply",
|
||||||
|
"reconciliation",
|
||||||
|
]
|
||||||
|
ConnectorEffectKind = Literal[
|
||||||
|
"create",
|
||||||
|
"update",
|
||||||
|
"delete",
|
||||||
|
"conflict",
|
||||||
|
"unchanged",
|
||||||
|
"ignored",
|
||||||
|
]
|
||||||
|
ConnectorOutcomeState = Literal[
|
||||||
|
"preview",
|
||||||
|
"accepted",
|
||||||
|
"rejected",
|
||||||
|
"outcome_unknown",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorContractError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectorEndpoint:
|
||||||
|
"""Sanitized endpoint identity. Credentials never belong in this value."""
|
||||||
|
|
||||||
|
url: str
|
||||||
|
credential_ref: str | None = None
|
||||||
|
tls_mode: Literal["required", "start_tls", "system", "disabled"] = "required"
|
||||||
|
options: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
normalized = self.url.strip()
|
||||||
|
parsed = urlsplit(normalized)
|
||||||
|
if not parsed.scheme or not parsed.hostname:
|
||||||
|
raise ConnectorContractError("Connector endpoints require an absolute URL.")
|
||||||
|
if parsed.username is not None or parsed.password is not None:
|
||||||
|
raise ConnectorContractError(
|
||||||
|
"Connector endpoint URLs must not contain credentials."
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "url", normalized)
|
||||||
|
if self.credential_ref is not None:
|
||||||
|
credential_ref = self.credential_ref.strip()
|
||||||
|
if not credential_ref:
|
||||||
|
raise ConnectorContractError("Credential references cannot be blank.")
|
||||||
|
object.__setattr__(self, "credential_ref", credential_ref)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectorDiagnostic:
|
||||||
|
severity: ConnectorDiagnosticSeverity
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
stage: ConnectorDiagnosticStage
|
||||||
|
retryable: bool = False
|
||||||
|
source_ref: str | None = None
|
||||||
|
object_ref: str | None = None
|
||||||
|
details: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.code.strip() or len(self.code) > 120:
|
||||||
|
raise ConnectorContractError(
|
||||||
|
"Connector diagnostic codes must contain 1 to 120 characters."
|
||||||
|
)
|
||||||
|
if not self.message.strip():
|
||||||
|
raise ConnectorContractError("Connector diagnostic messages cannot be blank.")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectorEffectPreview:
|
||||||
|
effect: ConnectorEffectKind
|
||||||
|
source_object_ref: str
|
||||||
|
target_object_ref: str | None = None
|
||||||
|
changed_fields: tuple[str, ...] = ()
|
||||||
|
sample: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
reason_code: str | None = None
|
||||||
|
outcome: ConnectorOutcomeState = "preview"
|
||||||
|
revision: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.source_object_ref.strip():
|
||||||
|
raise ConnectorContractError("Preview effects require a source object reference.")
|
||||||
|
if self.outcome != "preview":
|
||||||
|
raise ConnectorContractError("Dry-run effects must retain the preview outcome.")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectorEffectSummary:
|
||||||
|
creates: int = 0
|
||||||
|
updates: int = 0
|
||||||
|
deletes: int = 0
|
||||||
|
conflicts: int = 0
|
||||||
|
unchanged: int = 0
|
||||||
|
ignored: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> int:
|
||||||
|
return (
|
||||||
|
self.creates
|
||||||
|
+ self.updates
|
||||||
|
+ self.deletes
|
||||||
|
+ self.conflicts
|
||||||
|
+ self.unchanged
|
||||||
|
+ self.ignored
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectorDryRunRequest:
|
||||||
|
tenant_id: str
|
||||||
|
source_ref: str
|
||||||
|
force_full: bool = False
|
||||||
|
max_items: int = 1_000
|
||||||
|
expected_source_revision: str | None = None
|
||||||
|
context: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.tenant_id.strip() or not self.source_ref.strip():
|
||||||
|
raise ConnectorContractError("Dry runs require tenant and source references.")
|
||||||
|
if not 1 <= self.max_items <= 10_000:
|
||||||
|
raise ConnectorContractError("Dry-run max_items must be between 1 and 10000.")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectorDryRunResult:
|
||||||
|
contract_version: str
|
||||||
|
source_ref: str
|
||||||
|
source_revision: str
|
||||||
|
source_fingerprint: str
|
||||||
|
input_hash: str
|
||||||
|
generated_at: datetime
|
||||||
|
summary: ConnectorEffectSummary
|
||||||
|
effects: tuple[ConnectorEffectPreview, ...] = ()
|
||||||
|
diagnostics: tuple[ConnectorDiagnostic, ...] = ()
|
||||||
|
truncated: bool = False
|
||||||
|
stale: bool = False
|
||||||
|
apply_token: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.contract_version != CONNECTOR_RUNTIME_CONTRACT_VERSION:
|
||||||
|
raise ConnectorContractError(
|
||||||
|
f"Unsupported connector contract version: {self.contract_version!r}."
|
||||||
|
)
|
||||||
|
for name in ("source_ref", "source_revision", "source_fingerprint", "input_hash"):
|
||||||
|
if not str(getattr(self, name)).strip():
|
||||||
|
raise ConnectorContractError(f"Dry-run {name} cannot be blank.")
|
||||||
|
if self.summary.total != len(self.effects):
|
||||||
|
raise ConnectorContractError(
|
||||||
|
"Dry-run summary counts must match the returned effect list."
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_apply(self) -> bool:
|
||||||
|
return (
|
||||||
|
self.apply_token is not None
|
||||||
|
and not self.truncated
|
||||||
|
and not self.stale
|
||||||
|
and self.summary.conflicts == 0
|
||||||
|
and not any(item.severity == "error" for item in self.diagnostics)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_connector_effects(
|
||||||
|
effects: tuple[ConnectorEffectPreview, ...],
|
||||||
|
) -> ConnectorEffectSummary:
|
||||||
|
counts = Counter(item.effect for item in effects)
|
||||||
|
return ConnectorEffectSummary(
|
||||||
|
creates=counts["create"],
|
||||||
|
updates=counts["update"],
|
||||||
|
deletes=counts["delete"],
|
||||||
|
conflicts=counts["conflict"],
|
||||||
|
unchanged=counts["unchanged"],
|
||||||
|
ignored=counts["ignored"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ConnectorDryRunProvider(Protocol):
|
||||||
|
def preview(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ConnectorDryRunRequest,
|
||||||
|
) -> ConnectorDryRunResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CONNECTOR_RUNTIME_CONTRACT_VERSION",
|
||||||
|
"ConnectorContractError",
|
||||||
|
"ConnectorDiagnostic",
|
||||||
|
"ConnectorDiagnosticSeverity",
|
||||||
|
"ConnectorDiagnosticStage",
|
||||||
|
"ConnectorDryRunProvider",
|
||||||
|
"ConnectorDryRunRequest",
|
||||||
|
"ConnectorDryRunResult",
|
||||||
|
"ConnectorEffectKind",
|
||||||
|
"ConnectorEffectPreview",
|
||||||
|
"ConnectorEffectSummary",
|
||||||
|
"ConnectorEndpoint",
|
||||||
|
"ConnectorOutcomeState",
|
||||||
|
"summarize_connector_effects",
|
||||||
|
]
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
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.distribution_lists import (
|
||||||
|
DistributionChannel,
|
||||||
|
DistributionExplanation,
|
||||||
|
DistributionOutcome,
|
||||||
|
DistributionSourceReference,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION = "addresses.contact_point_resolution"
|
||||||
|
CONTACT_POINT_CONTRACT_VERSION = "1.0"
|
||||||
|
|
||||||
|
ContactPointFallbackRule = Literal["none", "primary", "any"]
|
||||||
|
PostalAddressFormat = Literal["domestic", "international"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ContactPointResolutionRequest:
|
||||||
|
tenant_id: str
|
||||||
|
subject: DistributionSourceReference
|
||||||
|
effective_at: datetime
|
||||||
|
purpose: str | None = None
|
||||||
|
requested_channels: tuple[DistributionChannel, ...] = ()
|
||||||
|
address_purpose: str | None = None
|
||||||
|
fallback_rule: ContactPointFallbackRule = "primary"
|
||||||
|
locale: str | None = None
|
||||||
|
postal_format: PostalAddressFormat = "domestic"
|
||||||
|
context: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ContactPointCandidate:
|
||||||
|
channel: DistributionChannel
|
||||||
|
target: str
|
||||||
|
target_key: str
|
||||||
|
status: DistributionOutcome
|
||||||
|
contact_point_id: str | None = None
|
||||||
|
address_purpose: str | None = None
|
||||||
|
locale: str | None = None
|
||||||
|
preferred: bool = False
|
||||||
|
preference_rank: int | None = None
|
||||||
|
reason_code: str | None = None
|
||||||
|
explanation: str | None = None
|
||||||
|
source: DistributionSourceReference | None = None
|
||||||
|
source_revision: str | None = None
|
||||||
|
preference_revision: str | None = None
|
||||||
|
consent_revision: str | None = None
|
||||||
|
value: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ContactPointResolution:
|
||||||
|
contract_version: str
|
||||||
|
subject: DistributionSourceReference
|
||||||
|
status: DistributionOutcome
|
||||||
|
contact_id: str | None = None
|
||||||
|
display_name: str | None = None
|
||||||
|
candidates: tuple[ContactPointCandidate, ...] = ()
|
||||||
|
excluded: tuple[ContactPointCandidate, ...] = ()
|
||||||
|
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 ContactPointSourceRequest:
|
||||||
|
tenant_id: str
|
||||||
|
source_id: str
|
||||||
|
effective_at: datetime
|
||||||
|
purpose: str | None = None
|
||||||
|
requested_channels: tuple[DistributionChannel, ...] = ()
|
||||||
|
address_purpose: str | None = None
|
||||||
|
fallback_rule: ContactPointFallbackRule = "primary"
|
||||||
|
locale: str | None = None
|
||||||
|
postal_format: PostalAddressFormat = "domestic"
|
||||||
|
max_items: int = 5_000
|
||||||
|
context: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ContactPointSourcePreview:
|
||||||
|
contract_version: str
|
||||||
|
source: DistributionSourceReference
|
||||||
|
request: ContactPointSourceRequest
|
||||||
|
resolutions: tuple[ContactPointResolution, ...]
|
||||||
|
total_count: int
|
||||||
|
usable_count: int
|
||||||
|
excluded_count: int
|
||||||
|
offset: int
|
||||||
|
limit: int
|
||||||
|
has_more: bool
|
||||||
|
source_revision: str
|
||||||
|
source_fingerprint: str
|
||||||
|
generated_at: datetime
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ContactPointSnapshotRef:
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
contract_version: str
|
||||||
|
source: DistributionSourceReference
|
||||||
|
request: ContactPointSourceRequest
|
||||||
|
resolutions: tuple[ContactPointResolution, ...]
|
||||||
|
recipient_count: int
|
||||||
|
excluded_count: int
|
||||||
|
source_revision: str
|
||||||
|
source_fingerprint: str
|
||||||
|
snapshot_hash: str
|
||||||
|
generated_at: datetime
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ContactPointResolutionProvider(Protocol):
|
||||||
|
def resolve_contact_points(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ContactPointResolutionRequest,
|
||||||
|
) -> ContactPointResolution:
|
||||||
|
...
|
||||||
|
|
||||||
|
def preview_source(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ContactPointSourceRequest,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> ContactPointSourcePreview:
|
||||||
|
...
|
||||||
|
|
||||||
|
def freeze_source(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ContactPointSourceRequest,
|
||||||
|
) -> ContactPointSnapshotRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_snapshot(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
snapshot_id: str,
|
||||||
|
) -> ContactPointSnapshotRef | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def contact_point_resolution_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> ContactPointResolutionProvider | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION)
|
||||||
|
return capability if isinstance(capability, ContactPointResolutionProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION",
|
||||||
|
"CONTACT_POINT_CONTRACT_VERSION",
|
||||||
|
"ContactPointCandidate",
|
||||||
|
"ContactPointFallbackRule",
|
||||||
|
"ContactPointResolution",
|
||||||
|
"ContactPointResolutionProvider",
|
||||||
|
"ContactPointResolutionRequest",
|
||||||
|
"ContactPointSnapshotRef",
|
||||||
|
"ContactPointSourcePreview",
|
||||||
|
"ContactPointSourceRequest",
|
||||||
|
"PostalAddressFormat",
|
||||||
|
"contact_point_resolution_provider",
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -0,0 +1,617 @@
|
|||||||
|
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.external_references import (
|
||||||
|
SOURCE_AUTHORITY_MODES,
|
||||||
|
SourceAuthorityMode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 DatasourceGovernance:
|
||||||
|
"""Provider-neutral governance facts attached to a datasource revision."""
|
||||||
|
|
||||||
|
owner_ref: str | None = None
|
||||||
|
steward_ref: str | None = None
|
||||||
|
responsible_organization_ref: str | None = None
|
||||||
|
responsible_function_ref: str | None = None
|
||||||
|
authoritative_source_ref: str | None = None
|
||||||
|
authority_mode: SourceAuthorityMode = "linked_reference"
|
||||||
|
legal_basis_refs: tuple[str, ...] = ()
|
||||||
|
purposes: tuple[str, ...] = ()
|
||||||
|
semantic_definition: str | None = None
|
||||||
|
schema_owner_ref: str | None = None
|
||||||
|
official_keys: tuple[str, ...] = ()
|
||||||
|
classification: str = "internal"
|
||||||
|
privacy_profile_ref: str | None = None
|
||||||
|
retention_policy_ref: str | None = None
|
||||||
|
hold_refs: tuple[str, ...] = ()
|
||||||
|
publication_state: str = "draft"
|
||||||
|
transfer_agreement_ref: str | None = None
|
||||||
|
freshness_policy: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
quality_policy: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
known_limits: tuple[str, ...] = ()
|
||||||
|
correction_procedure_ref: str | None = None
|
||||||
|
affected_refs: tuple[str, ...] = ()
|
||||||
|
dependency_refs: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.authority_mode not in SOURCE_AUTHORITY_MODES:
|
||||||
|
raise DatasourceValidationError(
|
||||||
|
f"Unsupported datasource authority mode: {self.authority_mode!r}."
|
||||||
|
)
|
||||||
|
if not self.classification.strip():
|
||||||
|
raise DatasourceValidationError("Datasource classification is required.")
|
||||||
|
if not self.publication_state.strip():
|
||||||
|
raise DatasourceValidationError("Datasource publication state is required.")
|
||||||
|
for field_name in (
|
||||||
|
"legal_basis_refs",
|
||||||
|
"purposes",
|
||||||
|
"official_keys",
|
||||||
|
"hold_refs",
|
||||||
|
"known_limits",
|
||||||
|
"affected_refs",
|
||||||
|
"dependency_refs",
|
||||||
|
):
|
||||||
|
values = getattr(self, field_name)
|
||||||
|
if any(not value.strip() for value in values):
|
||||||
|
raise DatasourceValidationError(
|
||||||
|
f"Datasource governance {field_name} cannot contain empty values."
|
||||||
|
)
|
||||||
|
if len(values) != len(set(values)):
|
||||||
|
raise DatasourceValidationError(
|
||||||
|
f"Datasource governance {field_name} cannot contain duplicates."
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_mapping(cls, value: Mapping[str, object] | None) -> "DatasourceGovernance":
|
||||||
|
source = value or {}
|
||||||
|
return cls(
|
||||||
|
owner_ref=_optional_governance_text(source.get("owner_ref")),
|
||||||
|
steward_ref=_optional_governance_text(source.get("steward_ref")),
|
||||||
|
responsible_organization_ref=_optional_governance_text(
|
||||||
|
source.get("responsible_organization_ref")
|
||||||
|
),
|
||||||
|
responsible_function_ref=_optional_governance_text(
|
||||||
|
source.get("responsible_function_ref")
|
||||||
|
),
|
||||||
|
authoritative_source_ref=_optional_governance_text(
|
||||||
|
source.get("authoritative_source_ref")
|
||||||
|
),
|
||||||
|
authority_mode=str(
|
||||||
|
source.get("authority_mode") or "linked_reference"
|
||||||
|
), # type: ignore[arg-type]
|
||||||
|
legal_basis_refs=_governance_texts(source.get("legal_basis_refs")),
|
||||||
|
purposes=_governance_texts(source.get("purposes")),
|
||||||
|
semantic_definition=_optional_governance_text(
|
||||||
|
source.get("semantic_definition")
|
||||||
|
),
|
||||||
|
schema_owner_ref=_optional_governance_text(source.get("schema_owner_ref")),
|
||||||
|
official_keys=_governance_texts(source.get("official_keys")),
|
||||||
|
classification=str(source.get("classification") or "internal"),
|
||||||
|
privacy_profile_ref=_optional_governance_text(
|
||||||
|
source.get("privacy_profile_ref")
|
||||||
|
),
|
||||||
|
retention_policy_ref=_optional_governance_text(
|
||||||
|
source.get("retention_policy_ref")
|
||||||
|
),
|
||||||
|
hold_refs=_governance_texts(source.get("hold_refs")),
|
||||||
|
publication_state=str(source.get("publication_state") or "draft"),
|
||||||
|
transfer_agreement_ref=_optional_governance_text(
|
||||||
|
source.get("transfer_agreement_ref")
|
||||||
|
),
|
||||||
|
freshness_policy=_governance_mapping(source.get("freshness_policy")),
|
||||||
|
quality_policy=_governance_mapping(source.get("quality_policy")),
|
||||||
|
known_limits=_governance_texts(source.get("known_limits")),
|
||||||
|
correction_procedure_ref=_optional_governance_text(
|
||||||
|
source.get("correction_procedure_ref")
|
||||||
|
),
|
||||||
|
affected_refs=_governance_texts(source.get("affected_refs")),
|
||||||
|
dependency_refs=_governance_texts(source.get("dependency_refs")),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"owner_ref": self.owner_ref,
|
||||||
|
"steward_ref": self.steward_ref,
|
||||||
|
"responsible_organization_ref": self.responsible_organization_ref,
|
||||||
|
"responsible_function_ref": self.responsible_function_ref,
|
||||||
|
"authoritative_source_ref": self.authoritative_source_ref,
|
||||||
|
"authority_mode": self.authority_mode,
|
||||||
|
"legal_basis_refs": list(self.legal_basis_refs),
|
||||||
|
"purposes": list(self.purposes),
|
||||||
|
"semantic_definition": self.semantic_definition,
|
||||||
|
"schema_owner_ref": self.schema_owner_ref,
|
||||||
|
"official_keys": list(self.official_keys),
|
||||||
|
"classification": self.classification,
|
||||||
|
"privacy_profile_ref": self.privacy_profile_ref,
|
||||||
|
"retention_policy_ref": self.retention_policy_ref,
|
||||||
|
"hold_refs": list(self.hold_refs),
|
||||||
|
"publication_state": self.publication_state,
|
||||||
|
"transfer_agreement_ref": self.transfer_agreement_ref,
|
||||||
|
"freshness_policy": dict(self.freshness_policy),
|
||||||
|
"quality_policy": dict(self.quality_policy),
|
||||||
|
"known_limits": list(self.known_limits),
|
||||||
|
"correction_procedure_ref": self.correction_procedure_ref,
|
||||||
|
"affected_refs": list(self.affected_refs),
|
||||||
|
"dependency_refs": list(self.dependency_refs),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
governance: DatasourceGovernance = field(default_factory=DatasourceGovernance)
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
governance: DatasourceGovernance = field(default_factory=DatasourceGovernance)
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
governance: DatasourceGovernance = field(default_factory=DatasourceGovernance)
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
governance: DatasourceGovernance | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
governance: DatasourceGovernance | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
|
authority_mode: str | None = None,
|
||||||
|
classification: str | None = None,
|
||||||
|
publication_state: str | None = None,
|
||||||
|
owner_ref: str | None = None,
|
||||||
|
responsible_organization_ref: str | None = None,
|
||||||
|
affected_ref: str | None = None,
|
||||||
|
dependency_ref: str | None = None,
|
||||||
|
) -> 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,
|
||||||
|
governance: DatasourceGovernance | None = None,
|
||||||
|
) -> DatasourceDescriptor:
|
||||||
|
...
|
||||||
|
|
||||||
|
def update_datasource_governance(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
datasource_ref: str,
|
||||||
|
governance: DatasourceGovernance,
|
||||||
|
) -> 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)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_governance_text(value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
cleaned = str(value).strip()
|
||||||
|
return cleaned or None
|
||||||
|
|
||||||
|
|
||||||
|
def _governance_texts(value: object) -> tuple[str, ...]:
|
||||||
|
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||||
|
return ()
|
||||||
|
return tuple(str(item).strip() for item in value)
|
||||||
|
|
||||||
|
|
||||||
|
def _governance_mapping(value: object) -> Mapping[str, object]:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
return {}
|
||||||
|
return {str(key): item for key, item in value.items()}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_DATASOURCE_CATALOGUE",
|
||||||
|
"CAPABILITY_DATASOURCE_LIFECYCLE",
|
||||||
|
"CAPABILITY_DATASOURCE_ORIGINS",
|
||||||
|
"DatasourceAccessError",
|
||||||
|
"DatasourceCatalogueProvider",
|
||||||
|
"DatasourceConsistency",
|
||||||
|
"DatasourceDescriptor",
|
||||||
|
"DatasourceError",
|
||||||
|
"DatasourceField",
|
||||||
|
"DatasourceGovernance",
|
||||||
|
"DatasourceKind",
|
||||||
|
"DatasourceLifecycleProvider",
|
||||||
|
"DatasourceMaterialization",
|
||||||
|
"DatasourceMode",
|
||||||
|
"DatasourceNotFoundError",
|
||||||
|
"DatasourceOrigin",
|
||||||
|
"DatasourceOriginProvider",
|
||||||
|
"DatasourceOriginReadRequest",
|
||||||
|
"DatasourceOriginReadResult",
|
||||||
|
"DatasourceReadRequest",
|
||||||
|
"DatasourceReadResult",
|
||||||
|
"DatasourceShape",
|
||||||
|
"DatasourceStage",
|
||||||
|
"DatasourceStageInput",
|
||||||
|
"DatasourceUnavailableError",
|
||||||
|
"DatasourceValidationError",
|
||||||
|
"datasource_catalogue",
|
||||||
|
"datasource_lifecycle",
|
||||||
|
"datasource_origins",
|
||||||
|
]
|
||||||
@@ -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_")]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,25 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import re
|
import re
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal, Protocol, runtime_checkable
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import event as sqlalchemy_event
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.institutional import GovernedContextEnvelope
|
||||||
|
|
||||||
|
|
||||||
_TRACE_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
|
_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:
|
def new_event_id() -> str:
|
||||||
@@ -81,6 +89,7 @@ class PlatformEvent:
|
|||||||
subject: EventObjectRef | None = None
|
subject: EventObjectRef | None = None
|
||||||
resource: EventObjectRef | None = None
|
resource: EventObjectRef | None = None
|
||||||
classification: EventClassification = "internal"
|
classification: EventClassification = "internal"
|
||||||
|
institutional_context: GovernedContextEnvelope | None = None
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -96,10 +105,114 @@ class PlatformEvent:
|
|||||||
"subject": self.subject.to_dict() if self.subject else None,
|
"subject": self.subject.to_dict() if self.subject else None,
|
||||||
"resource": self.resource.to_dict() if self.resource else None,
|
"resource": self.resource.to_dict() if self.resource else None,
|
||||||
"classification": self.classification,
|
"classification": self.classification,
|
||||||
|
"institutional_context": (
|
||||||
|
self.institutional_context.to_dict()
|
||||||
|
if self.institutional_context is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
EventHandler = Callable[[PlatformEvent], None]
|
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:
|
def current_event_trace() -> EventTrace | None:
|
||||||
@@ -149,6 +262,7 @@ def ensure_event_trace(event: PlatformEvent) -> PlatformEvent:
|
|||||||
subject=event.subject,
|
subject=event.subject,
|
||||||
resource=event.resource,
|
resource=event.resource,
|
||||||
classification=event.classification,
|
classification=event.classification,
|
||||||
|
institutional_context=event.institutional_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -189,5 +303,85 @@ def publish_platform_event(event: PlatformEvent) -> None:
|
|||||||
platform_event_bus().publish(event)
|
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]:
|
def _compact_dict(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
return {key: item for key, item in value.items() if item is not None}
|
return {key: item for key, item in value.items() if item is not None}
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
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",
|
||||||
|
]
|
||||||
|
SourceAuthorityMode = Literal[
|
||||||
|
"native_authoritative",
|
||||||
|
"external_authoritative",
|
||||||
|
"external_mirror",
|
||||||
|
"governed_sync",
|
||||||
|
"governance_overlay",
|
||||||
|
"linked_reference",
|
||||||
|
]
|
||||||
|
|
||||||
|
INTEGRATION_MATURITY_ORDER: tuple[IntegrationMaturity, ...] = (
|
||||||
|
"discover",
|
||||||
|
"link",
|
||||||
|
"search",
|
||||||
|
"read",
|
||||||
|
"publish",
|
||||||
|
"synchronize",
|
||||||
|
"migrate",
|
||||||
|
"replace",
|
||||||
|
)
|
||||||
|
SOURCE_AUTHORITY_MODES: tuple[SourceAuthorityMode, ...] = (
|
||||||
|
"native_authoritative",
|
||||||
|
"external_authoritative",
|
||||||
|
"external_mirror",
|
||||||
|
"governed_sync",
|
||||||
|
"governance_overlay",
|
||||||
|
"linked_reference",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
authority_mode: SourceAuthorityMode = "linked_reference"
|
||||||
|
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.authority_mode not in SOURCE_AUTHORITY_MODES:
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
f"Unsupported source-authority mode: {self.authority_mode!r}."
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
self.authority_mode == "external_mirror"
|
||||||
|
and not self.supports("read")
|
||||||
|
):
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
"External-mirror references require read maturity or higher."
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
self.authority_mode == "governed_sync"
|
||||||
|
and not self.supports("synchronize")
|
||||||
|
):
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
"Governed-sync references require synchronize maturity or higher."
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
"authority_mode": self.authority_mode,
|
||||||
|
"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",
|
||||||
|
"SOURCE_AUTHORITY_MODES",
|
||||||
|
"SourceAuthorityMode",
|
||||||
|
"integration_maturity_rank",
|
||||||
|
]
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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_CONNECTORS_FEEDS = "connectors.feeds"
|
||||||
|
FeedFormat = Literal["rss", "atom"]
|
||||||
|
FeedVisibility = Literal["public", "tenant", "private"]
|
||||||
|
|
||||||
|
|
||||||
|
class FeedCapabilityError(ValueError):
|
||||||
|
"""Stable error raised by feed transport implementations."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FeedEntry:
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
url: str | None = None
|
||||||
|
summary: str | None = None
|
||||||
|
content: str | None = None
|
||||||
|
author: str | None = None
|
||||||
|
published_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
categories: tuple[str, ...] = ()
|
||||||
|
enclosures: tuple[Mapping[str, object], ...] = ()
|
||||||
|
visibility: FeedVisibility = "public"
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FeedDocument:
|
||||||
|
format: FeedFormat
|
||||||
|
title: str
|
||||||
|
source_url: str
|
||||||
|
entries: tuple[FeedEntry, ...]
|
||||||
|
description: str | None = None
|
||||||
|
home_url: str | None = None
|
||||||
|
language: str | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
acquired_at: datetime | None = None
|
||||||
|
fresh_until: datetime | None = None
|
||||||
|
etag: str | None = None
|
||||||
|
last_modified: str | None = None
|
||||||
|
content_type: str | None = None
|
||||||
|
sha256: str = ""
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FeedRenderRequest:
|
||||||
|
format: FeedFormat
|
||||||
|
title: str
|
||||||
|
feed_url: str
|
||||||
|
home_url: str
|
||||||
|
entries: tuple[FeedEntry, ...]
|
||||||
|
description: str | None = None
|
||||||
|
language: str | None = None
|
||||||
|
allowed_visibilities: frozenset[FeedVisibility] = frozenset({"public"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FeedRenderResult:
|
||||||
|
format: FeedFormat
|
||||||
|
content_type: str
|
||||||
|
body: bytes
|
||||||
|
included_entries: int
|
||||||
|
excluded_entries: int
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class FeedProvider(Protocol):
|
||||||
|
def fetch(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
timeout: float = 15,
|
||||||
|
max_entries: int = 2_000,
|
||||||
|
) -> FeedDocument:
|
||||||
|
...
|
||||||
|
|
||||||
|
def parse(
|
||||||
|
self,
|
||||||
|
content: bytes,
|
||||||
|
*,
|
||||||
|
source_url: str,
|
||||||
|
content_type: str | None = None,
|
||||||
|
max_entries: int = 2_000,
|
||||||
|
) -> FeedDocument:
|
||||||
|
...
|
||||||
|
|
||||||
|
def render(self, request: FeedRenderRequest) -> FeedRenderResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def feed_provider(registry: object | None) -> FeedProvider | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_CONNECTORS_FEEDS)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
provider = registry.capability(CAPABILITY_CONNECTORS_FEEDS)
|
||||||
|
return provider if isinstance(provider, FeedProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_CONNECTORS_FEEDS",
|
||||||
|
"FeedCapabilityError",
|
||||||
|
"FeedDocument",
|
||||||
|
"FeedEntry",
|
||||||
|
"FeedFormat",
|
||||||
|
"FeedProvider",
|
||||||
|
"FeedRenderRequest",
|
||||||
|
"FeedRenderResult",
|
||||||
|
"FeedVisibility",
|
||||||
|
"feed_provider",
|
||||||
|
]
|
||||||
@@ -1,13 +1,52 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from typing import Protocol, runtime_checkable
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
from govoplan_core.core.access import ResourceAccessExplanationProvider
|
from govoplan_core.core.access import ResourceAccessExplanationProvider
|
||||||
|
|
||||||
|
|
||||||
CAPABILITY_FILES_ACCESS = "files.access"
|
CAPABILITY_FILES_ACCESS = "files.access"
|
||||||
|
CAPABILITY_FILES_ARTIFACT_STORE = "files.artifact_store"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ManagedArtifactWriteRequest:
|
||||||
|
filename: str
|
||||||
|
payload: bytes
|
||||||
|
content_type: str
|
||||||
|
folder: str = "Generated"
|
||||||
|
description: str | None = None
|
||||||
|
idempotency_key: str | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ManagedArtifactRef:
|
||||||
|
file_asset_id: str
|
||||||
|
file_version_id: str
|
||||||
|
filename: str
|
||||||
|
display_path: str
|
||||||
|
content_type: str
|
||||||
|
size_bytes: int
|
||||||
|
sha256: str
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class FileAccessProvider(ResourceAccessExplanationProvider, Protocol):
|
class FileAccessProvider(ResourceAccessExplanationProvider, Protocol):
|
||||||
"""Resource-level access explanation provider for Files-owned resources."""
|
"""Resource-level access explanation provider for Files-owned resources."""
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ManagedArtifactStore(Protocol):
|
||||||
|
"""Store generated module artifacts without exposing Files internals."""
|
||||||
|
|
||||||
|
def store_artifact(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ManagedArtifactWriteRequest,
|
||||||
|
) -> ManagedArtifactRef: ...
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import Literal, Protocol, runtime_checkable
|
|||||||
|
|
||||||
IDENTITY_MODULE_ID = "identity"
|
IDENTITY_MODULE_ID = "identity"
|
||||||
CAPABILITY_IDENTITY_DIRECTORY = "identity.directory"
|
CAPABILITY_IDENTITY_DIRECTORY = "identity.directory"
|
||||||
|
CAPABILITY_IDENTITY_SEARCH = "identity.search"
|
||||||
|
|
||||||
IdentityStatus = Literal["active", "inactive", "suspended"]
|
IdentityStatus = Literal["active", "inactive", "suspended"]
|
||||||
|
|
||||||
@@ -44,3 +45,15 @@ class IdentityDirectory(Protocol):
|
|||||||
|
|
||||||
def accounts_for_identity(self, identity_id: str) -> Sequence[IdentityAccountLinkRef]:
|
def accounts_for_identity(self, identity_id: str) -> Sequence[IdentityAccountLinkRef]:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class IdentitySearchProvider(Protocol):
|
||||||
|
def search_identities(
|
||||||
|
self,
|
||||||
|
query: str | None = None,
|
||||||
|
*,
|
||||||
|
include_inactive: bool = False,
|
||||||
|
limit: int = 25,
|
||||||
|
) -> Sequence[IdentityRef]:
|
||||||
|
...
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_IDENTITY_TRUST_DIRECTORY = "identity_trust.directory"
|
||||||
|
CAPABILITY_IDENTITY_TRUST_ASSURANCE = "identity_trust.assurance"
|
||||||
|
IDENTITY_TRUST_CONTRACT_VERSION = "1"
|
||||||
|
|
||||||
|
DeviceKeyPurpose = Literal["encryption", "signing", "encryption_and_signing"]
|
||||||
|
DeviceKeyStatus = Literal["active", "revoked", "expired"]
|
||||||
|
TrustSubjectKind = Literal[
|
||||||
|
"identity",
|
||||||
|
"account",
|
||||||
|
"function",
|
||||||
|
"postbox",
|
||||||
|
"external_recipient",
|
||||||
|
]
|
||||||
|
|
||||||
|
_PRIVATE_JWK_FIELDS = frozenset({"d", "p", "q", "dp", "dq", "qi", "oth", "k"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DeviceKeyRegistration:
|
||||||
|
tenant_id: str
|
||||||
|
identity_id: str
|
||||||
|
account_id: str
|
||||||
|
device_id: str
|
||||||
|
key_id: str
|
||||||
|
algorithm: str
|
||||||
|
public_jwk: Mapping[str, object]
|
||||||
|
purpose: DeviceKeyPurpose = "encryption"
|
||||||
|
assurance_level: str = "software"
|
||||||
|
attestation_ref: str | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
idempotency_key: str = ""
|
||||||
|
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract(self.contract_version)
|
||||||
|
for label, value in (
|
||||||
|
("tenant id", self.tenant_id),
|
||||||
|
("identity id", self.identity_id),
|
||||||
|
("account id", self.account_id),
|
||||||
|
("device id", self.device_id),
|
||||||
|
("key id", self.key_id),
|
||||||
|
("algorithm", self.algorithm),
|
||||||
|
("idempotency key", self.idempotency_key),
|
||||||
|
):
|
||||||
|
_require_text(value, label)
|
||||||
|
if not self.public_jwk or _PRIVATE_JWK_FIELDS & set(self.public_jwk):
|
||||||
|
raise ValueError("Only a bounded public JWK may be registered")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DeviceKeyRef:
|
||||||
|
tenant_id: str
|
||||||
|
identity_id: str
|
||||||
|
account_id: str
|
||||||
|
device_id: str
|
||||||
|
key_id: str
|
||||||
|
algorithm: str
|
||||||
|
public_jwk: Mapping[str, object]
|
||||||
|
purpose: DeviceKeyPurpose
|
||||||
|
assurance_level: str
|
||||||
|
status: DeviceKeyStatus
|
||||||
|
epoch: int
|
||||||
|
registered_at: datetime
|
||||||
|
attestation_ref: str | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
revoked_at: datetime | None = None
|
||||||
|
revocation_reason: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class KeyEpochRotationRequest:
|
||||||
|
tenant_id: str
|
||||||
|
subject_kind: TrustSubjectKind
|
||||||
|
subject_id: str
|
||||||
|
reason: str
|
||||||
|
access_decision_ref: str
|
||||||
|
idempotency_key: str
|
||||||
|
history_policy: str = "all_retained"
|
||||||
|
previous_epoch: int | None = None
|
||||||
|
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract(self.contract_version)
|
||||||
|
for label, value in (
|
||||||
|
("tenant id", self.tenant_id),
|
||||||
|
("subject id", self.subject_id),
|
||||||
|
("reason", self.reason),
|
||||||
|
("access decision reference", self.access_decision_ref),
|
||||||
|
("idempotency key", self.idempotency_key),
|
||||||
|
):
|
||||||
|
_require_text(value, label)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class KeyEpochRef:
|
||||||
|
tenant_id: str
|
||||||
|
subject_kind: TrustSubjectKind
|
||||||
|
subject_id: str
|
||||||
|
epoch: int
|
||||||
|
state: Literal["active", "superseded", "revoked"]
|
||||||
|
history_policy: str
|
||||||
|
effective_at: datetime
|
||||||
|
previous_epoch: int | None = None
|
||||||
|
reason: str | None = None
|
||||||
|
access_decision_ref: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class KeyAccessRequest:
|
||||||
|
tenant_id: str
|
||||||
|
account_id: str
|
||||||
|
device_key_id: str
|
||||||
|
subject_kind: TrustSubjectKind
|
||||||
|
subject_id: str
|
||||||
|
key_epoch: int
|
||||||
|
access_decision_ref: str
|
||||||
|
purpose: str
|
||||||
|
requested_at: datetime
|
||||||
|
function_assignment_id: str | None = None
|
||||||
|
delegation_id: str | None = None
|
||||||
|
resource_ref: str | None = None
|
||||||
|
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract(self.contract_version)
|
||||||
|
if self.key_epoch < 1:
|
||||||
|
raise ValueError("Key epoch must be positive")
|
||||||
|
for label, value in (
|
||||||
|
("tenant id", self.tenant_id),
|
||||||
|
("account id", self.account_id),
|
||||||
|
("device key id", self.device_key_id),
|
||||||
|
("subject id", self.subject_id),
|
||||||
|
("access decision reference", self.access_decision_ref),
|
||||||
|
("purpose", self.purpose),
|
||||||
|
):
|
||||||
|
_require_text(value, label)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class KeyAccessDecision:
|
||||||
|
allowed: bool
|
||||||
|
decision_ref: str
|
||||||
|
reason: str
|
||||||
|
device_key: DeviceKeyRef | None = None
|
||||||
|
epoch: KeyEpochRef | None = None
|
||||||
|
audit_event_ref: str | None = None
|
||||||
|
requirements: tuple[str, ...] = ()
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AssuranceCheckRequest:
|
||||||
|
tenant_id: str
|
||||||
|
account_id: str
|
||||||
|
purpose: str
|
||||||
|
minimum_level: str
|
||||||
|
evidence_ref: str
|
||||||
|
evaluated_at: datetime
|
||||||
|
maximum_age_seconds: int = 300
|
||||||
|
device_key_id: str | None = None
|
||||||
|
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract(self.contract_version)
|
||||||
|
if self.maximum_age_seconds < 1:
|
||||||
|
raise ValueError("Assurance maximum age must be positive")
|
||||||
|
for label, value in (
|
||||||
|
("tenant id", self.tenant_id),
|
||||||
|
("account id", self.account_id),
|
||||||
|
("purpose", self.purpose),
|
||||||
|
("minimum level", self.minimum_level),
|
||||||
|
("evidence reference", self.evidence_ref),
|
||||||
|
):
|
||||||
|
_require_text(value, label)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AssuranceDecision:
|
||||||
|
allowed: bool
|
||||||
|
reason: str
|
||||||
|
assurance_level: str | None = None
|
||||||
|
evidence_ref: str | None = None
|
||||||
|
verified_at: datetime | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class IdentityTrustDirectory(Protocol):
|
||||||
|
def register_device_key(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: DeviceKeyRegistration,
|
||||||
|
) -> DeviceKeyRef: ...
|
||||||
|
|
||||||
|
def revoke_device_key(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
key_id: str,
|
||||||
|
expected_epoch: int,
|
||||||
|
reason: str,
|
||||||
|
) -> DeviceKeyRef: ...
|
||||||
|
|
||||||
|
def list_device_keys(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
account_id: str,
|
||||||
|
active_only: bool = True,
|
||||||
|
) -> tuple[DeviceKeyRef, ...]: ...
|
||||||
|
|
||||||
|
def rotate_epoch(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: KeyEpochRotationRequest,
|
||||||
|
) -> KeyEpochRef: ...
|
||||||
|
|
||||||
|
def resolve_epoch(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject_kind: TrustSubjectKind,
|
||||||
|
subject_id: str,
|
||||||
|
epoch: int | None = None,
|
||||||
|
) -> KeyEpochRef | None: ...
|
||||||
|
|
||||||
|
def decide_key_access(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: KeyAccessRequest,
|
||||||
|
) -> KeyAccessDecision: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class IdentityTrustAssurance(Protocol):
|
||||||
|
def verify_assurance(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: AssuranceCheckRequest,
|
||||||
|
) -> AssuranceDecision: ...
|
||||||
|
|
||||||
|
|
||||||
|
def identity_trust_directory(
|
||||||
|
registry: object | None,
|
||||||
|
) -> IdentityTrustDirectory | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_IDENTITY_TRUST_DIRECTORY)
|
||||||
|
return capability if isinstance(capability, IdentityTrustDirectory) else None
|
||||||
|
|
||||||
|
|
||||||
|
def identity_trust_assurance(
|
||||||
|
registry: object | None,
|
||||||
|
) -> IdentityTrustAssurance | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_IDENTITY_TRUST_ASSURANCE)
|
||||||
|
return capability if isinstance(capability, IdentityTrustAssurance) 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)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_contract(value: str) -> None:
|
||||||
|
if value != IDENTITY_TRUST_CONTRACT_VERSION:
|
||||||
|
raise ValueError("Unsupported identity-trust contract version")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_text(value: str, label: str) -> None:
|
||||||
|
if not value.strip():
|
||||||
|
raise ValueError(f"{label.capitalize()} is required")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AssuranceCheckRequest",
|
||||||
|
"AssuranceDecision",
|
||||||
|
"CAPABILITY_IDENTITY_TRUST_ASSURANCE",
|
||||||
|
"CAPABILITY_IDENTITY_TRUST_DIRECTORY",
|
||||||
|
"DeviceKeyRef",
|
||||||
|
"DeviceKeyRegistration",
|
||||||
|
"IdentityTrustAssurance",
|
||||||
|
"IdentityTrustDirectory",
|
||||||
|
"KeyAccessDecision",
|
||||||
|
"KeyAccessRequest",
|
||||||
|
"KeyEpochRef",
|
||||||
|
"KeyEpochRotationRequest",
|
||||||
|
"identity_trust_assurance",
|
||||||
|
"identity_trust_directory",
|
||||||
|
]
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Literal, Protocol, runtime_checkable
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
IDM_MODULE_ID = "idm"
|
IDM_MODULE_ID = "idm"
|
||||||
CAPABILITY_IDM_DIRECTORY = "idm.directory"
|
CAPABILITY_IDM_DIRECTORY = "idm.directory"
|
||||||
|
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS = "idm.function_assignments"
|
||||||
|
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE = "idm.assignment_lifecycle"
|
||||||
|
CAPABILITY_IDM_RELATIONSHIPS = "idm.relationships"
|
||||||
|
|
||||||
IdmStatus = Literal["active", "inactive", "suspended"]
|
IdmStatus = Literal["active", "inactive", "suspended"]
|
||||||
OrganizationFunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
|
OrganizationFunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
|
||||||
|
TypedGroupStatus = Literal["active", "inactive"]
|
||||||
|
IdentityRelationshipStatus = Literal["active", "revoked"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -30,6 +35,90 @@ class OrganizationFunctionAssignmentRef:
|
|||||||
status: IdmStatus = "active"
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TypedGroupRef:
|
||||||
|
"""Provider-neutral IDM group fact scoped to one tenant."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
key: str
|
||||||
|
name: str
|
||||||
|
group_type: str
|
||||||
|
description: str | None = None
|
||||||
|
status: TypedGroupStatus = "active"
|
||||||
|
source_provider: str = "local"
|
||||||
|
source_resource_type: str | None = None
|
||||||
|
source_resource_id: str | None = None
|
||||||
|
source_revision: str | None = None
|
||||||
|
properties: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
revision: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IdentityRelationshipRef:
|
||||||
|
"""An effective-dated relationship from an identity to a typed target."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
relationship_kind: str
|
||||||
|
subject_identity_id: str
|
||||||
|
target_group_id: str | None = None
|
||||||
|
related_identity_id: str | None = None
|
||||||
|
role: str | None = None
|
||||||
|
valid_from: datetime | None = None
|
||||||
|
valid_until: datetime | None = None
|
||||||
|
status: IdentityRelationshipStatus = "active"
|
||||||
|
revoked_at: datetime | None = None
|
||||||
|
revoked_by: str | None = None
|
||||||
|
revocation_reason: str | None = None
|
||||||
|
source_provider: str = "local"
|
||||||
|
source_resource_type: str | None = None
|
||||||
|
source_resource_id: str | None = None
|
||||||
|
source_revision: str | None = None
|
||||||
|
properties: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
revision: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class IdentityRelationshipDecisionRef:
|
||||||
|
relationship: IdentityRelationshipRef
|
||||||
|
included: bool
|
||||||
|
code: str
|
||||||
|
explanation: str
|
||||||
|
identity_status: IdmStatus | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TypedGroupMembershipResolutionRef:
|
||||||
|
group: TypedGroupRef
|
||||||
|
effective_at: datetime
|
||||||
|
decisions: tuple[IdentityRelationshipDecisionRef, ...] = ()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identity_ids(self) -> tuple[str, ...]:
|
||||||
|
return tuple(
|
||||||
|
dict.fromkeys(
|
||||||
|
item.relationship.subject_identity_id
|
||||||
|
for item in self.decisions
|
||||||
|
if item.included
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class IdmDirectory(Protocol):
|
class IdmDirectory(Protocol):
|
||||||
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
|
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
|
||||||
@@ -40,6 +129,7 @@ class IdmDirectory(Protocol):
|
|||||||
identity_id: str,
|
identity_id: str,
|
||||||
*,
|
*,
|
||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
||||||
...
|
...
|
||||||
|
|
||||||
@@ -48,5 +138,153 @@ class IdmDirectory(Protocol):
|
|||||||
account_id: str,
|
account_id: str,
|
||||||
*,
|
*,
|
||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
) -> 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 IdmRelationshipDirectory(Protocol):
|
||||||
|
"""Tenant-safe forward/reverse lookup for typed IDM relationships."""
|
||||||
|
|
||||||
|
def get_typed_group(
|
||||||
|
self,
|
||||||
|
group_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
) -> TypedGroupRef | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def list_typed_groups(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
query: str | None = None,
|
||||||
|
group_types: Sequence[str] = (),
|
||||||
|
include_inactive: bool = False,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[TypedGroupRef]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def identity_relationships_for_identity(
|
||||||
|
self,
|
||||||
|
identity_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
relationship_kinds: Sequence[str] = (),
|
||||||
|
) -> Sequence[IdentityRelationshipRef]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def identity_relationships_for_identities(
|
||||||
|
self,
|
||||||
|
identity_ids: Sequence[str],
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
relationship_kinds: Sequence[str] = (),
|
||||||
|
) -> Mapping[str, Sequence[IdentityRelationshipRef]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def identity_relationships_for_group(
|
||||||
|
self,
|
||||||
|
group_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
relationship_kinds: Sequence[str] = (),
|
||||||
|
) -> Sequence[IdentityRelationshipRef]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def identity_relationships_for_groups(
|
||||||
|
self,
|
||||||
|
group_ids: Sequence[str],
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
relationship_kinds: Sequence[str] = (),
|
||||||
|
) -> Mapping[str, Sequence[IdentityRelationshipRef]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def resolve_typed_group_memberships(
|
||||||
|
self,
|
||||||
|
group_ids: Sequence[str],
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
relationship_kinds: Sequence[str] = ("member",),
|
||||||
|
) -> Mapping[str, TypedGroupMembershipResolutionRef]:
|
||||||
|
...
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Literal
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
from sqlalchemy.engine import make_url
|
from sqlalchemy.engine import make_url
|
||||||
@@ -72,6 +74,27 @@ class ConfigValidationResult:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _RuntimeProfile:
|
||||||
|
name: str
|
||||||
|
production: bool
|
||||||
|
production_like: bool
|
||||||
|
local: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _ConfigIssueCollector:
|
||||||
|
strict: bool
|
||||||
|
issues: list[ConfigIssue]
|
||||||
|
|
||||||
|
def add(self, level: ConfigIssueLevel, key: str, message: str, action: str) -> None:
|
||||||
|
if self.strict and level == "warning":
|
||||||
|
level = "error"
|
||||||
|
self.issues.append(
|
||||||
|
ConfigIssue(level=level, key=key, message=message, action=action)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
_LOCAL_PROFILES = {"dev", "local", "local-dev", "test"}
|
_LOCAL_PROFILES = {"dev", "local", "local-dev", "test"}
|
||||||
_PRODUCTION_PROFILES = {"prod", "production", "self-hosted"}
|
_PRODUCTION_PROFILES = {"prod", "production", "self-hosted"}
|
||||||
_PRODUCTION_LIKE_PROFILES = {"staging", "production-like", "production-like-dev"}
|
_PRODUCTION_LIKE_PROFILES = {"staging", "production-like", "production-like-dev"}
|
||||||
@@ -99,9 +122,15 @@ def generate_master_key() -> str:
|
|||||||
return Fernet.generate_key().decode("ascii")
|
return Fernet.generate_key().decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
def env_template(*, profile: str = "self-hosted", generate_secrets: bool = False) -> str:
|
def env_template(
|
||||||
|
*, profile: str = "self-hosted", generate_secrets: bool = False
|
||||||
|
) -> str:
|
||||||
clean_profile = normalize_install_profile(profile)
|
clean_profile = normalize_install_profile(profile)
|
||||||
master_key = generate_master_key() if generate_secrets else "<generate-with-govoplan-config-env-template-generate-secrets>"
|
master_key = (
|
||||||
|
generate_master_key()
|
||||||
|
if generate_secrets
|
||||||
|
else "<generate-with-govoplan-config-env-template-generate-secrets>"
|
||||||
|
)
|
||||||
if clean_profile == "production-like":
|
if clean_profile == "production-like":
|
||||||
return _production_like_env_template(master_key)
|
return _production_like_env_template(master_key)
|
||||||
return _self_hosted_env_template(master_key)
|
return _self_hosted_env_template(master_key)
|
||||||
@@ -114,95 +143,568 @@ def validate_runtime_configuration(
|
|||||||
strict: bool = False,
|
strict: bool = False,
|
||||||
) -> ConfigValidationResult:
|
) -> ConfigValidationResult:
|
||||||
env = dict(os.environ if environ is None else environ)
|
env = dict(os.environ if environ is None else environ)
|
||||||
clean_profile = normalize_install_profile(profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV"))
|
runtime = _runtime_profile(env, profile=profile)
|
||||||
issues: list[ConfigIssue] = []
|
collector = _ConfigIssueCollector(strict=strict, issues=[])
|
||||||
|
_validate_app_env(env, runtime, collector)
|
||||||
|
_validate_database_settings(env, runtime, collector)
|
||||||
|
_validate_master_key(env, runtime, collector)
|
||||||
|
_validate_enabled_modules(env, runtime, collector)
|
||||||
|
_validate_async_and_auth_settings(env, runtime, collector)
|
||||||
|
_validate_cors_settings(env, runtime, collector)
|
||||||
|
_validate_file_storage_settings(env, runtime, collector)
|
||||||
|
_validate_shared_state_settings(env, collector)
|
||||||
|
_validate_outbound_connector_policy(env, runtime, collector)
|
||||||
|
_validate_module_catalog_trust(env, runtime, collector)
|
||||||
|
return ConfigValidationResult(profile=runtime.name, issues=tuple(collector.issues))
|
||||||
|
|
||||||
production = clean_profile in _PRODUCTION_PROFILES or env.get("APP_ENV", "").strip().lower() in {"prod", "production"}
|
|
||||||
|
def _runtime_profile(env: Mapping[str, str], *, profile: str | None) -> _RuntimeProfile:
|
||||||
|
clean_profile = normalize_install_profile(
|
||||||
|
profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV")
|
||||||
|
)
|
||||||
|
production = clean_profile in _PRODUCTION_PROFILES or env.get(
|
||||||
|
"APP_ENV", ""
|
||||||
|
).strip().lower() in {"prod", "production"}
|
||||||
production_like = production or clean_profile in _PRODUCTION_LIKE_PROFILES
|
production_like = production or clean_profile in _PRODUCTION_LIKE_PROFILES
|
||||||
local = clean_profile in _LOCAL_PROFILES and not production_like
|
return _RuntimeProfile(
|
||||||
|
name=clean_profile,
|
||||||
|
production=production,
|
||||||
|
production_like=production_like,
|
||||||
|
local=clean_profile in _LOCAL_PROFILES and not production_like,
|
||||||
|
)
|
||||||
|
|
||||||
def issue(level: ConfigIssueLevel, key: str, message: str, action: str) -> None:
|
|
||||||
if strict and level == "warning":
|
|
||||||
level = "error"
|
|
||||||
issues.append(ConfigIssue(level=level, key=key, message=message, action=action))
|
|
||||||
|
|
||||||
|
def _validate_app_env(
|
||||||
|
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
|
||||||
|
) -> None:
|
||||||
app_env = _clean(env.get("APP_ENV"))
|
app_env = _clean(env.get("APP_ENV"))
|
||||||
if not app_env and production_like:
|
if not app_env and runtime.production_like:
|
||||||
issue("error", "APP_ENV", "APP_ENV is missing for a production-like install.", "Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.")
|
collector.add(
|
||||||
elif app_env.lower() in {"dev", "test", "local"} and production_like:
|
"error",
|
||||||
issue("error", "APP_ENV", f"APP_ENV={app_env!r} is not valid for profile {clean_profile!r}.", "Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.")
|
"APP_ENV",
|
||||||
|
"APP_ENV is missing for a production-like install.",
|
||||||
|
"Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.",
|
||||||
|
)
|
||||||
|
elif app_env.lower() in {"dev", "test", "local"} and runtime.production_like:
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"APP_ENV",
|
||||||
|
f"APP_ENV={app_env!r} is not valid for profile {runtime.name!r}.",
|
||||||
|
"Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_database_settings(
|
||||||
|
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
|
||||||
|
) -> None:
|
||||||
database_url = _clean(env.get("DATABASE_URL"))
|
database_url = _clean(env.get("DATABASE_URL"))
|
||||||
if not database_url:
|
if not database_url:
|
||||||
issue("error", "DATABASE_URL", "DATABASE_URL is missing.", "Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.")
|
collector.add(
|
||||||
else:
|
"error",
|
||||||
|
"DATABASE_URL",
|
||||||
|
"DATABASE_URL is missing.",
|
||||||
|
"Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.",
|
||||||
|
)
|
||||||
|
return
|
||||||
backend = _database_backend(database_url)
|
backend = _database_backend(database_url)
|
||||||
if backend is None:
|
if backend is None:
|
||||||
issue("error", "DATABASE_URL", "DATABASE_URL is not a valid SQLAlchemy URL.", "Use a value like postgresql+psycopg://user:password@host:5432/database.")
|
collector.add(
|
||||||
elif backend == "sqlite" and production_like:
|
"error",
|
||||||
issue("error", "DATABASE_URL", "SQLite is only supported for disposable local development.", "Use PostgreSQL for production-like and self-hosted installs.")
|
"DATABASE_URL",
|
||||||
elif backend != "postgresql" and production:
|
"DATABASE_URL is not a valid SQLAlchemy URL.",
|
||||||
issue("warning", "DATABASE_URL", f"Database backend {backend!r} is not the preferred production target.", "Use PostgreSQL unless this deployment has an explicit support decision.")
|
"Use a value like postgresql+psycopg://user:password@host:5432/database.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if backend == "sqlite" and runtime.production_like:
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"DATABASE_URL",
|
||||||
|
"SQLite is only supported for disposable local development.",
|
||||||
|
"Use PostgreSQL for production-like and self-hosted installs.",
|
||||||
|
)
|
||||||
|
elif backend != "postgresql" and runtime.production:
|
||||||
|
collector.add(
|
||||||
|
"warning",
|
||||||
|
"DATABASE_URL",
|
||||||
|
f"Database backend {backend!r} is not the preferred production target.",
|
||||||
|
"Use PostgreSQL unless this deployment has an explicit support decision.",
|
||||||
|
)
|
||||||
if backend == "postgresql" and not _clean(env.get("GOVOPLAN_DATABASE_URL_PGTOOLS")):
|
if backend == "postgresql" and not _clean(env.get("GOVOPLAN_DATABASE_URL_PGTOOLS")):
|
||||||
issue("warning", "GOVOPLAN_DATABASE_URL_PGTOOLS", "PostgreSQL backup/restore tools URL is missing.", "Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.")
|
collector.add(
|
||||||
|
"warning",
|
||||||
|
"GOVOPLAN_DATABASE_URL_PGTOOLS",
|
||||||
|
"PostgreSQL backup/restore tools URL is missing.",
|
||||||
|
"Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_master_key(
|
||||||
|
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
|
||||||
|
) -> None:
|
||||||
master_key = _clean(env.get("MASTER_KEY_B64"))
|
master_key = _clean(env.get("MASTER_KEY_B64"))
|
||||||
if not master_key and not local:
|
if not master_key and not runtime.local:
|
||||||
issue("error", "MASTER_KEY_B64", "MASTER_KEY_B64 is required outside local dev/test.", "Generate a Fernet key with `govoplan-config env-template --generate-secrets` or `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` and store it in deployment secrets.")
|
collector.add(
|
||||||
elif master_key:
|
"error",
|
||||||
|
"MASTER_KEY_B64",
|
||||||
|
"MASTER_KEY_B64 is required outside local dev/test.",
|
||||||
|
"Generate a Fernet key with `govoplan-config env-template --generate-secrets` or `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` and store it in deployment secrets.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if not master_key:
|
||||||
|
return
|
||||||
error = _master_key_error(master_key)
|
error = _master_key_error(master_key)
|
||||||
if error:
|
if error:
|
||||||
issue("error", "MASTER_KEY_B64", error, "Replace MASTER_KEY_B64 with a Fernet key or base64-encoded 32-byte key.")
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"MASTER_KEY_B64",
|
||||||
|
error,
|
||||||
|
"Replace MASTER_KEY_B64 with a Fernet key or base64-encoded 32-byte key.",
|
||||||
|
)
|
||||||
elif "change-me" in master_key.lower() or "generate" in master_key.lower():
|
elif "change-me" in master_key.lower() or "generate" in master_key.lower():
|
||||||
issue("error", "MASTER_KEY_B64", "MASTER_KEY_B64 still looks like a placeholder.", "Generate a real deployment key and store it outside git.")
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"MASTER_KEY_B64",
|
||||||
|
"MASTER_KEY_B64 still looks like a placeholder.",
|
||||||
|
"Generate a real deployment key and store it outside git.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_enabled_modules(
|
||||||
|
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
|
||||||
|
) -> None:
|
||||||
enabled_modules = _csv(env.get("ENABLED_MODULES"))
|
enabled_modules = _csv(env.get("ENABLED_MODULES"))
|
||||||
if not enabled_modules and production_like:
|
if not enabled_modules and runtime.production_like:
|
||||||
issue("error", "ENABLED_MODULES", "ENABLED_MODULES is missing.", "Set ENABLED_MODULES explicitly so startup module composition is intentional.")
|
collector.add(
|
||||||
elif "access" not in enabled_modules and production_like:
|
"error",
|
||||||
issue("error", "ENABLED_MODULES", "The access module is not enabled.", "Include `access` unless this deployment has a replacement auth/principal provider.")
|
"ENABLED_MODULES",
|
||||||
elif enabled_modules and "admin" not in enabled_modules and production_like:
|
"ENABLED_MODULES is missing.",
|
||||||
issue("warning", "ENABLED_MODULES", "The admin module is not enabled.", "Keep `admin` enabled for operator UI unless this is a deliberately headless install.")
|
"Set ENABLED_MODULES explicitly so startup module composition is intentional.",
|
||||||
|
)
|
||||||
|
elif "access" not in enabled_modules and runtime.production_like:
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"ENABLED_MODULES",
|
||||||
|
"The access module is not enabled.",
|
||||||
|
"Include `access` unless this deployment has a replacement auth/principal provider.",
|
||||||
|
)
|
||||||
|
elif enabled_modules and "admin" not in enabled_modules and runtime.production_like:
|
||||||
|
collector.add(
|
||||||
|
"warning",
|
||||||
|
"ENABLED_MODULES",
|
||||||
|
"The admin module is not enabled.",
|
||||||
|
"Keep `admin` enabled for operator UI unless this is a deliberately headless install.",
|
||||||
|
)
|
||||||
|
|
||||||
celery_enabled = _truthy(env.get("CELERY_ENABLED"))
|
|
||||||
if celery_enabled and not _clean(env.get("REDIS_URL")):
|
|
||||||
issue("error", "REDIS_URL", "CELERY_ENABLED=true but REDIS_URL is missing.", "Set REDIS_URL to the Redis broker/result backend used by workers.")
|
|
||||||
|
|
||||||
if production and _truthy(env.get("DEV_BOOTSTRAP_ENABLED")):
|
def _validate_async_and_auth_settings(
|
||||||
issue("error", "DEV_BOOTSTRAP_ENABLED", "Development bootstrap is enabled in production.", "Set DEV_BOOTSTRAP_ENABLED=false and create first administrators through the controlled bootstrap path.")
|
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
|
||||||
|
) -> None:
|
||||||
|
if _truthy(env.get("CELERY_ENABLED")) and not _clean(env.get("REDIS_URL")):
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"REDIS_URL",
|
||||||
|
"CELERY_ENABLED=true but REDIS_URL is missing.",
|
||||||
|
"Set REDIS_URL to the Redis broker/result backend used by workers.",
|
||||||
|
)
|
||||||
|
if runtime.production and _truthy(env.get("DEV_BOOTSTRAP_ENABLED")):
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"DEV_BOOTSTRAP_ENABLED",
|
||||||
|
"Development bootstrap is enabled in production.",
|
||||||
|
"Set DEV_BOOTSTRAP_ENABLED=false and create first administrators through the controlled bootstrap path.",
|
||||||
|
)
|
||||||
|
if runtime.production and not _truthy(env.get("AUTH_COOKIE_SECURE")):
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"AUTH_COOKIE_SECURE",
|
||||||
|
"Secure auth cookies are disabled for production.",
|
||||||
|
"Set AUTH_COOKIE_SECURE=true behind HTTPS.",
|
||||||
|
)
|
||||||
|
|
||||||
if production and not _truthy(env.get("AUTH_COOKIE_SECURE")):
|
|
||||||
issue("error", "AUTH_COOKIE_SECURE", "Secure auth cookies are disabled for production.", "Set AUTH_COOKIE_SECURE=true behind HTTPS.")
|
|
||||||
|
|
||||||
|
def _validate_cors_settings(
|
||||||
|
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
|
||||||
|
) -> None:
|
||||||
cors_origins = _csv(env.get("CORS_ORIGINS"))
|
cors_origins = _csv(env.get("CORS_ORIGINS"))
|
||||||
if production_like and not cors_origins:
|
if runtime.production_like and not cors_origins:
|
||||||
issue("error", "CORS_ORIGINS", "CORS_ORIGINS is missing.", "Set CORS_ORIGINS to the exact WebUI origin or origins.")
|
collector.add(
|
||||||
elif "*" in cors_origins and production_like:
|
"error",
|
||||||
issue("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.")
|
"CORS_ORIGINS",
|
||||||
elif production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
|
"CORS_ORIGINS is missing.",
|
||||||
issue("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.")
|
"Set CORS_ORIGINS to the exact WebUI origin or origins.",
|
||||||
|
)
|
||||||
|
elif "*" in cors_origins and runtime.production_like:
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"CORS_ORIGINS",
|
||||||
|
"Wildcard CORS is not allowed for production-like installs.",
|
||||||
|
"Replace `*` with exact HTTPS/WebUI origins.",
|
||||||
|
)
|
||||||
|
elif runtime.production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
|
||||||
|
collector.add(
|
||||||
|
"warning",
|
||||||
|
"CORS_ORIGINS",
|
||||||
|
"CORS_ORIGINS still contains only local development origins.",
|
||||||
|
"Set CORS_ORIGINS to the deployed WebUI origin.",
|
||||||
|
)
|
||||||
|
trusted_hosts = _csv(env.get("GOVOPLAN_TRUSTED_HOSTS"))
|
||||||
|
if runtime.production_like and not trusted_hosts:
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"GOVOPLAN_TRUSTED_HOSTS",
|
||||||
|
"Trusted HTTP hosts are not configured.",
|
||||||
|
"Set GOVOPLAN_TRUSTED_HOSTS to the exact API host names accepted by this deployment.",
|
||||||
|
)
|
||||||
|
elif "*" in trusted_hosts and runtime.production_like:
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"GOVOPLAN_TRUSTED_HOSTS",
|
||||||
|
"Wildcard trusted hosts are not allowed for production-like installs.",
|
||||||
|
"Replace `*` with exact host names or narrowly scoped `*.example.org` entries.",
|
||||||
|
)
|
||||||
|
forwarded_allow_ips = _csv(env.get("FORWARDED_ALLOW_IPS"))
|
||||||
|
if runtime.production_like and "*" in forwarded_allow_ips:
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"FORWARDED_ALLOW_IPS",
|
||||||
|
"Proxy headers must not be trusted from every address.",
|
||||||
|
"Set FORWARDED_ALLOW_IPS to the reverse proxy address or network passed to Uvicorn.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_file_storage_settings(
|
||||||
|
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
|
||||||
|
) -> None:
|
||||||
storage_backend = _clean(env.get("FILE_STORAGE_BACKEND")) or "local"
|
storage_backend = _clean(env.get("FILE_STORAGE_BACKEND")) or "local"
|
||||||
|
deployment_managed_raw = _clean(
|
||||||
|
env.get("FILE_STORAGE_S3_DEPLOYMENT_MANAGED")
|
||||||
|
).lower()
|
||||||
|
endpoint_trusted_raw = _clean(env.get("FILE_STORAGE_S3_ENDPOINT_TRUSTED")).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 endpoint_trusted_raw and endpoint_trusted_raw not in {
|
||||||
|
"true",
|
||||||
|
"false",
|
||||||
|
"1",
|
||||||
|
"0",
|
||||||
|
"yes",
|
||||||
|
"no",
|
||||||
|
"on",
|
||||||
|
"off",
|
||||||
|
}:
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
|
||||||
|
"External S3 endpoint trust must be an explicit boolean.",
|
||||||
|
"Set it only for a deployment-controlled HTTPS storage origin.",
|
||||||
|
)
|
||||||
|
endpoint_trusted = _truthy(endpoint_trusted_raw)
|
||||||
if storage_backend == "local":
|
if storage_backend == "local":
|
||||||
if not _clean(env.get("FILE_STORAGE_LOCAL_ROOT")) and production_like:
|
if deployment_managed or endpoint_trusted:
|
||||||
issue("error", "FILE_STORAGE_LOCAL_ROOT", "Local file storage root is missing.", "Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.")
|
collector.add(
|
||||||
elif production:
|
"error",
|
||||||
issue("warning", "FILE_STORAGE_BACKEND", "Production is configured for local file storage.", "Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.")
|
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
|
||||||
|
"S3 endpoint trust cannot be enabled for local file storage.",
|
||||||
|
"Disable both S3 trust settings while FILE_STORAGE_BACKEND=local.",
|
||||||
|
)
|
||||||
|
_validate_local_file_storage(env, runtime, collector)
|
||||||
elif storage_backend == "s3":
|
elif storage_backend == "s3":
|
||||||
for key in ("FILE_STORAGE_S3_ENDPOINT_URL", "FILE_STORAGE_S3_REGION", "FILE_STORAGE_S3_ACCESS_KEY_ID", "FILE_STORAGE_S3_SECRET_ACCESS_KEY", "FILE_STORAGE_S3_BUCKET"):
|
_validate_s3_file_storage(
|
||||||
if not _clean(env.get(key)):
|
env,
|
||||||
issue("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
|
collector,
|
||||||
|
deployment_managed=deployment_managed,
|
||||||
|
endpoint_trusted=endpoint_trusted,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
issue("error", "FILE_STORAGE_BACKEND", f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.", "Use `local` or `s3`.")
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"FILE_STORAGE_BACKEND",
|
||||||
|
f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.",
|
||||||
|
"Use `local` or `s3`.",
|
||||||
|
)
|
||||||
|
|
||||||
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG"))
|
|
||||||
if production and catalog_source:
|
def _validate_local_file_storage(
|
||||||
|
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
|
||||||
|
) -> None:
|
||||||
|
if not _clean(env.get("FILE_STORAGE_LOCAL_ROOT")) and runtime.production_like:
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"FILE_STORAGE_LOCAL_ROOT",
|
||||||
|
"Local file storage root is missing.",
|
||||||
|
"Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.",
|
||||||
|
)
|
||||||
|
elif runtime.production:
|
||||||
|
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,
|
||||||
|
*,
|
||||||
|
deployment_managed: bool,
|
||||||
|
endpoint_trusted: 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.",
|
||||||
|
)
|
||||||
|
if deployment_managed and endpoint_trusted:
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
|
||||||
|
"Managed Garage trust and external endpoint trust are mutually exclusive.",
|
||||||
|
"Use installer-managed Garage trust or one explicit external endpoint.",
|
||||||
|
)
|
||||||
|
if not deployment_managed and not endpoint_trusted:
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
|
||||||
|
"External S3 storage requires an explicit deployment trust decision.",
|
||||||
|
"Set FILE_STORAGE_S3_ENDPOINT_TRUSTED=true only for a deployment-controlled HTTPS origin.",
|
||||||
|
)
|
||||||
|
endpoint = _clean(env.get("FILE_STORAGE_S3_ENDPOINT_URL"))
|
||||||
|
if endpoint_trusted and not endpoint.lower().startswith("https://"):
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"FILE_STORAGE_S3_ENDPOINT_URL",
|
||||||
|
"Deployment-trusted external S3 storage must use HTTPS.",
|
||||||
|
"Use an HTTPS storage origin with certificate verification.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_shared_state_settings(
|
||||||
|
env: Mapping[str, str],
|
||||||
|
collector: _ConfigIssueCollector,
|
||||||
|
) -> None:
|
||||||
|
state_profile = (_clean(env.get("GOVOPLAN_STATE_PROFILE")) or "local").lower()
|
||||||
|
if state_profile not in {"local", "host-shared", "shared"}:
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"GOVOPLAN_STATE_PROFILE",
|
||||||
|
f"Unsupported state profile {state_profile!r}.",
|
||||||
|
"Use `local` for one process per role, `host-shared` for one Compose host, or `shared` for a multi-host stateless tier.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
api_replicas = int(_clean(env.get("GOVOPLAN_EXPECTED_API_REPLICAS")) or "1")
|
||||||
|
worker_replicas = int(
|
||||||
|
_clean(env.get("GOVOPLAN_EXPECTED_WORKER_REPLICAS")) or "0"
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"GOVOPLAN_EXPECTED_API_REPLICAS",
|
||||||
|
"Expected replica counts must be integers.",
|
||||||
|
"Set GOVOPLAN_EXPECTED_API_REPLICAS and GOVOPLAN_EXPECTED_WORKER_REPLICAS to non-negative integers.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if api_replicas < 1 or worker_replicas < 0:
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"GOVOPLAN_EXPECTED_API_REPLICAS",
|
||||||
|
"Expected replica counts are outside their supported range.",
|
||||||
|
"Configure at least one API replica and zero or more worker replicas.",
|
||||||
|
)
|
||||||
|
if state_profile == "local":
|
||||||
|
if api_replicas > 1 or worker_replicas > 1:
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"GOVOPLAN_STATE_PROFILE",
|
||||||
|
"A local-state profile cannot safely run replicated API or worker nodes.",
|
||||||
|
"Use `host-shared` with one shared host volume, or `shared` with PostgreSQL, Redis, and S3-compatible object storage.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
installation_id = _clean(env.get("GOVOPLAN_INSTALLATION_ID"))
|
||||||
|
if not installation_id or (
|
||||||
|
state_profile == "shared" and installation_id == "govoplan-local"
|
||||||
|
):
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"GOVOPLAN_INSTALLATION_ID",
|
||||||
|
"Shared-state deployments require a stable installation identifier.",
|
||||||
|
"Set one immutable deployment-wide GOVOPLAN_INSTALLATION_ID on every node.",
|
||||||
|
)
|
||||||
|
if _database_backend(_clean(env.get("DATABASE_URL"))) != "postgresql":
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"DATABASE_URL",
|
||||||
|
"Shared-state deployments require PostgreSQL.",
|
||||||
|
"Point every API, scheduler, and worker node at the same logical PostgreSQL service.",
|
||||||
|
)
|
||||||
|
if not _clean(env.get("REDIS_URL")):
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"REDIS_URL",
|
||||||
|
"Shared-state deployments require a common Redis service.",
|
||||||
|
"Configure the same Redis endpoint for all API and worker nodes.",
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
state_profile == "shared"
|
||||||
|
and (_clean(env.get("FILE_STORAGE_BACKEND")) or "local").lower() != "s3"
|
||||||
|
):
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"FILE_STORAGE_BACKEND",
|
||||||
|
"Shared-state deployments cannot use node-local object storage.",
|
||||||
|
"Set FILE_STORAGE_BACKEND=s3 and configure one shared S3-compatible bucket.",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
heartbeat = int(_clean(env.get("GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS")) or "15")
|
||||||
|
stale_after = int(
|
||||||
|
_clean(env.get("GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS")) or "60"
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS",
|
||||||
|
"Runtime heartbeat and stale intervals must be integers.",
|
||||||
|
"Use a heartbeat interval shorter than one third of the stale interval.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if heartbeat < 2 or stale_after < max(10, heartbeat * 3):
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS",
|
||||||
|
"Runtime stale detection leaves insufficient room for missed heartbeats.",
|
||||||
|
"Set stale-after to at least three heartbeat intervals and at least ten seconds.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_outbound_connector_policy(
|
||||||
|
env: Mapping[str, str],
|
||||||
|
runtime: _RuntimeProfile,
|
||||||
|
collector: _ConfigIssueCollector,
|
||||||
|
) -> None:
|
||||||
|
private_networks = _clean(
|
||||||
|
env.get("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS")
|
||||||
|
).lower()
|
||||||
|
if runtime.production_like and private_networks not in {
|
||||||
|
"true",
|
||||||
|
"false",
|
||||||
|
"1",
|
||||||
|
"0",
|
||||||
|
"yes",
|
||||||
|
"no",
|
||||||
|
"on",
|
||||||
|
"off",
|
||||||
|
}:
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
|
||||||
|
"Private-network connector access must be an explicit deployment decision.",
|
||||||
|
"Set GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false for public-only egress, or true when this deployment must reach internal services.",
|
||||||
|
)
|
||||||
|
for key in (
|
||||||
|
"GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
|
||||||
|
"GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES",
|
||||||
|
"GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES",
|
||||||
|
):
|
||||||
|
value = _clean(env.get(key))
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except ValueError:
|
||||||
|
parsed = 0
|
||||||
|
if parsed <= 0:
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
key,
|
||||||
|
f"{key} must be a positive byte count.",
|
||||||
|
"Use a positive integer byte limit.",
|
||||||
|
)
|
||||||
|
secret_env_names = [
|
||||||
|
item.strip()
|
||||||
|
for item in env.get("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST", "").split(",")
|
||||||
|
if item.strip()
|
||||||
|
]
|
||||||
|
if any(
|
||||||
|
re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", item) is None
|
||||||
|
for item in secret_env_names
|
||||||
|
):
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
|
||||||
|
"Connector secret environment allowlist contains an invalid variable name.",
|
||||||
|
"Use a comma-separated list of exact environment variable names, or leave the setting empty.",
|
||||||
|
)
|
||||||
|
ca_bundle_paths = [
|
||||||
|
item.strip()
|
||||||
|
for item in env.get("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST", "").split(",")
|
||||||
|
if item.strip()
|
||||||
|
]
|
||||||
|
if any(not Path(item).is_absolute() for item in ca_bundle_paths):
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST",
|
||||||
|
"Connector CA bundle allowlist contains a non-absolute path.",
|
||||||
|
"Use comma-separated absolute paths mounted on every API and connector worker, or leave the setting empty.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_module_catalog_trust(
|
||||||
|
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
|
||||||
|
) -> None:
|
||||||
|
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(
|
||||||
|
env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG")
|
||||||
|
)
|
||||||
|
if not runtime.production or not catalog_source:
|
||||||
|
return
|
||||||
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE")):
|
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE")):
|
||||||
issue("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "A module catalog source is configured without a trusted keyring file.", "Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.")
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE",
|
||||||
|
"A module catalog source is configured without a trusted keyring file.",
|
||||||
|
"Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.",
|
||||||
|
)
|
||||||
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL")):
|
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL")):
|
||||||
issue("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL", "A module catalog source is configured without an approved release channel.", "Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.")
|
collector.add(
|
||||||
|
"error",
|
||||||
return ConfigValidationResult(profile=clean_profile, issues=tuple(issues))
|
"GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL",
|
||||||
|
"A module catalog source is configured without an approved release channel.",
|
||||||
|
"Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _self_hosted_env_template(master_key: str) -> str:
|
def _self_hosted_env_template(master_key: str) -> str:
|
||||||
@@ -211,6 +713,13 @@ def _self_hosted_env_template(master_key: str) -> str:
|
|||||||
|
|
||||||
APP_ENV=production
|
APP_ENV=production
|
||||||
GOVOPLAN_INSTALL_PROFILE=self-hosted
|
GOVOPLAN_INSTALL_PROFILE=self-hosted
|
||||||
|
GOVOPLAN_INSTALLATION_ID=govoplan-production
|
||||||
|
GOVOPLAN_STATE_PROFILE=local
|
||||||
|
GOVOPLAN_RUNTIME_ROLE=api
|
||||||
|
GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS=15
|
||||||
|
GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS=60
|
||||||
|
GOVOPLAN_EXPECTED_API_REPLICAS=1
|
||||||
|
GOVOPLAN_EXPECTED_WORKER_REPLICAS=1
|
||||||
MASTER_KEY_B64={master_key}
|
MASTER_KEY_B64={master_key}
|
||||||
|
|
||||||
DATABASE_URL=postgresql+psycopg://govoplan:change-me@127.0.0.1:5432/govoplan
|
DATABASE_URL=postgresql+psycopg://govoplan:change-me@127.0.0.1:5432/govoplan
|
||||||
@@ -220,9 +729,34 @@ ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,aud
|
|||||||
|
|
||||||
CELERY_ENABLED=true
|
CELERY_ENABLED=true
|
||||||
REDIS_URL=redis://127.0.0.1:6379/0
|
REDIS_URL=redis://127.0.0.1:6379/0
|
||||||
CELERY_QUEUES=send_email,append_sent,default
|
CELERY_QUEUES=send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,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.
|
||||||
|
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false
|
||||||
|
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
|
||||||
|
GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES=536870912
|
||||||
|
# Exact names/paths only. Keep empty until a deployment-owned connector needs them.
|
||||||
|
GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=
|
||||||
|
GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=
|
||||||
|
GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES=536870912
|
||||||
|
GOVOPLAN_HTTP_HSTS_SECONDS=31536000
|
||||||
|
|
||||||
|
AUTH_LOGIN_THROTTLE_ENABLED=true
|
||||||
|
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
|
||||||
|
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
|
||||||
|
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
|
||||||
|
AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS=30
|
||||||
|
|
||||||
CORS_ORIGINS=https://govoplan.example.org
|
CORS_ORIGINS=https://govoplan.example.org
|
||||||
|
GOVOPLAN_TRUSTED_HOSTS=govoplan.example.org
|
||||||
|
# Uvicorn reads FORWARDED_ALLOW_IPS when proxy headers are enabled. Keep this
|
||||||
|
# restricted to the actual reverse proxy address or network.
|
||||||
|
FORWARDED_ALLOW_IPS=127.0.0.1
|
||||||
AUTH_COOKIE_SECURE=true
|
AUTH_COOKIE_SECURE=true
|
||||||
AUTH_COOKIE_SAMESITE=lax
|
AUTH_COOKIE_SAMESITE=lax
|
||||||
AUTH_COOKIE_DOMAIN=
|
AUTH_COOKIE_DOMAIN=
|
||||||
@@ -230,6 +764,12 @@ AUTH_COOKIE_DOMAIN=
|
|||||||
FILE_STORAGE_BACKEND=local
|
FILE_STORAGE_BACKEND=local
|
||||||
FILE_STORAGE_LOCAL_ROOT=/var/lib/govoplan/files
|
FILE_STORAGE_LOCAL_ROOT=/var/lib/govoplan/files
|
||||||
FILE_STORAGE_LOCAL_FALLBACK_ROOTS=
|
FILE_STORAGE_LOCAL_FALLBACK_ROOTS=
|
||||||
|
FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false
|
||||||
|
FILE_STORAGE_S3_ENDPOINT_TRUSTED=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_AUTO_MIGRATE_ENABLED=false
|
||||||
DEV_BOOTSTRAP_ENABLED=false
|
DEV_BOOTSTRAP_ENABLED=false
|
||||||
@@ -247,6 +787,13 @@ def _production_like_env_template(master_key: str) -> str:
|
|||||||
|
|
||||||
APP_ENV=staging
|
APP_ENV=staging
|
||||||
GOVOPLAN_INSTALL_PROFILE=production-like
|
GOVOPLAN_INSTALL_PROFILE=production-like
|
||||||
|
GOVOPLAN_INSTALLATION_ID=govoplan-production-like
|
||||||
|
GOVOPLAN_STATE_PROFILE=local
|
||||||
|
GOVOPLAN_RUNTIME_ROLE=api
|
||||||
|
GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS=15
|
||||||
|
GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS=60
|
||||||
|
GOVOPLAN_EXPECTED_API_REPLICAS=1
|
||||||
|
GOVOPLAN_EXPECTED_WORKER_REPLICAS=1
|
||||||
MASTER_KEY_B64={master_key}
|
MASTER_KEY_B64={master_key}
|
||||||
|
|
||||||
GOVOPLAN_PRODUCTION_LIKE_POSTGRES_DB=govoplan
|
GOVOPLAN_PRODUCTION_LIKE_POSTGRES_DB=govoplan
|
||||||
@@ -263,13 +810,39 @@ 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
|
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
||||||
REDIS_URL=redis://127.0.0.1:56379/0
|
REDIS_URL=redis://127.0.0.1:56379/0
|
||||||
CELERY_ENABLED=true
|
CELERY_ENABLED=true
|
||||||
CELERY_QUEUES=send_email,append_sent,default
|
CELERY_QUEUES=send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,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
|
||||||
|
GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES=536870912
|
||||||
|
GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=
|
||||||
|
GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=
|
||||||
|
GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES=536870912
|
||||||
|
GOVOPLAN_HTTP_HSTS_SECONDS=0
|
||||||
|
|
||||||
|
AUTH_LOGIN_THROTTLE_ENABLED=true
|
||||||
|
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
|
||||||
|
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
|
||||||
|
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
|
||||||
|
AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS=30
|
||||||
|
|
||||||
ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops
|
ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops
|
||||||
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
||||||
|
GOVOPLAN_TRUSTED_HOSTS=127.0.0.1,localhost,testserver
|
||||||
|
FORWARDED_ALLOW_IPS=127.0.0.1
|
||||||
AUTH_COOKIE_SECURE=false
|
AUTH_COOKIE_SECURE=false
|
||||||
FILE_STORAGE_BACKEND=local
|
FILE_STORAGE_BACKEND=local
|
||||||
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
|
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
|
||||||
|
FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false
|
||||||
|
FILE_STORAGE_S3_ENDPOINT_TRUSTED=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_AUTO_MIGRATE_ENABLED=false
|
||||||
DEV_BOOTSTRAP_ENABLED=true
|
DEV_BOOTSTRAP_ENABLED=true
|
||||||
"""
|
"""
|
||||||
@@ -280,7 +853,11 @@ def _clean(value: str | None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _csv(value: str | None) -> tuple[str, ...]:
|
def _csv(value: str | None) -> tuple[str, ...]:
|
||||||
return tuple(dict.fromkeys(item.strip() for item in str(value or "").split(",") if item.strip()))
|
return tuple(
|
||||||
|
dict.fromkeys(
|
||||||
|
item.strip() for item in str(value or "").split(",") if item.strip()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _truthy(value: str | None) -> bool:
|
def _truthy(value: str | None) -> bool:
|
||||||
@@ -299,8 +876,8 @@ def _master_key_error(value: str) -> str | None:
|
|||||||
try:
|
try:
|
||||||
Fernet(candidate)
|
Fernet(candidate)
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except (TypeError, ValueError):
|
||||||
pass
|
raw = None
|
||||||
try:
|
try:
|
||||||
raw = base64.b64decode(candidate)
|
raw = base64.b64decode(candidate)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -309,6 +886,6 @@ def _master_key_error(value: str) -> str | None:
|
|||||||
return "MASTER_KEY_B64 must decode to exactly 32 bytes."
|
return "MASTER_KEY_B64 must decode to exactly 32 bytes."
|
||||||
try:
|
try:
|
||||||
Fernet(base64.urlsafe_b64encode(raw))
|
Fernet(base64.urlsafe_b64encode(raw))
|
||||||
except Exception:
|
except (TypeError, ValueError):
|
||||||
return "MASTER_KEY_B64 is not usable as a Fernet key."
|
return "MASTER_KEY_B64 is not usable as a Fernet key."
|
||||||
return None
|
return None
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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.modules import ModuleContext, ModuleManifest
|
||||||
from govoplan_core.core.registry import PlatformRegistry
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
from govoplan_core.core.runtime import configure_runtime
|
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
|
from govoplan_core.server.route_validation import validate_router_can_mount
|
||||||
|
|
||||||
|
|
||||||
@@ -67,6 +70,17 @@ class ModuleLifecycleManager:
|
|||||||
def mounted_module_ids(self) -> tuple[str, ...]:
|
def mounted_module_ids(self) -> tuple[str, ...]:
|
||||||
return tuple(sorted(self._mounted_modules))
|
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(
|
def apply_enabled_modules(
|
||||||
self,
|
self,
|
||||||
requested_enabled: Sequence[str],
|
requested_enabled: Sequence[str],
|
||||||
@@ -105,6 +119,8 @@ class ModuleLifecycleManager:
|
|||||||
if hook is not None:
|
if hook is not None:
|
||||||
hook(self.context)
|
hook(self.context)
|
||||||
|
|
||||||
|
self.reconcile_workflow_definitions()
|
||||||
|
|
||||||
if self._app is not None:
|
if self._app is not None:
|
||||||
self._app.openapi_schema = None
|
self._app.openapi_schema = None
|
||||||
|
|
||||||
@@ -116,6 +132,24 @@ class ModuleLifecycleManager:
|
|||||||
migrations_applied=migrate,
|
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:
|
def _mount_module_router(self, module_id: str) -> bool:
|
||||||
if module_id in self._mounted_modules:
|
if module_id in self._mounted_modules:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_MAIL_DELIVERY_OUTBOX = "mail.delivery_outbox"
|
||||||
|
CAPABILITY_MAIL_NOTIFICATION_DELIVERY = "mail.notificationDelivery"
|
||||||
|
CAPABILITY_MAIL_BOUNCE_PROCESSING = "mail.bounce_processing"
|
||||||
|
|
||||||
|
|
||||||
|
@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]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class MailBounceObservationRef:
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
profile_id: str
|
||||||
|
folder: str
|
||||||
|
uid: str
|
||||||
|
original_message_id: str | None
|
||||||
|
command_id: str | None
|
||||||
|
recipient: str | None
|
||||||
|
action: str
|
||||||
|
status_code: str | None
|
||||||
|
diagnostic: str | None
|
||||||
|
permanent: bool
|
||||||
|
observed_at: datetime
|
||||||
|
matched: bool
|
||||||
|
evidence: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class MailBounceProcessingProvider(Protocol):
|
||||||
|
"""Mail-owned DSN ingestion and durable correlation boundary."""
|
||||||
|
|
||||||
|
def process_raw_message(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
profile_id: str,
|
||||||
|
folder: str,
|
||||||
|
uid: str,
|
||||||
|
raw_message: bytes,
|
||||||
|
) -> tuple[MailBounceObservationRef, ...]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def scan_due(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def observations_for_commands(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
command_ids: tuple[str, ...],
|
||||||
|
) -> Mapping[str, tuple[MailBounceObservationRef, ...]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def mail_bounce_processing_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> MailBounceProcessingProvider | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_MAIL_BOUNCE_PROCESSING)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
provider = registry.require_capability(CAPABILITY_MAIL_BOUNCE_PROCESSING)
|
||||||
|
if not isinstance(provider, MailBounceProcessingProvider):
|
||||||
|
raise TypeError(
|
||||||
|
"mail.bounce_processing provider does not implement "
|
||||||
|
"MailBounceProcessingProvider"
|
||||||
|
)
|
||||||
|
return provider
|
||||||
@@ -79,15 +79,19 @@ def drop_table_retirement_provider(
|
|||||||
warnings.append("Tables not present and therefore skipped: " + ", ".join(missing_names))
|
warnings.append("Tables not present and therefore skipped: " + ", ".join(missing_names))
|
||||||
|
|
||||||
def executor(execute_session: object, _module_id: str) -> None:
|
def executor(execute_session: object, _module_id: str) -> None:
|
||||||
if not hasattr(execute_session, "get_bind"):
|
if not hasattr(execute_session, "connection"):
|
||||||
raise RuntimeError("No database session is available for destructive table retirement.")
|
raise RuntimeError("No database session is available for destructive table retirement.")
|
||||||
execute_bind = execute_session.get_bind() # type: ignore[attr-defined]
|
# Enlist schema retirement in the caller's active transaction. An
|
||||||
live_inspector = inspect(execute_bind)
|
# Engine returned by Session.get_bind() may acquire a second
|
||||||
|
# connection, separating the DROP from module-specific secret
|
||||||
|
# scrubbing/audit writes and deadlocking on their uncommitted locks.
|
||||||
|
execute_connection = execute_session.connection() # type: ignore[attr-defined]
|
||||||
|
live_inspector = inspect(execute_connection)
|
||||||
live_tables = [table for table in tables if live_inspector.has_table(table.name)]
|
live_tables = [table for table in tables if live_inspector.has_table(table.name)]
|
||||||
if not live_tables:
|
if not live_tables:
|
||||||
return
|
return
|
||||||
metadata = live_tables[0].metadata
|
metadata = live_tables[0].metadata
|
||||||
metadata.drop_all(bind=execute_bind, tables=live_tables, checkfirst=True)
|
metadata.drop_all(bind=execute_connection, tables=live_tables, checkfirst=True)
|
||||||
|
|
||||||
return MigrationRetirementPlan(
|
return MigrationRetirementPlan(
|
||||||
supported=True,
|
supported=True,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
||||||
|
from govoplan_core.core.modules import ModuleContext
|
||||||
|
from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider
|
||||||
|
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
||||||
|
|
||||||
|
|
||||||
|
INSTALLER_NOTIFICATION_ACTION_URL = "/admin?section=modules"
|
||||||
|
|
||||||
|
|
||||||
|
def emit_module_installer_notification(
|
||||||
|
*,
|
||||||
|
session: object,
|
||||||
|
registry: object | None,
|
||||||
|
tenant_id: str | None,
|
||||||
|
request: Mapping[str, object],
|
||||||
|
event_kind: str,
|
||||||
|
subject: str,
|
||||||
|
body_text: str,
|
||||||
|
recipient_id: str | None = None,
|
||||||
|
priority: int = 5,
|
||||||
|
) -> bool:
|
||||||
|
if not tenant_id:
|
||||||
|
return False
|
||||||
|
provider = notification_dispatch_provider(registry)
|
||||||
|
if provider is None:
|
||||||
|
return False
|
||||||
|
request_id = str(request.get("request_id") or "")
|
||||||
|
if not request_id:
|
||||||
|
return False
|
||||||
|
provider.enqueue_notification(
|
||||||
|
session,
|
||||||
|
NotificationDispatchRequest(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_module="core",
|
||||||
|
source_resource_type="module_install_request",
|
||||||
|
source_resource_id=request_id,
|
||||||
|
event_kind=event_kind,
|
||||||
|
channel="inbox",
|
||||||
|
recipient_type="user" if recipient_id else None,
|
||||||
|
recipient_id=recipient_id,
|
||||||
|
subject=subject,
|
||||||
|
body_text=body_text,
|
||||||
|
action_url=INSTALLER_NOTIFICATION_ACTION_URL,
|
||||||
|
priority=priority,
|
||||||
|
payload={
|
||||||
|
"request_id": request_id,
|
||||||
|
"status": request.get("status"),
|
||||||
|
"run_id": _result_run_id(request),
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"trace": request.get("trace") if isinstance(request.get("trace"), Mapping) else None,
|
||||||
|
"retry_of": request.get("retry_of"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
enqueue_delivery=False,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def build_runtime_notification_registry(settings: object) -> object | None:
|
||||||
|
try:
|
||||||
|
raw_enabled_modules = load_startup_enabled_modules(str(getattr(settings, "enabled_modules", "") or ""))
|
||||||
|
candidate_modules = startup_candidate_module_ids(str(getattr(settings, "enabled_modules", "") or ""), raw_enabled_modules)
|
||||||
|
available_modules = available_module_manifests(enabled_modules=candidate_modules, ignore_load_errors=True)
|
||||||
|
enabled_modules = load_startup_enabled_modules(str(getattr(settings, "enabled_modules", "") or ""), available=available_modules)
|
||||||
|
if "notifications" not in enabled_modules:
|
||||||
|
return None
|
||||||
|
registry = build_platform_registry(enabled_modules)
|
||||||
|
registry.configure_capability_context(ModuleContext(registry=registry, settings=settings))
|
||||||
|
return registry
|
||||||
|
except Exception: # noqa: BLE001 - notification bridge must not block installer work.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def installer_notification_subject(event_kind: str, request: Mapping[str, object]) -> str:
|
||||||
|
request_id = str(request.get("request_id") or "unknown")
|
||||||
|
status = str(request.get("status") or event_kind.rsplit(".", 1)[-1])
|
||||||
|
prefixes = {
|
||||||
|
"queued": "Module installer request queued",
|
||||||
|
"running": "Module installer request started",
|
||||||
|
"completed": "Module installer request completed",
|
||||||
|
"failed": "Module installer request failed",
|
||||||
|
"cancelled": "Module installer request cancelled",
|
||||||
|
}
|
||||||
|
prefix = prefixes.get(status, "Module installer request updated")
|
||||||
|
return ": ".join((prefix, request_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _installer_status_sentence(request_id: str, status: str) -> str:
|
||||||
|
return " ".join(("Installer request", request_id, "is", status))
|
||||||
|
|
||||||
|
|
||||||
|
def installer_notification_body(event_kind: str, request: Mapping[str, object]) -> str:
|
||||||
|
status = str(request.get("status") or event_kind.rsplit(".", 1)[-1])
|
||||||
|
request_id = str(request.get("request_id") or "unknown")
|
||||||
|
result = request.get("result") if isinstance(request.get("result"), Mapping) else {}
|
||||||
|
error = str(request.get("error") or result.get("error") or "").strip()
|
||||||
|
sentence = _installer_status_sentence(request_id, status)
|
||||||
|
if error:
|
||||||
|
return ". Error: ".join((sentence, error))
|
||||||
|
run_id = _result_run_id(request)
|
||||||
|
if run_id:
|
||||||
|
return ". Run: ".join((sentence, run_id))
|
||||||
|
# This helper returns plain notification text, not an HTTP/HTML response.
|
||||||
|
return sentence + "." # nosemgrep: python.flask.security.audit.directly-returned-format-string.directly-returned-format-string
|
||||||
|
|
||||||
|
|
||||||
|
def installer_notification_priority(status: str) -> int:
|
||||||
|
if status == "failed":
|
||||||
|
return 20
|
||||||
|
if status in {"completed", "cancelled"}:
|
||||||
|
return 8
|
||||||
|
if status == "running":
|
||||||
|
return 4
|
||||||
|
return 5
|
||||||
|
|
||||||
|
|
||||||
|
def _result_run_id(request: Mapping[str, object]) -> str | None:
|
||||||
|
result = request.get("result") if isinstance(request.get("result"), Mapping) else {}
|
||||||
|
run_id: Any = result.get("run_id") if isinstance(result, Mapping) else None
|
||||||
|
return str(run_id) if run_id else None
|
||||||
@@ -6,13 +6,11 @@ from datetime import UTC, datetime
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
from cryptography.exceptions import InvalidSignature
|
from cryptography.exceptions import InvalidSignature
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
||||||
|
from govoplan_core.security.http_fetch import fetch_http_text
|
||||||
|
|
||||||
|
|
||||||
def module_license_decision(required_features: list[str] | tuple[str, ...]) -> dict[str, object]:
|
def module_license_decision(required_features: list[str] | tuple[str, ...]) -> dict[str, object]:
|
||||||
@@ -257,17 +255,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
|
|||||||
|
|
||||||
|
|
||||||
def _read_trusted_keys_url(url: str) -> str:
|
def _read_trusted_keys_url(url: str) -> str:
|
||||||
if not url.startswith(("https://", "http://")):
|
|
||||||
raise ValueError("Trusted license key URL must use http:// or https://.")
|
|
||||||
cache_path = _configured_trusted_keys_cache_path()
|
cache_path = _configured_trusted_keys_cache_path()
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(url, timeout=15) as response:
|
body = fetch_http_text(url, timeout=15, label="Trusted license key URL")
|
||||||
body = response.read().decode("utf-8")
|
|
||||||
if cache_path is not None:
|
if cache_path is not None:
|
||||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
cache_path.write_text(body, encoding="utf-8")
|
cache_path.write_text(body, encoding="utf-8")
|
||||||
return body
|
return body
|
||||||
except (OSError, urllib.error.URLError):
|
except OSError:
|
||||||
if cache_path is not None and cache_path.exists():
|
if cache_path is not None and cache_path.exists():
|
||||||
return cache_path.read_text(encoding="utf-8")
|
return cache_path.read_text(encoding="utf-8")
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from govoplan_core.admin.models import SystemSettings
|
from govoplan_core.admin.models import SystemSettings
|
||||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID, get_system_settings
|
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.discovery import iter_module_entry_points
|
||||||
from govoplan_core.core.modules import ModuleManifest
|
from govoplan_core.core.modules import ModuleManifest
|
||||||
from govoplan_core.db.session import get_database
|
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"
|
MODULE_SETTINGS_KEY = "module_management"
|
||||||
INSTALL_PLAN_KEY = "install_plan"
|
INSTALL_PLAN_KEY = "install_plan"
|
||||||
REQUIRED_PLATFORM_MODULES = ("access",)
|
REQUIRED_PLATFORM_MODULES: tuple[str, ...] = ()
|
||||||
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin")
|
PROTECTED_MODULES = ("admin",)
|
||||||
INSTALL_PLAN_ACTIONS = ("install", "update", "uninstall")
|
INSTALL_PLAN_ACTIONS = ("install", "update", "uninstall")
|
||||||
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
|
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
|
||||||
INSTALL_PLAN_SOURCES = ("manual", "catalog")
|
INSTALL_PLAN_SOURCES = ("manual", "catalog")
|
||||||
@@ -73,6 +74,23 @@ class ModuleInstallPlanItem:
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _NormalizedModuleInstallPlanItem:
|
||||||
|
module_id: str
|
||||||
|
action: str
|
||||||
|
source: str
|
||||||
|
catalog: Mapping[str, object] | None
|
||||||
|
status: str
|
||||||
|
python_package: str | None
|
||||||
|
python_ref: str | None
|
||||||
|
webui_package: str | None
|
||||||
|
webui_ref: str | None
|
||||||
|
artifact_integrity: Mapping[str, object] | None
|
||||||
|
data_safety_acknowledged: bool
|
||||||
|
destroy_data: bool
|
||||||
|
notes: str | None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ModuleInstallPlan:
|
class ModuleInstallPlan:
|
||||||
items: tuple[ModuleInstallPlanItem, ...] = ()
|
items: tuple[ModuleInstallPlanItem, ...] = ()
|
||||||
@@ -92,6 +110,7 @@ def startup_candidate_module_ids(
|
|||||||
if desired is not None:
|
if desired is not None:
|
||||||
candidates.extend(str(item).strip() for item in desired if str(item).strip())
|
candidates.extend(str(item).strip() for item in desired if str(item).strip())
|
||||||
candidates.extend(REQUIRED_PLATFORM_MODULES)
|
candidates.extend(REQUIRED_PLATFORM_MODULES)
|
||||||
|
candidates.extend(DEFAULT_CAPABILITY_PROVIDERS.values())
|
||||||
if "admin" in fallback:
|
if "admin" in fallback:
|
||||||
candidates.append("admin")
|
candidates.append("admin")
|
||||||
return tuple(dict.fromkeys(candidates))
|
return tuple(dict.fromkeys(candidates))
|
||||||
@@ -291,6 +310,28 @@ def desired_modules_after_package_plan(
|
|||||||
def normalize_module_install_plan_item(
|
def normalize_module_install_plan_item(
|
||||||
item: Mapping[str, object] | ModuleInstallPlanItem,
|
item: Mapping[str, object] | ModuleInstallPlanItem,
|
||||||
) -> ModuleInstallPlanItem:
|
) -> ModuleInstallPlanItem:
|
||||||
|
normalized = _normalized_module_install_plan_item(item)
|
||||||
|
_validate_module_install_plan_item(normalized)
|
||||||
|
return ModuleInstallPlanItem(
|
||||||
|
module_id=normalized.module_id,
|
||||||
|
action=normalized.action,
|
||||||
|
source=normalized.source,
|
||||||
|
catalog=normalized.catalog,
|
||||||
|
python_package=normalized.python_package,
|
||||||
|
python_ref=normalized.python_ref,
|
||||||
|
webui_package=normalized.webui_package,
|
||||||
|
webui_ref=normalized.webui_ref,
|
||||||
|
artifact_integrity=normalized.artifact_integrity,
|
||||||
|
data_safety_acknowledged=normalized.data_safety_acknowledged,
|
||||||
|
destroy_data=normalized.destroy_data,
|
||||||
|
status=normalized.status,
|
||||||
|
notes=normalized.notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_module_install_plan_item(
|
||||||
|
item: Mapping[str, object] | ModuleInstallPlanItem,
|
||||||
|
) -> _NormalizedModuleInstallPlanItem:
|
||||||
if isinstance(item, ModuleInstallPlanItem):
|
if isinstance(item, ModuleInstallPlanItem):
|
||||||
raw = item.as_dict()
|
raw = item.as_dict()
|
||||||
elif isinstance(item, Mapping):
|
elif isinstance(item, Mapping):
|
||||||
@@ -311,33 +352,12 @@ def normalize_module_install_plan_item(
|
|||||||
data_safety_acknowledged = _clean_bool(raw.get("data_safety_acknowledged"))
|
data_safety_acknowledged = _clean_bool(raw.get("data_safety_acknowledged"))
|
||||||
destroy_data = _clean_bool(raw.get("destroy_data"))
|
destroy_data = _clean_bool(raw.get("destroy_data"))
|
||||||
notes = _clean_optional_string(raw.get("notes"))
|
notes = _clean_optional_string(raw.get("notes"))
|
||||||
|
return _NormalizedModuleInstallPlanItem(
|
||||||
if action not in INSTALL_PLAN_ACTIONS:
|
|
||||||
raise ModuleManagementError(f"Unsupported install plan action for {module_id!r}: {action!r}.")
|
|
||||||
if status not in INSTALL_PLAN_STATUSES:
|
|
||||||
raise ModuleManagementError(f"Unsupported install plan status for {module_id!r}: {status!r}.")
|
|
||||||
if source not in INSTALL_PLAN_SOURCES:
|
|
||||||
raise ModuleManagementError(f"Unsupported install plan source for {module_id!r}: {source!r}.")
|
|
||||||
if action in {"install", "update"} and not python_ref:
|
|
||||||
raise ModuleManagementError(f"Install plan item {module_id!r} needs a Python package reference.")
|
|
||||||
if action == "uninstall" and not python_package:
|
|
||||||
raise ModuleManagementError(f"Uninstall plan item {module_id!r} needs a Python package name.")
|
|
||||||
if action != "uninstall" and destroy_data:
|
|
||||||
raise ModuleManagementError(f"Install plan item {module_id!r} can only destroy data during uninstall.")
|
|
||||||
if action in {"install", "update"} and bool(webui_package) != bool(webui_ref):
|
|
||||||
raise ModuleManagementError(f"Install plan item {module_id!r} needs both WebUI package and WebUI reference, or neither.")
|
|
||||||
if action == "uninstall" and webui_ref and not webui_package:
|
|
||||||
raise ModuleManagementError(f"Uninstall plan item {module_id!r} has a WebUI reference but no WebUI package.")
|
|
||||||
if python_ref:
|
|
||||||
_validate_dependency_ref(python_ref, field="python_ref", module_id=module_id)
|
|
||||||
if webui_ref:
|
|
||||||
_validate_dependency_ref(webui_ref, field="webui_ref", module_id=module_id)
|
|
||||||
|
|
||||||
return ModuleInstallPlanItem(
|
|
||||||
module_id=module_id,
|
module_id=module_id,
|
||||||
action=action,
|
action=action,
|
||||||
source=source,
|
source=source,
|
||||||
catalog=catalog,
|
catalog=catalog,
|
||||||
|
status=status,
|
||||||
python_package=python_package,
|
python_package=python_package,
|
||||||
python_ref=python_ref,
|
python_ref=python_ref,
|
||||||
webui_package=webui_package,
|
webui_package=webui_package,
|
||||||
@@ -345,30 +365,159 @@ def normalize_module_install_plan_item(
|
|||||||
artifact_integrity=artifact_integrity,
|
artifact_integrity=artifact_integrity,
|
||||||
data_safety_acknowledged=data_safety_acknowledged,
|
data_safety_acknowledged=data_safety_acknowledged,
|
||||||
destroy_data=destroy_data,
|
destroy_data=destroy_data,
|
||||||
status=status,
|
|
||||||
notes=notes,
|
notes=notes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_module_install_plan_item(item: _NormalizedModuleInstallPlanItem) -> None:
|
||||||
|
if item.action not in INSTALL_PLAN_ACTIONS:
|
||||||
|
raise ModuleManagementError(f"Unsupported install plan action for {item.module_id!r}: {item.action!r}.")
|
||||||
|
if item.status not in INSTALL_PLAN_STATUSES:
|
||||||
|
raise ModuleManagementError(f"Unsupported install plan status for {item.module_id!r}: {item.status!r}.")
|
||||||
|
if item.source not in INSTALL_PLAN_SOURCES:
|
||||||
|
raise ModuleManagementError(f"Unsupported install plan source for {item.module_id!r}: {item.source!r}.")
|
||||||
|
_validate_module_install_plan_item_requirements(item)
|
||||||
|
_validate_module_install_plan_item_refs(item)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_module_install_plan_item_requirements(item: _NormalizedModuleInstallPlanItem) -> None:
|
||||||
|
if item.action in {"install", "update"} and not item.python_ref:
|
||||||
|
raise ModuleManagementError(f"Install plan item {item.module_id!r} needs a Python package reference.")
|
||||||
|
if item.action == "uninstall" and not item.python_package:
|
||||||
|
raise ModuleManagementError(f"Uninstall plan item {item.module_id!r} needs a Python package name.")
|
||||||
|
if item.action != "uninstall" and item.destroy_data:
|
||||||
|
raise ModuleManagementError(f"Install plan item {item.module_id!r} can only destroy data during uninstall.")
|
||||||
|
if item.action in {"install", "update"} and bool(item.webui_package) != bool(item.webui_ref):
|
||||||
|
raise ModuleManagementError(f"Install plan item {item.module_id!r} needs both WebUI package and WebUI reference, or neither.")
|
||||||
|
if item.action == "uninstall" and item.webui_ref and not item.webui_package:
|
||||||
|
raise ModuleManagementError(f"Uninstall plan item {item.module_id!r} has a WebUI reference but no WebUI package.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_module_install_plan_item_refs(item: _NormalizedModuleInstallPlanItem) -> None:
|
||||||
|
if item.python_ref:
|
||||||
|
_validate_dependency_ref(item.python_ref, field="python_ref", module_id=item.module_id)
|
||||||
|
if item.webui_ref:
|
||||||
|
_validate_dependency_ref(item.webui_ref, field="webui_ref", module_id=item.module_id)
|
||||||
|
|
||||||
|
|
||||||
def plan_desired_enabled_modules(
|
def plan_desired_enabled_modules(
|
||||||
requested_enabled: Iterable[str],
|
requested_enabled: Iterable[str],
|
||||||
available: Mapping[str, ModuleManifest],
|
available: Mapping[str, ModuleManifest],
|
||||||
*,
|
*,
|
||||||
protected_modules: Iterable[str] = PROTECTED_MODULES,
|
protected_modules: Iterable[str] = PROTECTED_MODULES,
|
||||||
) -> ModuleStatePlan:
|
) -> ModuleStatePlan:
|
||||||
requested = {str(item).strip() for item in requested_enabled if str(item).strip()}
|
requested = _normalized_module_ids(requested_enabled)
|
||||||
protected = {str(item).strip() for item in protected_modules if str(item).strip()}
|
requested.update(_normalized_module_ids(protected_modules))
|
||||||
requested.update(protected)
|
|
||||||
missing = sorted(module_id for module_id in requested if module_id not in available)
|
missing = sorted(module_id for module_id in requested if module_id not in available)
|
||||||
if missing:
|
if missing:
|
||||||
raise ModuleManagementError("Unknown or uninstalled modules: " + ", ".join(missing))
|
raise ModuleManagementError("Unknown or uninstalled modules: " + ", ".join(missing))
|
||||||
|
|
||||||
added_dependencies: set[str] = set()
|
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()
|
visiting: set[str] = set()
|
||||||
visited: set[str] = set()
|
visited: set[str] = set()
|
||||||
ordered: list[str] = []
|
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(module_id: str) -> None:
|
|
||||||
|
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:
|
if module_id in visited:
|
||||||
return
|
return
|
||||||
if module_id in visiting:
|
if module_id in visiting:
|
||||||
@@ -379,22 +528,25 @@ def plan_desired_enabled_modules(
|
|||||||
visiting.add(module_id)
|
visiting.add(module_id)
|
||||||
for dependency_id in manifest.dependencies:
|
for dependency_id in manifest.dependencies:
|
||||||
if dependency_id not in available:
|
if dependency_id not in available:
|
||||||
raise ModuleManagementError(f"Module {module_id!r} depends on uninstalled module {dependency_id!r}.")
|
raise ModuleManagementError(
|
||||||
|
f"Module {module_id!r} depends on uninstalled module {dependency_id!r}."
|
||||||
|
)
|
||||||
if dependency_id not in requested:
|
if dependency_id not in requested:
|
||||||
added_dependencies.add(dependency_id)
|
added_dependencies.add(dependency_id)
|
||||||
requested.add(dependency_id)
|
requested.add(dependency_id)
|
||||||
visit(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)
|
visiting.remove(module_id)
|
||||||
visited.add(module_id)
|
visited.add(module_id)
|
||||||
ordered.append(module_id)
|
ordered.append(module_id)
|
||||||
|
|
||||||
for module_id in sorted(requested):
|
|
||||||
visit(module_id)
|
|
||||||
return ModuleStatePlan(
|
|
||||||
enabled_modules=tuple(dict.fromkeys(ordered)),
|
|
||||||
added_dependencies=tuple(sorted(added_dependencies)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def module_dependents(available: Mapping[str, ModuleManifest]) -> dict[str, tuple[str, ...]]:
|
def module_dependents(available: Mapping[str, ModuleManifest]) -> dict[str, tuple[str, ...]]:
|
||||||
dependents: dict[str, list[str]] = {module_id: [] for module_id in available}
|
dependents: dict[str, list[str]] = {module_id: [] for module_id in available}
|
||||||
|
|||||||
@@ -3,20 +3,26 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
from cryptography.exceptions import InvalidSignature
|
from cryptography.exceptions import InvalidSignature
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
||||||
|
|
||||||
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
||||||
|
from govoplan_core.core.provider_governance import (
|
||||||
|
external_provider_from_mapping,
|
||||||
|
module_architecture_from_mapping,
|
||||||
|
module_architecture_issues,
|
||||||
|
)
|
||||||
|
from govoplan_core.security.http_fetch import fetch_http_text, is_http_url
|
||||||
|
|
||||||
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
|
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
|
||||||
CATALOG_MIGRATION_SAFETY = ("automatic", "requires_review", "forward_only", "destructive")
|
CATALOG_MIGRATION_SAFETY = ("automatic", "requires_review", "forward_only", "destructive")
|
||||||
@@ -28,6 +34,20 @@ CATALOG_MIGRATION_TASK_PHASES = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _CatalogValidationState:
|
||||||
|
modules: tuple[dict[str, object], ...]
|
||||||
|
channel: str | None
|
||||||
|
sequence: int | None
|
||||||
|
generated_at: str | None
|
||||||
|
not_before: str | None
|
||||||
|
expires_at: str | None
|
||||||
|
signature_state: dict[str, object]
|
||||||
|
freshness: dict[str, object]
|
||||||
|
replay: dict[str, object]
|
||||||
|
read_state: dict[str, object]
|
||||||
|
|
||||||
|
|
||||||
def module_package_catalog(
|
def module_package_catalog(
|
||||||
path: Path | str | None = None,
|
path: Path | str | None = None,
|
||||||
*,
|
*,
|
||||||
@@ -61,178 +81,143 @@ def validate_module_package_catalog(
|
|||||||
effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels
|
effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels
|
||||||
effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys()
|
effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys()
|
||||||
if catalog_source is not None and not _catalog_source_exists(catalog_source):
|
if catalog_source is not None and not _catalog_source_exists(catalog_source):
|
||||||
return {
|
return _catalog_error_result(catalog_source, error=f"Module package catalog does not exist: {catalog_source}")
|
||||||
"valid": False,
|
|
||||||
"configured": True,
|
|
||||||
"path": str(catalog_source),
|
|
||||||
"source": str(catalog_source),
|
|
||||||
"source_type": _catalog_source_type(catalog_source),
|
|
||||||
"cache_used": False,
|
|
||||||
"cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() is not None else None,
|
|
||||||
"modules": [],
|
|
||||||
"channel": None,
|
|
||||||
"sequence": None,
|
|
||||||
"generated_at": None,
|
|
||||||
"not_before": None,
|
|
||||||
"expires_at": None,
|
|
||||||
"signed": False,
|
|
||||||
"trusted": False,
|
|
||||||
"key_id": None,
|
|
||||||
"warnings": [],
|
|
||||||
"error": f"Module package catalog does not exist: {catalog_source}",
|
|
||||||
}
|
|
||||||
warnings: list[str] = []
|
|
||||||
read_state = {"cache_used": False, "cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() is not None else None}
|
|
||||||
try:
|
try:
|
||||||
payload, read_state = _read_catalog_payload_with_metadata(catalog_source)
|
state = _catalog_validation_state(catalog_source, trusted_keys=effective_trusted_keys)
|
||||||
|
except Exception as exc:
|
||||||
|
return _catalog_error_result(catalog_source, error=str(exc))
|
||||||
|
policy_error = _catalog_policy_error(
|
||||||
|
catalog_source,
|
||||||
|
state,
|
||||||
|
require_trusted=effective_require_trusted,
|
||||||
|
approved_channels=effective_approved_channels,
|
||||||
|
)
|
||||||
|
if policy_error is not None:
|
||||||
|
return policy_error
|
||||||
|
return _valid_catalog_result(catalog_source, state)
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_validation_state(
|
||||||
|
source: Path | str | None,
|
||||||
|
*,
|
||||||
|
trusted_keys: dict[str, str],
|
||||||
|
) -> _CatalogValidationState:
|
||||||
|
payload, read_state = _read_catalog_payload_with_metadata(source)
|
||||||
modules = _normalize_catalog_modules(payload)
|
modules = _normalize_catalog_modules(payload)
|
||||||
channel = _catalog_channel(payload)
|
channel = _catalog_channel(payload)
|
||||||
sequence = _catalog_sequence(payload)
|
sequence = _catalog_sequence(payload)
|
||||||
generated_at = _catalog_optional_text(payload, "generated_at")
|
return _CatalogValidationState(
|
||||||
not_before = _catalog_optional_text(payload, "not_before")
|
modules=modules,
|
||||||
expires_at = _catalog_optional_text(payload, "expires_at")
|
|
||||||
signature_state = _catalog_signature_state(payload, trusted_keys=effective_trusted_keys)
|
|
||||||
freshness = _catalog_freshness_state(payload)
|
|
||||||
replay = _catalog_replay_state(channel=channel, sequence=sequence)
|
|
||||||
except Exception as exc:
|
|
||||||
return {
|
|
||||||
"valid": False,
|
|
||||||
"configured": catalog_source is not None,
|
|
||||||
"path": str(catalog_source) if catalog_source is not None else None,
|
|
||||||
"source": str(catalog_source) if catalog_source is not None else None,
|
|
||||||
"source_type": _catalog_source_type(catalog_source),
|
|
||||||
"cache_used": bool(read_state.get("cache_used")),
|
|
||||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
|
||||||
"modules": [],
|
|
||||||
"channel": None,
|
|
||||||
"sequence": None,
|
|
||||||
"generated_at": None,
|
|
||||||
"not_before": None,
|
|
||||||
"expires_at": None,
|
|
||||||
"signed": False,
|
|
||||||
"trusted": False,
|
|
||||||
"key_id": None,
|
|
||||||
"warnings": [],
|
|
||||||
"error": str(exc),
|
|
||||||
}
|
|
||||||
if signature_state.get("fatal"):
|
|
||||||
return {
|
|
||||||
"valid": False,
|
|
||||||
"configured": catalog_source is not None,
|
|
||||||
"path": str(catalog_source) if catalog_source is not None else None,
|
|
||||||
"source": str(catalog_source) if catalog_source is not None else None,
|
|
||||||
"source_type": _catalog_source_type(catalog_source),
|
|
||||||
"cache_used": bool(read_state.get("cache_used")),
|
|
||||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
|
||||||
"modules": [],
|
|
||||||
"channel": channel,
|
|
||||||
"sequence": sequence,
|
|
||||||
"generated_at": generated_at,
|
|
||||||
"not_before": not_before,
|
|
||||||
"expires_at": expires_at,
|
|
||||||
"signed": signature_state["signed"],
|
|
||||||
"trusted": signature_state["trusted"],
|
|
||||||
"key_id": signature_state["key_id"],
|
|
||||||
"warnings": [],
|
|
||||||
"error": str(signature_state["error"] or "Module package catalog signature is invalid."),
|
|
||||||
}
|
|
||||||
if effective_approved_channels and channel not in effective_approved_channels:
|
|
||||||
return {
|
|
||||||
"valid": False,
|
|
||||||
"configured": catalog_source is not None,
|
|
||||||
"path": str(catalog_source) if catalog_source is not None else None,
|
|
||||||
"source": str(catalog_source) if catalog_source is not None else None,
|
|
||||||
"source_type": _catalog_source_type(catalog_source),
|
|
||||||
"cache_used": bool(read_state.get("cache_used")),
|
|
||||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
|
||||||
"modules": [],
|
|
||||||
"channel": channel,
|
|
||||||
"sequence": sequence,
|
|
||||||
"generated_at": generated_at,
|
|
||||||
"not_before": not_before,
|
|
||||||
"expires_at": expires_at,
|
|
||||||
"signed": signature_state["signed"],
|
|
||||||
"trusted": signature_state["trusted"],
|
|
||||||
"key_id": signature_state["key_id"],
|
|
||||||
"warnings": [],
|
|
||||||
"error": f"Module package catalog channel {channel!r} is not approved. Approved channels: {', '.join(effective_approved_channels)}.",
|
|
||||||
}
|
|
||||||
if effective_require_trusted and not signature_state["trusted"]:
|
|
||||||
return {
|
|
||||||
"valid": False,
|
|
||||||
"configured": catalog_source is not None,
|
|
||||||
"path": str(catalog_source) if catalog_source is not None else None,
|
|
||||||
"source": str(catalog_source) if catalog_source is not None else None,
|
|
||||||
"source_type": _catalog_source_type(catalog_source),
|
|
||||||
"cache_used": bool(read_state.get("cache_used")),
|
|
||||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
|
||||||
"modules": [],
|
|
||||||
"channel": channel,
|
|
||||||
"sequence": sequence,
|
|
||||||
"generated_at": generated_at,
|
|
||||||
"not_before": not_before,
|
|
||||||
"expires_at": expires_at,
|
|
||||||
"signed": signature_state["signed"],
|
|
||||||
"trusted": False,
|
|
||||||
"key_id": signature_state["key_id"],
|
|
||||||
"warnings": [],
|
|
||||||
"error": str(signature_state["error"] or "Module package catalog must be signed by a trusted key."),
|
|
||||||
}
|
|
||||||
if not freshness["valid"]:
|
|
||||||
return _invalid_catalog_result(
|
|
||||||
catalog_source,
|
|
||||||
modules=(),
|
|
||||||
channel=channel,
|
channel=channel,
|
||||||
sequence=sequence,
|
sequence=sequence,
|
||||||
generated_at=generated_at,
|
generated_at=_catalog_optional_text(payload, "generated_at"),
|
||||||
not_before=not_before,
|
not_before=_catalog_optional_text(payload, "not_before"),
|
||||||
expires_at=expires_at,
|
expires_at=_catalog_optional_text(payload, "expires_at"),
|
||||||
signature_state=signature_state,
|
signature_state=_catalog_signature_state(payload, trusted_keys=trusted_keys),
|
||||||
|
freshness=_catalog_freshness_state(payload),
|
||||||
|
replay=_catalog_replay_state(channel=channel, sequence=sequence),
|
||||||
read_state=read_state,
|
read_state=read_state,
|
||||||
error=str(freshness["error"]),
|
|
||||||
)
|
)
|
||||||
if not replay["valid"]:
|
|
||||||
|
|
||||||
|
def _catalog_policy_error(
|
||||||
|
source: Path | str | None,
|
||||||
|
state: _CatalogValidationState,
|
||||||
|
*,
|
||||||
|
require_trusted: bool,
|
||||||
|
approved_channels: tuple[str, ...],
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
if state.signature_state.get("fatal"):
|
||||||
|
return _invalid_catalog_state_result(source, state, error=str(state.signature_state["error"] or "Module package catalog signature is invalid."))
|
||||||
|
if approved_channels and state.channel not in approved_channels:
|
||||||
|
return _invalid_catalog_state_result(
|
||||||
|
source,
|
||||||
|
state,
|
||||||
|
error=f"Module package catalog channel {state.channel!r} is not approved. Approved channels: {', '.join(approved_channels)}.",
|
||||||
|
)
|
||||||
|
if require_trusted and not state.signature_state["trusted"]:
|
||||||
|
return _invalid_catalog_state_result(source, state, error=str(state.signature_state["error"] or "Module package catalog must be signed by a trusted key."))
|
||||||
|
if not state.freshness["valid"]:
|
||||||
|
return _invalid_catalog_state_result(source, state, error=str(state.freshness["error"]))
|
||||||
|
if not state.replay["valid"]:
|
||||||
|
return _invalid_catalog_state_result(source, state, error=str(state.replay["error"]))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_error_result(source: Path | str | None, *, error: str) -> dict[str, object]:
|
||||||
return _invalid_catalog_result(
|
return _invalid_catalog_result(
|
||||||
catalog_source,
|
source,
|
||||||
modules=(),
|
modules=(),
|
||||||
channel=channel,
|
channel=None,
|
||||||
sequence=sequence,
|
sequence=None,
|
||||||
generated_at=generated_at,
|
generated_at=None,
|
||||||
not_before=not_before,
|
not_before=None,
|
||||||
expires_at=expires_at,
|
expires_at=None,
|
||||||
signature_state=signature_state,
|
signature_state=_unsigned_catalog_signature_state(),
|
||||||
read_state=read_state,
|
read_state=_default_catalog_read_state(),
|
||||||
error=str(replay["error"]),
|
error=error,
|
||||||
)
|
)
|
||||||
warnings.extend(str(item) for item in freshness.get("warnings", ()) if item)
|
|
||||||
warnings.extend(str(item) for item in replay.get("warnings", ()) if item)
|
|
||||||
warnings.extend(_catalog_interface_warnings(modules))
|
def _invalid_catalog_state_result(source: Path | str | None, state: _CatalogValidationState, *, error: str) -> dict[str, object]:
|
||||||
if not signature_state["signed"]:
|
return _invalid_catalog_result(
|
||||||
warnings.append("Catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
|
source,
|
||||||
elif not signature_state["trusted"]:
|
modules=(),
|
||||||
warnings.append(str(signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
|
channel=state.channel,
|
||||||
|
sequence=state.sequence,
|
||||||
|
generated_at=state.generated_at,
|
||||||
|
not_before=state.not_before,
|
||||||
|
expires_at=state.expires_at,
|
||||||
|
signature_state=state.signature_state,
|
||||||
|
read_state=state.read_state,
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_catalog_result(source: Path | str | None, state: _CatalogValidationState) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"valid": True,
|
"valid": True,
|
||||||
"configured": catalog_source is not None and _catalog_source_exists(catalog_source),
|
"configured": source is not None and _catalog_source_exists(source),
|
||||||
"path": str(catalog_source) if catalog_source is not None else None,
|
"path": str(source) if source is not None else None,
|
||||||
"source": str(catalog_source) if catalog_source is not None else None,
|
"source": str(source) if source is not None else None,
|
||||||
"source_type": _catalog_source_type(catalog_source),
|
"source_type": _catalog_source_type(source),
|
||||||
"cache_used": bool(read_state.get("cache_used")),
|
"cache_used": bool(state.read_state.get("cache_used")),
|
||||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
"cache_path": state.read_state.get("cache_path") if isinstance(state.read_state.get("cache_path"), str) else None,
|
||||||
"modules": list(modules),
|
"modules": list(state.modules),
|
||||||
"channel": channel,
|
"channel": state.channel,
|
||||||
"sequence": sequence,
|
"sequence": state.sequence,
|
||||||
"generated_at": generated_at,
|
"generated_at": state.generated_at,
|
||||||
"not_before": not_before,
|
"not_before": state.not_before,
|
||||||
"expires_at": expires_at,
|
"expires_at": state.expires_at,
|
||||||
"signed": signature_state["signed"],
|
"signed": state.signature_state["signed"],
|
||||||
"trusted": signature_state["trusted"],
|
"trusted": state.signature_state["trusted"],
|
||||||
"key_id": signature_state["key_id"],
|
"key_id": state.signature_state["key_id"],
|
||||||
"warnings": warnings,
|
"warnings": _catalog_validation_warnings(state),
|
||||||
"error": None,
|
"error": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_validation_warnings(state: _CatalogValidationState) -> list[str]:
|
||||||
|
warnings: list[str] = []
|
||||||
|
warnings.extend(str(item) for item in state.freshness.get("warnings", ()) if item)
|
||||||
|
warnings.extend(str(item) for item in state.replay.get("warnings", ()) if item)
|
||||||
|
warnings.extend(_catalog_interface_warnings(state.modules))
|
||||||
|
if not state.signature_state["signed"]:
|
||||||
|
warnings.append("Catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
|
||||||
|
elif not state.signature_state["trusted"]:
|
||||||
|
warnings.append(str(state.signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
|
def _default_catalog_read_state() -> dict[str, object]:
|
||||||
|
cache_path = _configured_catalog_cache_path()
|
||||||
|
return {"cache_used": False, "cache_path": str(cache_path) if cache_path is not None else None}
|
||||||
|
|
||||||
|
|
||||||
|
def _unsigned_catalog_signature_state() -> dict[str, object]:
|
||||||
|
return {"signed": False, "trusted": False, "key_id": None}
|
||||||
|
|
||||||
|
|
||||||
def sign_module_package_catalog(
|
def sign_module_package_catalog(
|
||||||
*,
|
*,
|
||||||
path: Path,
|
path: Path,
|
||||||
@@ -259,7 +244,17 @@ def sign_module_package_catalog(
|
|||||||
|
|
||||||
|
|
||||||
def record_module_package_catalog_acceptance(validation: dict[str, object]) -> None:
|
def record_module_package_catalog_acceptance(validation: dict[str, object]) -> None:
|
||||||
state_path = _configured_sequence_state_path()
|
_record_catalog_acceptance(
|
||||||
|
validation,
|
||||||
|
state_path=_configured_sequence_state_path(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_catalog_acceptance(
|
||||||
|
validation: dict[str, object],
|
||||||
|
*,
|
||||||
|
state_path: Path | None,
|
||||||
|
) -> None:
|
||||||
if state_path is None or validation.get("valid") is not True:
|
if state_path is None or validation.get("valid") is not True:
|
||||||
return
|
return
|
||||||
channel = validation.get("channel")
|
channel = validation.get("channel")
|
||||||
@@ -350,17 +345,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
|
|||||||
|
|
||||||
|
|
||||||
def _read_trusted_keys_url(url: str) -> str:
|
def _read_trusted_keys_url(url: str) -> str:
|
||||||
if not _is_http_url(url):
|
|
||||||
raise ValueError("Trusted catalog key URL must use http:// or https://.")
|
|
||||||
cache_path = _configured_trusted_keys_cache_path()
|
cache_path = _configured_trusted_keys_cache_path()
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(url, timeout=15) as response:
|
body = fetch_http_text(url, timeout=15, label="Trusted catalog key URL")
|
||||||
body = response.read().decode("utf-8")
|
|
||||||
if cache_path is not None:
|
if cache_path is not None:
|
||||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
cache_path.write_text(body, encoding="utf-8")
|
cache_path.write_text(body, encoding="utf-8")
|
||||||
return body
|
return body
|
||||||
except (OSError, urllib.error.URLError):
|
except OSError:
|
||||||
if cache_path is not None and cache_path.exists():
|
if cache_path is not None and cache_path.exists():
|
||||||
return cache_path.read_text(encoding="utf-8")
|
return cache_path.read_text(encoding="utf-8")
|
||||||
raise
|
raise
|
||||||
@@ -421,13 +413,12 @@ def _read_catalog_url(url: str) -> str:
|
|||||||
def _read_catalog_url_with_metadata(url: str) -> tuple[str, bool]:
|
def _read_catalog_url_with_metadata(url: str) -> tuple[str, bool]:
|
||||||
cache_path = _configured_catalog_cache_path()
|
cache_path = _configured_catalog_cache_path()
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(url, timeout=15) as response:
|
body = fetch_http_text(url, timeout=15, label="Module package catalog URL")
|
||||||
body = response.read().decode("utf-8")
|
|
||||||
if cache_path is not None:
|
if cache_path is not None:
|
||||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
cache_path.write_text(body, encoding="utf-8")
|
cache_path.write_text(body, encoding="utf-8")
|
||||||
return body, False
|
return body, False
|
||||||
except (OSError, urllib.error.URLError):
|
except OSError:
|
||||||
if cache_path is not None and cache_path.exists():
|
if cache_path is not None and cache_path.exists():
|
||||||
return cache_path.read_text(encoding="utf-8"), True
|
return cache_path.read_text(encoding="utf-8"), True
|
||||||
raise
|
raise
|
||||||
@@ -638,6 +629,72 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
|
|||||||
"notes": _optional_str(value, "notes"),
|
"notes": _optional_str(value, "notes"),
|
||||||
"tags": _string_list(value.get("tags")),
|
"tags": _string_list(value.get("tags")),
|
||||||
}
|
}
|
||||||
|
raw_architecture = value.get("architecture")
|
||||||
|
if raw_architecture is not None:
|
||||||
|
if not isinstance(raw_architecture, Mapping):
|
||||||
|
raise ValueError(
|
||||||
|
f"Module package catalog architecture for {module_id!r} must be an object."
|
||||||
|
)
|
||||||
|
architecture = module_architecture_from_mapping(raw_architecture)
|
||||||
|
issues = module_architecture_issues(
|
||||||
|
architecture,
|
||||||
|
has_migrations=bool(item["migration_tasks"])
|
||||||
|
or bool(item["migration_notes"]),
|
||||||
|
)
|
||||||
|
if issues:
|
||||||
|
raise ValueError(
|
||||||
|
f"Module package catalog architecture for {module_id!r} is invalid: "
|
||||||
|
+ "; ".join(issues)
|
||||||
|
)
|
||||||
|
item["architecture"] = architecture.to_dict()
|
||||||
|
raw_providers = value.get("external_providers")
|
||||||
|
if raw_providers is not None:
|
||||||
|
if not isinstance(raw_providers, list):
|
||||||
|
raise ValueError(
|
||||||
|
f"Module package catalog external_providers for {module_id!r} must be a list."
|
||||||
|
)
|
||||||
|
providers = []
|
||||||
|
seen_provider_ids: set[str] = set()
|
||||||
|
for raw_provider in raw_providers:
|
||||||
|
if not isinstance(raw_provider, Mapping):
|
||||||
|
raise ValueError(
|
||||||
|
f"Module package catalog external provider entries for {module_id!r} must be objects."
|
||||||
|
)
|
||||||
|
provider = external_provider_from_mapping(raw_provider)
|
||||||
|
if provider.module_id != module_id:
|
||||||
|
raise ValueError(
|
||||||
|
f"Module package catalog provider {provider.id!r} belongs to "
|
||||||
|
f"{provider.module_id!r}, not {module_id!r}."
|
||||||
|
)
|
||||||
|
if provider.id in seen_provider_ids:
|
||||||
|
raise ValueError(
|
||||||
|
f"Module package catalog has duplicate provider {provider.id!r}."
|
||||||
|
)
|
||||||
|
seen_provider_ids.add(provider.id)
|
||||||
|
providers.append(provider)
|
||||||
|
if providers and "architecture" not in item:
|
||||||
|
raise ValueError(
|
||||||
|
f"Module package catalog {module_id!r} declares external providers without architecture metadata."
|
||||||
|
)
|
||||||
|
if providers:
|
||||||
|
architecture_payload = item["architecture"]
|
||||||
|
if not isinstance(architecture_payload, Mapping):
|
||||||
|
raise ValueError(
|
||||||
|
f"Module package catalog {module_id!r} has invalid architecture metadata."
|
||||||
|
)
|
||||||
|
architecture_modes = set(
|
||||||
|
_string_list(architecture_payload.get("supported_authority_modes"))
|
||||||
|
)
|
||||||
|
provider_modes = {
|
||||||
|
mode for provider in providers for mode in provider.authority_modes
|
||||||
|
}
|
||||||
|
missing_modes = provider_modes - architecture_modes
|
||||||
|
if missing_modes:
|
||||||
|
raise ValueError(
|
||||||
|
f"Module package catalog {module_id!r} provider modes are missing from architecture metadata: "
|
||||||
|
+ ", ".join(sorted(missing_modes))
|
||||||
|
)
|
||||||
|
item["external_providers"] = [provider.to_dict() for provider in providers]
|
||||||
if not version_range_is_valid(
|
if not version_range_is_valid(
|
||||||
version_min=item["current_version_min"] if isinstance(item["current_version_min"], str) else None,
|
version_min=item["current_version_min"] if isinstance(item["current_version_min"], str) else None,
|
||||||
version_max_exclusive=item["current_version_max_exclusive"] if isinstance(item["current_version_max_exclusive"], str) else None,
|
version_max_exclusive=item["current_version_max_exclusive"] if isinstance(item["current_version_max_exclusive"], str) else None,
|
||||||
@@ -935,7 +992,7 @@ def _catalog_source_type(source: Path | str | None) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def _is_http_url(value: str) -> bool:
|
def _is_http_url(value: str) -> bool:
|
||||||
return value.startswith(("https://", "http://"))
|
return is_http_url(value)
|
||||||
|
|
||||||
|
|
||||||
def _invalid_catalog_result(
|
def _invalid_catalog_result(
|
||||||
|
|||||||
@@ -4,8 +4,22 @@ from collections.abc import Callable, Iterable, Mapping, Sequence
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Literal, Protocol, TYPE_CHECKING
|
from typing import Any, Literal, Protocol, TYPE_CHECKING
|
||||||
|
|
||||||
|
from govoplan_core.core.ownership import OwnershipProviderRegistration
|
||||||
|
from govoplan_core.core.provider_governance import (
|
||||||
|
ExternalProviderDeclaration,
|
||||||
|
ExternalProviderStateProviderRegistration,
|
||||||
|
ModuleArchitectureDeclaration,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.views import ViewSurface
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
from govoplan_core.core.operations import OperationalCheckProviderRegistration
|
||||||
|
from govoplan_core.core.search import (
|
||||||
|
SearchProviderRegistration,
|
||||||
|
SearchSourceProviderRegistration,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.workflows import WorkflowDefinitionContribution
|
||||||
|
|
||||||
|
|
||||||
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
|
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
|
||||||
@@ -45,6 +59,7 @@ class RoleTemplate:
|
|||||||
level: PermissionLevel = "tenant"
|
level: PermissionLevel = "tenant"
|
||||||
managed: bool = True
|
managed: bool = True
|
||||||
protected: bool = False
|
protected: bool = False
|
||||||
|
default_authenticated: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -56,6 +71,7 @@ class NavItem:
|
|||||||
required_all: tuple[str, ...] = ()
|
required_all: tuple[str, ...] = ()
|
||||||
required_any: tuple[str, ...] = ()
|
required_any: tuple[str, ...] = ()
|
||||||
order: int = 100
|
order: int = 100
|
||||||
|
surface_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -67,6 +83,16 @@ class FrontendRoute:
|
|||||||
required_all: tuple[str, ...] = ()
|
required_all: tuple[str, ...] = ()
|
||||||
required_any: tuple[str, ...] = ()
|
required_any: tuple[str, ...] = ()
|
||||||
order: int = 100
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -79,8 +105,10 @@ class FrontendModule:
|
|||||||
asset_manifest_integrity: str | None = None
|
asset_manifest_integrity: str | None = None
|
||||||
asset_manifest_contract_version: str = "1"
|
asset_manifest_contract_version: str = "1"
|
||||||
routes: tuple[FrontendRoute, ...] = ()
|
routes: tuple[FrontendRoute, ...] = ()
|
||||||
|
public_routes: tuple[PublicFrontendRoute, ...] = ()
|
||||||
nav_items: tuple[NavItem, ...] = ()
|
nav_items: tuple[NavItem, ...] = ()
|
||||||
settings_routes: tuple[FrontendRoute, ...] = ()
|
settings_routes: tuple[FrontendRoute, ...] = ()
|
||||||
|
view_surfaces: tuple[ViewSurface, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -195,6 +223,19 @@ class ModuleContext:
|
|||||||
DocumentationLayer = Literal["always", "configured", "available", "evidence"]
|
DocumentationLayer = Literal["always", "configured", "available", "evidence"]
|
||||||
DocumentationLinkKind = Literal["runtime", "api", "repository", "wiki", "public"]
|
DocumentationLinkKind = Literal["runtime", "api", "repository", "wiki", "public"]
|
||||||
DocumentationType = Literal["admin", "user"]
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -233,9 +274,40 @@ class DocumentationTopic:
|
|||||||
i18n_key: str | None = None
|
i18n_key: str | None = None
|
||||||
translations: Mapping[str, Mapping[str, str]] = field(default_factory=dict)
|
translations: Mapping[str, Mapping[str, str]] = field(default_factory=dict)
|
||||||
source_module_id: str | None = None
|
source_module_id: str | None = None
|
||||||
|
version_min: str | None = None
|
||||||
|
version_max_exclusive: str | None = None
|
||||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class DocumentationContext:
|
class DocumentationContext:
|
||||||
registry: object
|
registry: object
|
||||||
@@ -247,6 +319,53 @@ class DocumentationContext:
|
|||||||
data: Mapping[str, Any] = field(default_factory=dict)
|
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):
|
class ResourceAclProvider(Protocol):
|
||||||
resource_type: str
|
resource_type: str
|
||||||
|
|
||||||
@@ -261,6 +380,10 @@ class ResourceAclProvider(Protocol):
|
|||||||
|
|
||||||
|
|
||||||
TenantSummaryProvider = Callable[[object, str], Mapping[str, int]]
|
TenantSummaryProvider = Callable[[object, str], Mapping[str, int]]
|
||||||
|
TenantSummaryBatchProvider = Callable[
|
||||||
|
[object, Sequence[str]],
|
||||||
|
Mapping[str, Mapping[str, int]],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -307,12 +430,37 @@ class ModuleManifest:
|
|||||||
nav_items: tuple[NavItem, ...] = ()
|
nav_items: tuple[NavItem, ...] = ()
|
||||||
frontend: FrontendModule | None = None
|
frontend: FrontendModule | None = None
|
||||||
resource_acl_providers: tuple[ResourceAclProvider, ...] = ()
|
resource_acl_providers: tuple[ResourceAclProvider, ...] = ()
|
||||||
|
ownership_providers: tuple[OwnershipProviderRegistration, ...] = ()
|
||||||
tenant_summary_providers: tuple[TenantSummaryProvider, ...] = ()
|
tenant_summary_providers: tuple[TenantSummaryProvider, ...] = ()
|
||||||
|
tenant_summary_batch_providers: tuple[TenantSummaryBatchProvider, ...] = ()
|
||||||
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
|
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
|
||||||
uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = ()
|
uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = ()
|
||||||
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
|
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", ...] = ()
|
||||||
|
operational_check_providers: tuple[
|
||||||
|
"OperationalCheckProviderRegistration",
|
||||||
|
...,
|
||||||
|
] = ()
|
||||||
|
architecture: ModuleArchitectureDeclaration | None = None
|
||||||
|
external_providers: tuple[ExternalProviderDeclaration, ...] = ()
|
||||||
|
external_provider_state_providers: tuple[
|
||||||
|
ExternalProviderStateProviderRegistration,
|
||||||
|
...,
|
||||||
|
] = ()
|
||||||
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
|
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
|
||||||
on_activate: LifecycleHook | None = None
|
on_activate: LifecycleHook | None = None
|
||||||
on_deactivate: LifecycleHook | None = None
|
on_deactivate: LifecycleHook | None = None
|
||||||
documentation: tuple[DocumentationTopic, ...] = ()
|
documentation: tuple[DocumentationTopic, ...] = ()
|
||||||
documentation_providers: tuple[DocumentationProvider, ...] = ()
|
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", ...] = ()
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_NOTIFICATIONS_DISPATCH = "notifications.dispatch"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class NotificationDispatchRequest:
|
||||||
|
tenant_id: str
|
||||||
|
source_module: str
|
||||||
|
source_resource_type: str
|
||||||
|
source_resource_id: str | None
|
||||||
|
event_kind: str
|
||||||
|
channel: str = "inbox"
|
||||||
|
recipient: str | None = None
|
||||||
|
recipient_type: str | None = None
|
||||||
|
recipient_id: str | None = None
|
||||||
|
recipient_label: str | None = None
|
||||||
|
subject: str | None = None
|
||||||
|
body_text: str | None = None
|
||||||
|
body_html: str | None = None
|
||||||
|
action_url: str | None = None
|
||||||
|
priority: int = 0
|
||||||
|
not_before_at: datetime | None = None
|
||||||
|
payload: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class NotificationDispatchProvider(Protocol):
|
||||||
|
def enqueue_notification(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
request: NotificationDispatchRequest,
|
||||||
|
*,
|
||||||
|
enqueue_delivery: bool = True,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def deliver_notification(self, session: object, *, notification_id: str) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def deliver_pending(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def notification_dispatch_provider(registry: object | None) -> NotificationDispatchProvider | None:
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_NOTIFICATIONS_DISPATCH):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_NOTIFICATIONS_DISPATCH)
|
||||||
|
return capability if isinstance(capability, NotificationDispatchProvider) else None
|
||||||
@@ -0,0 +1,597 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from heapq import nsmallest
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
from typing import Any, Iterable, Protocol
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from govoplan_core.security.outbound_http import (
|
||||||
|
OutboundHttpError,
|
||||||
|
response_limit,
|
||||||
|
validate_unpinned_sdk_http_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StorageBackendError(RuntimeError):
|
||||||
|
"""Base error for the deployment-owned object-storage boundary."""
|
||||||
|
|
||||||
|
|
||||||
|
class StorageObjectMissing(StorageBackendError):
|
||||||
|
"""Raised when a referenced object no longer exists."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class StorageObjectInfo:
|
||||||
|
key: str
|
||||||
|
size_bytes: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class StorageObjectPage:
|
||||||
|
objects: tuple[StorageObjectInfo, ...]
|
||||||
|
next_cursor: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class StorageBackend(Protocol):
|
||||||
|
"""Shared byte-object storage used by modules without cross-module imports."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
|
||||||
|
def put_bytes(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
data: bytes,
|
||||||
|
*,
|
||||||
|
content_type: str | None = None,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
def get_bytes(self, key: str) -> bytes: ...
|
||||||
|
|
||||||
|
def iter_bytes(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
*,
|
||||||
|
chunk_size: int = 1024 * 1024,
|
||||||
|
) -> Iterable[bytes]: ...
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None: ...
|
||||||
|
|
||||||
|
def exists(self, key: str) -> bool: ...
|
||||||
|
|
||||||
|
def stat(self, key: str) -> StorageObjectInfo: ...
|
||||||
|
|
||||||
|
def list_objects(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
prefix: str,
|
||||||
|
after: str | None = None,
|
||||||
|
limit: int = 500,
|
||||||
|
) -> StorageObjectPage: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class LocalFilesystemStorageBackend:
|
||||||
|
root: Path
|
||||||
|
fallback_roots: tuple[Path, ...] = field(default_factory=tuple)
|
||||||
|
name: str = "local"
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
self.root = self.root.expanduser().resolve()
|
||||||
|
self.fallback_roots = tuple(
|
||||||
|
root.expanduser().resolve() for root in self.fallback_roots if root
|
||||||
|
)
|
||||||
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def _path_for_root(self, root: Path, key: str) -> Path:
|
||||||
|
normalized = normalize_storage_key(key)
|
||||||
|
path = (root / normalized).resolve()
|
||||||
|
if not path.is_relative_to(root):
|
||||||
|
raise StorageBackendError("Storage key escapes local storage root")
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _path(self, key: str) -> Path:
|
||||||
|
return self._path_for_root(self.root, key)
|
||||||
|
|
||||||
|
def _readable_path(self, key: str) -> Path:
|
||||||
|
primary = self._path(key)
|
||||||
|
if primary.exists() and primary.is_file():
|
||||||
|
return primary
|
||||||
|
for root in self.fallback_roots:
|
||||||
|
candidate = self._path_for_root(root, key)
|
||||||
|
if candidate.exists() and candidate.is_file():
|
||||||
|
return candidate
|
||||||
|
raise StorageObjectMissing("Stored object does not exist")
|
||||||
|
|
||||||
|
def put_bytes(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
data: bytes,
|
||||||
|
*,
|
||||||
|
content_type: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
del content_type
|
||||||
|
path = self._path(key)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
descriptor, temporary_name = tempfile.mkstemp(
|
||||||
|
prefix=f".{path.name}.",
|
||||||
|
suffix=".tmp",
|
||||||
|
dir=path.parent,
|
||||||
|
)
|
||||||
|
temporary = Path(temporary_name)
|
||||||
|
try:
|
||||||
|
os.fchmod(descriptor, 0o600)
|
||||||
|
with os.fdopen(descriptor, "wb") as stream:
|
||||||
|
descriptor = -1
|
||||||
|
stream.write(data)
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
temporary.replace(path)
|
||||||
|
finally:
|
||||||
|
if descriptor >= 0:
|
||||||
|
os.close(descriptor)
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def get_bytes(self, key: str) -> bytes:
|
||||||
|
return self._readable_path(key).read_bytes()
|
||||||
|
|
||||||
|
def iter_bytes(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
*,
|
||||||
|
chunk_size: int = 1024 * 1024,
|
||||||
|
) -> Iterable[bytes]:
|
||||||
|
path = self._readable_path(key)
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
while True:
|
||||||
|
chunk = handle.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
path = self._path(key)
|
||||||
|
if path.exists() and path.is_file():
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def exists(self, key: str) -> bool:
|
||||||
|
try:
|
||||||
|
self._readable_path(key)
|
||||||
|
except StorageObjectMissing:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def stat(self, key: str) -> StorageObjectInfo:
|
||||||
|
path = self._readable_path(key)
|
||||||
|
return StorageObjectInfo(
|
||||||
|
key=normalize_storage_key(key),
|
||||||
|
size_bytes=path.stat().st_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_objects(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
prefix: str,
|
||||||
|
after: str | None = None,
|
||||||
|
limit: int = 500,
|
||||||
|
) -> StorageObjectPage:
|
||||||
|
normalized_prefix = normalize_storage_prefix(prefix)
|
||||||
|
normalized_after = normalize_storage_key(after) if after else None
|
||||||
|
normalized_limit = max(1, min(int(limit), 5000))
|
||||||
|
|
||||||
|
def matching_objects() -> Iterable[StorageObjectInfo]:
|
||||||
|
for path in _iter_local_files(self.root):
|
||||||
|
key = path.relative_to(self.root).as_posix()
|
||||||
|
if not key.startswith(normalized_prefix) or (
|
||||||
|
normalized_after is not None and key <= normalized_after
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
yield StorageObjectInfo(
|
||||||
|
key=key,
|
||||||
|
size_bytes=path.stat().st_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
candidates = nsmallest(
|
||||||
|
normalized_limit + 1,
|
||||||
|
matching_objects(),
|
||||||
|
key=lambda item: item.key,
|
||||||
|
)
|
||||||
|
has_more = len(candidates) > normalized_limit
|
||||||
|
page = tuple(candidates[:normalized_limit])
|
||||||
|
return StorageObjectPage(
|
||||||
|
objects=page,
|
||||||
|
next_cursor=page[-1].key if has_more and page else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class S3StorageBackend:
|
||||||
|
bucket: str
|
||||||
|
endpoint_url: str
|
||||||
|
region_name: str
|
||||||
|
access_key_id: str
|
||||||
|
secret_access_key: str
|
||||||
|
deployment_managed: bool = False
|
||||||
|
endpoint_trusted: bool = False
|
||||||
|
name: str = "s3"
|
||||||
|
_client: Any = field(default=None, init=False, repr=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client(self):
|
||||||
|
if self._client is not None:
|
||||||
|
return self._client
|
||||||
|
if self.deployment_managed:
|
||||||
|
endpoint_url = _deployment_managed_garage_endpoint(self.endpoint_url)
|
||||||
|
elif self.endpoint_trusted:
|
||||||
|
endpoint_url = _trusted_deployment_endpoint(self.endpoint_url)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
endpoint_url = validate_unpinned_sdk_http_url(
|
||||||
|
self.endpoint_url,
|
||||||
|
label="Object storage S3 endpoint",
|
||||||
|
)
|
||||||
|
except OutboundHttpError as exc:
|
||||||
|
raise StorageBackendError(str(exc)) from exc
|
||||||
|
try:
|
||||||
|
import boto3
|
||||||
|
from botocore.config import Config
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise StorageBackendError(
|
||||||
|
"boto3 is required for the S3 storage backend"
|
||||||
|
) from exc
|
||||||
|
options: dict[str, object] = {
|
||||||
|
"endpoint_url": endpoint_url,
|
||||||
|
"region_name": self.region_name,
|
||||||
|
"aws_access_key_id": self.access_key_id,
|
||||||
|
"aws_secret_access_key": self.secret_access_key,
|
||||||
|
}
|
||||||
|
if self.deployment_managed:
|
||||||
|
options["config"] = Config(s3={"addressing_style": "path"})
|
||||||
|
self._client = boto3.client("s3", **options)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
def put_bytes(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
data: bytes,
|
||||||
|
*,
|
||||||
|
content_type: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
normalized = normalize_storage_key(key)
|
||||||
|
max_bytes = response_limit("file")
|
||||||
|
if len(data) > max_bytes:
|
||||||
|
raise StorageBackendError(
|
||||||
|
f"Stored object exceeds the deployment limit of {max_bytes} bytes"
|
||||||
|
)
|
||||||
|
kwargs: dict[str, object] = {
|
||||||
|
"Bucket": self.bucket,
|
||||||
|
"Key": normalized,
|
||||||
|
"Body": data,
|
||||||
|
}
|
||||||
|
if content_type:
|
||||||
|
kwargs["ContentType"] = content_type
|
||||||
|
try:
|
||||||
|
self.client.put_object(**kwargs)
|
||||||
|
except Exception as exc: # pragma: no cover - depends on S3 backend
|
||||||
|
raise StorageBackendError(str(exc)) from exc
|
||||||
|
|
||||||
|
def get_bytes(self, key: str) -> bytes:
|
||||||
|
normalized = normalize_storage_key(key)
|
||||||
|
try:
|
||||||
|
obj = self.client.get_object(
|
||||||
|
Bucket=self.bucket,
|
||||||
|
Key=normalized,
|
||||||
|
)
|
||||||
|
max_bytes = response_limit("file")
|
||||||
|
body = obj["Body"]
|
||||||
|
try:
|
||||||
|
_reject_declared_object_size(obj, max_bytes=max_bytes)
|
||||||
|
data = body.read(max_bytes + 1)
|
||||||
|
if len(data) > max_bytes:
|
||||||
|
raise StorageBackendError(
|
||||||
|
"Stored object exceeds the deployment limit of "
|
||||||
|
f"{max_bytes} bytes"
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
finally:
|
||||||
|
if hasattr(body, "close"):
|
||||||
|
body.close()
|
||||||
|
except StorageBackendError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # pragma: no cover - depends on S3 backend
|
||||||
|
if _s3_missing_error(exc):
|
||||||
|
raise StorageObjectMissing("Stored object does not exist") from exc
|
||||||
|
raise StorageBackendError(str(exc)) from exc
|
||||||
|
|
||||||
|
def iter_bytes(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
*,
|
||||||
|
chunk_size: int = 1024 * 1024,
|
||||||
|
) -> Iterable[bytes]:
|
||||||
|
normalized = normalize_storage_key(key)
|
||||||
|
try:
|
||||||
|
obj = self.client.get_object(
|
||||||
|
Bucket=self.bucket,
|
||||||
|
Key=normalized,
|
||||||
|
)
|
||||||
|
max_bytes = response_limit("file")
|
||||||
|
body = obj["Body"]
|
||||||
|
try:
|
||||||
|
_reject_declared_object_size(obj, max_bytes=max_bytes)
|
||||||
|
total = 0
|
||||||
|
while True:
|
||||||
|
chunk = body.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
total += len(chunk)
|
||||||
|
if total > max_bytes:
|
||||||
|
raise StorageBackendError(
|
||||||
|
"Stored object exceeds the deployment limit of "
|
||||||
|
f"{max_bytes} bytes"
|
||||||
|
)
|
||||||
|
yield chunk
|
||||||
|
finally:
|
||||||
|
if hasattr(body, "close"):
|
||||||
|
body.close()
|
||||||
|
except StorageBackendError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # pragma: no cover - depends on S3 backend
|
||||||
|
if _s3_missing_error(exc):
|
||||||
|
raise StorageObjectMissing("Stored object does not exist") from exc
|
||||||
|
raise StorageBackendError(str(exc)) from exc
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
try:
|
||||||
|
self.client.delete_object(
|
||||||
|
Bucket=self.bucket,
|
||||||
|
Key=normalize_storage_key(key),
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover - depends on S3 backend
|
||||||
|
raise StorageBackendError(str(exc)) from exc
|
||||||
|
|
||||||
|
def exists(self, key: str) -> bool:
|
||||||
|
try:
|
||||||
|
self.client.head_object(
|
||||||
|
Bucket=self.bucket,
|
||||||
|
Key=normalize_storage_key(key),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
if _s3_missing_error(exc):
|
||||||
|
return False
|
||||||
|
raise StorageBackendError(str(exc)) from exc
|
||||||
|
|
||||||
|
def stat(self, key: str) -> StorageObjectInfo:
|
||||||
|
normalized = normalize_storage_key(key)
|
||||||
|
try:
|
||||||
|
response = self.client.head_object(
|
||||||
|
Bucket=self.bucket,
|
||||||
|
Key=normalized,
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover - depends on S3 backend
|
||||||
|
if _s3_missing_error(exc):
|
||||||
|
raise StorageObjectMissing("Stored object does not exist") from exc
|
||||||
|
raise StorageBackendError(str(exc)) from exc
|
||||||
|
try:
|
||||||
|
size = int(response.get("ContentLength"))
|
||||||
|
except (AttributeError, TypeError, ValueError) as exc:
|
||||||
|
raise StorageBackendError(
|
||||||
|
"S3 object metadata did not include a valid size"
|
||||||
|
) from exc
|
||||||
|
return StorageObjectInfo(key=normalized, size_bytes=size)
|
||||||
|
|
||||||
|
def list_objects(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
prefix: str,
|
||||||
|
after: str | None = None,
|
||||||
|
limit: int = 500,
|
||||||
|
) -> StorageObjectPage:
|
||||||
|
normalized_prefix = normalize_storage_prefix(prefix)
|
||||||
|
normalized_limit = max(1, min(int(limit), 1000))
|
||||||
|
kwargs: dict[str, object] = {
|
||||||
|
"Bucket": self.bucket,
|
||||||
|
"Prefix": normalized_prefix,
|
||||||
|
"MaxKeys": normalized_limit,
|
||||||
|
}
|
||||||
|
if after:
|
||||||
|
kwargs["StartAfter"] = normalize_storage_key(after)
|
||||||
|
try:
|
||||||
|
response = self.client.list_objects_v2(**kwargs)
|
||||||
|
except Exception as exc: # pragma: no cover - depends on S3 backend
|
||||||
|
raise StorageBackendError(str(exc)) from exc
|
||||||
|
objects = tuple(
|
||||||
|
StorageObjectInfo(
|
||||||
|
key=str(item["Key"]),
|
||||||
|
size_bytes=int(item.get("Size") or 0),
|
||||||
|
)
|
||||||
|
for item in response.get("Contents", ())
|
||||||
|
if isinstance(item, dict) and item.get("Key")
|
||||||
|
)
|
||||||
|
has_more = bool(response.get("IsTruncated"))
|
||||||
|
return StorageObjectPage(
|
||||||
|
objects=objects,
|
||||||
|
next_cursor=objects[-1].key if has_more and objects else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def configured_storage_backend(settings: object) -> StorageBackend:
|
||||||
|
"""Build the deployment-wide object store from Core settings.
|
||||||
|
|
||||||
|
Modules own their metadata and key namespaces. The deployment owns the
|
||||||
|
storage endpoint and credentials, so modules do not need to depend on the
|
||||||
|
Files package merely to persist opaque generated bytes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
configured = (
|
||||||
|
str(getattr(settings, "file_storage_backend", "local") or "local")
|
||||||
|
.strip()
|
||||||
|
.lower()
|
||||||
|
)
|
||||||
|
if configured in {"local", "filesystem", "fs"}:
|
||||||
|
raw_fallbacks = str(
|
||||||
|
getattr(settings, "file_storage_local_fallback_roots", "") or ""
|
||||||
|
)
|
||||||
|
return LocalFilesystemStorageBackend(
|
||||||
|
Path(
|
||||||
|
str(
|
||||||
|
getattr(
|
||||||
|
settings,
|
||||||
|
"file_storage_local_root",
|
||||||
|
"runtime/files",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
fallback_roots=tuple(
|
||||||
|
Path(item.strip()) for item in raw_fallbacks.split(",") if item.strip()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if configured in {"s3", "garage"}:
|
||||||
|
return S3StorageBackend(
|
||||||
|
bucket=str(
|
||||||
|
getattr(settings, "file_storage_s3_bucket", None)
|
||||||
|
or getattr(settings, "s3_bucket", "files")
|
||||||
|
),
|
||||||
|
endpoint_url=str(
|
||||||
|
getattr(settings, "file_storage_s3_endpoint_url", None)
|
||||||
|
or getattr(settings, "s3_endpoint_url", "")
|
||||||
|
),
|
||||||
|
region_name=str(
|
||||||
|
getattr(settings, "file_storage_s3_region", None)
|
||||||
|
or getattr(settings, "s3_region", "")
|
||||||
|
),
|
||||||
|
access_key_id=str(
|
||||||
|
getattr(settings, "file_storage_s3_access_key_id", None)
|
||||||
|
or getattr(settings, "s3_access_key_id", "")
|
||||||
|
),
|
||||||
|
secret_access_key=str(
|
||||||
|
getattr(
|
||||||
|
settings,
|
||||||
|
"file_storage_s3_secret_access_key",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
or getattr(settings, "s3_secret_access_key", "")
|
||||||
|
),
|
||||||
|
deployment_managed=bool(
|
||||||
|
getattr(
|
||||||
|
settings,
|
||||||
|
"file_storage_s3_deployment_managed",
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
endpoint_trusted=bool(
|
||||||
|
getattr(
|
||||||
|
settings,
|
||||||
|
"file_storage_s3_endpoint_trusted",
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
raise StorageBackendError(f"Unsupported object storage backend: {configured}")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_storage_key(value: str) -> str:
|
||||||
|
candidate = str(value or "").strip().replace("\\", "/")
|
||||||
|
parts = candidate.split("/")
|
||||||
|
if (
|
||||||
|
not candidate
|
||||||
|
or candidate.startswith("/")
|
||||||
|
or any(part in {"", ".", ".."} for part in parts)
|
||||||
|
or any(ord(character) < 32 for character in candidate)
|
||||||
|
):
|
||||||
|
raise StorageBackendError("Storage key is not a safe relative key")
|
||||||
|
return "/".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_storage_prefix(value: str) -> str:
|
||||||
|
candidate = str(value or "").strip().replace("\\", "/")
|
||||||
|
if not candidate:
|
||||||
|
return ""
|
||||||
|
trailing_slash = candidate.endswith("/")
|
||||||
|
normalized = normalize_storage_key(candidate.rstrip("/"))
|
||||||
|
return normalized + ("/" if trailing_slash else "")
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_declared_object_size(obj: object, *, max_bytes: int) -> None:
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
declared_size = int(obj.get("ContentLength"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return
|
||||||
|
if declared_size > max_bytes:
|
||||||
|
raise StorageBackendError(
|
||||||
|
f"Stored object exceeds the deployment limit of {max_bytes} bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_local_files(root: Path):
|
||||||
|
for entry in sorted(root.iterdir(), key=lambda item: item.name):
|
||||||
|
if entry.is_symlink():
|
||||||
|
continue
|
||||||
|
if entry.is_dir():
|
||||||
|
yield from _iter_local_files(entry)
|
||||||
|
elif entry.is_file():
|
||||||
|
yield entry
|
||||||
|
|
||||||
|
|
||||||
|
def _s3_missing_error(exc: Exception) -> bool:
|
||||||
|
response = getattr(exc, "response", None)
|
||||||
|
if not isinstance(response, dict):
|
||||||
|
return False
|
||||||
|
error = response.get("Error")
|
||||||
|
metadata = response.get("ResponseMetadata")
|
||||||
|
code = str(error.get("Code") if isinstance(error, dict) else "")
|
||||||
|
status_code = metadata.get("HTTPStatusCode") if isinstance(metadata, dict) else None
|
||||||
|
return code in {"404", "NoSuchKey", "NotFound"} or status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def _deployment_managed_garage_endpoint(value: str) -> str:
|
||||||
|
endpoint = str(value or "").strip()
|
||||||
|
if endpoint != "http://garage:3900":
|
||||||
|
raise StorageBackendError(
|
||||||
|
"Deployment-managed S3 trust is restricted to http://garage:3900"
|
||||||
|
)
|
||||||
|
return endpoint
|
||||||
|
|
||||||
|
|
||||||
|
def _trusted_deployment_endpoint(value: str) -> str:
|
||||||
|
endpoint = str(value or "").strip()
|
||||||
|
parsed = urlsplit(endpoint)
|
||||||
|
try:
|
||||||
|
parsed.port
|
||||||
|
except ValueError as exc:
|
||||||
|
raise StorageBackendError(
|
||||||
|
"Deployment-trusted S3 endpoint has an invalid port"
|
||||||
|
) from exc
|
||||||
|
if (
|
||||||
|
parsed.scheme.lower() != "https"
|
||||||
|
or not parsed.hostname
|
||||||
|
or parsed.username
|
||||||
|
or parsed.password
|
||||||
|
or parsed.query
|
||||||
|
or parsed.fragment
|
||||||
|
or parsed.path not in {"", "/"}
|
||||||
|
):
|
||||||
|
raise StorageBackendError(
|
||||||
|
"Deployment-trusted S3 endpoint must be an HTTPS origin without "
|
||||||
|
"credentials, query, fragment, or path"
|
||||||
|
)
|
||||||
|
return endpoint.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LocalFilesystemStorageBackend",
|
||||||
|
"S3StorageBackend",
|
||||||
|
"StorageBackend",
|
||||||
|
"StorageBackendError",
|
||||||
|
"StorageObjectInfo",
|
||||||
|
"StorageObjectMissing",
|
||||||
|
"StorageObjectPage",
|
||||||
|
"configured_storage_backend",
|
||||||
|
"normalize_storage_key",
|
||||||
|
"normalize_storage_prefix",
|
||||||
|
]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
OperationalCheckState = Literal["ok", "warning", "error", "inactive"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OperationalCheck:
|
||||||
|
"""A bounded module-owned runtime check exposed through the Ops module."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
state: OperationalCheckState
|
||||||
|
detail: str
|
||||||
|
readiness_critical: bool = False
|
||||||
|
metrics: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"label": self.label,
|
||||||
|
"state": self.state,
|
||||||
|
"detail": self.detail,
|
||||||
|
"readiness_critical": self.readiness_critical,
|
||||||
|
"metrics": dict(self.metrics),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
OperationalCheckProvider = Callable[[], OperationalCheck]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OperationalCheckProviderRegistration:
|
||||||
|
"""Register one independently executable operational check."""
|
||||||
|
|
||||||
|
module_id: str
|
||||||
|
check_id: str
|
||||||
|
provider: OperationalCheckProvider
|
||||||
|
cache_seconds: int = 60
|
||||||
|
|
||||||
@@ -1,14 +1,58 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
from typing import Literal, Protocol, runtime_checkable
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
ORGANIZATIONS_MODULE_ID = "organizations"
|
ORGANIZATIONS_MODULE_ID = "organizations"
|
||||||
CAPABILITY_ORGANIZATION_DIRECTORY = "organizations.directory"
|
CAPABILITY_ORGANIZATION_DIRECTORY = "organizations.directory"
|
||||||
|
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY = (
|
||||||
|
"organizations.hierarchyDirectory"
|
||||||
|
)
|
||||||
|
|
||||||
OrganizationStatus = Literal["active", "inactive", "suspended"]
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -35,6 +79,143 @@ class OrganizationFunctionRef:
|
|||||||
delegable: bool = False
|
delegable: bool = False
|
||||||
act_in_place_allowed: bool = False
|
act_in_place_allowed: bool = False
|
||||||
status: OrganizationStatus = "active"
|
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
|
@runtime_checkable
|
||||||
@@ -55,3 +236,156 @@ class OrganizationDirectory(Protocol):
|
|||||||
include_subunits: bool = False,
|
include_subunits: bool = False,
|
||||||
) -> Sequence[OrganizationFunctionRef]:
|
) -> 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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -4,11 +4,55 @@ from dataclasses import dataclass, field
|
|||||||
from typing import Any, Iterable, Literal, Mapping, Protocol, cast, runtime_checkable
|
from typing import Any, Iterable, Literal, Mapping, Protocol, cast, runtime_checkable
|
||||||
from urllib.parse import quote, unquote
|
from urllib.parse import quote, unquote
|
||||||
|
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
|
||||||
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
|
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_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:
|
def normalize_policy_scope_type(scope_type: str) -> PolicyScopeType:
|
||||||
@@ -28,7 +72,11 @@ class PolicySourceRef:
|
|||||||
return policy_source_path(self.scope_type, self.scope_id)
|
return policy_source_path(self.scope_type, self.scope_id)
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
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:
|
def policy_source_path(scope_type: str, scope_id: str | None = None) -> str:
|
||||||
@@ -38,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")
|
raise ValueError("System policy sources do not carry a scope_id")
|
||||||
return "system"
|
return "system"
|
||||||
if not scope_id:
|
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='')}"
|
return f"{clean_scope}:{quote(str(scope_id), safe='')}"
|
||||||
|
|
||||||
|
|
||||||
@@ -48,7 +98,9 @@ def parse_policy_source_path(path: str) -> PolicySourceRef:
|
|||||||
return PolicySourceRef(scope_type="system")
|
return PolicySourceRef(scope_type="system")
|
||||||
scope_type, separator, encoded_scope_id = clean_path.partition(":")
|
scope_type, separator, encoded_scope_id = clean_path.partition(":")
|
||||||
if not separator:
|
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)
|
clean_scope = normalize_policy_scope_type(scope_type)
|
||||||
if clean_scope == "system":
|
if clean_scope == "system":
|
||||||
raise ValueError("System policy source path must be exactly system")
|
raise ValueError("System policy source path must be exactly system")
|
||||||
@@ -85,9 +137,13 @@ class PolicySourceStep:
|
|||||||
policy_value = value.get("policy")
|
policy_value = value.get("policy")
|
||||||
return cls(
|
return cls(
|
||||||
scope_type=normalize_policy_scope_type(str(value.get("scope_type", ""))),
|
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 ""),
|
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 {},
|
policy=policy_value if isinstance(policy_value, Mapping) else {},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -126,37 +182,287 @@ 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.
|
||||||
|
|
||||||
|
``requested_visibility`` is the Scheduling-owned configuration result. A
|
||||||
|
policy provider may preserve or reduce it, but must not broaden it. Core
|
||||||
|
deliberately defines no fallback when the optional capability is absent;
|
||||||
|
that secure default remains the responsibility of Scheduling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
tenant_id: str
|
||||||
|
scheduling_request_id: str
|
||||||
|
participant_id: str
|
||||||
|
requested_visibility: SchedulingParticipantVisibility
|
||||||
|
actor_user_id: str | None = None
|
||||||
|
context: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SchedulingParticipantPrivacyDecision:
|
||||||
|
"""Policy-resolved visibility of other participants' names and statuses."""
|
||||||
|
|
||||||
|
effective_visibility: SchedulingParticipantVisibility
|
||||||
|
reason: str | None = None
|
||||||
|
source_path: tuple[PolicySourceStep, ...] = ()
|
||||||
|
details: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"effective_visibility": self.effective_visibility,
|
||||||
|
"reason": self.reason,
|
||||||
|
"source_path": [step.to_dict() for step in self.source_path],
|
||||||
|
"details": dict(self.details),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class SchedulingParticipantPrivacyPolicy(Protocol):
|
||||||
|
"""Optional policy hook for Scheduling participant-roster disclosure."""
|
||||||
|
|
||||||
|
def resolve_scheduling_participant_visibility(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
request: SchedulingParticipantPrivacyRequest,
|
||||||
|
) -> SchedulingParticipantPrivacyDecision: ...
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class PrivacyRetentionService(Protocol):
|
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(
|
||||||
|
registry: object | None,
|
||||||
|
) -> SchedulingParticipantPrivacyPolicy | None:
|
||||||
|
"""Return the optional provider without selecting a visibility fallback."""
|
||||||
|
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_POLL_SCHEDULING = "poll.scheduling"
|
||||||
|
|
||||||
|
|
||||||
|
class PollCapabilityError(ValueError):
|
||||||
|
"""Stable error raised by Poll capability implementations."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollOptionRequest:
|
||||||
|
label: str
|
||||||
|
key: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
value: Mapping[str, object] | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollCreateCommand:
|
||||||
|
title: str
|
||||||
|
kind: str
|
||||||
|
status: str
|
||||||
|
visibility: str
|
||||||
|
result_visibility: str
|
||||||
|
description: str | None = None
|
||||||
|
context_module: str | None = None
|
||||||
|
context_resource_type: str | None = None
|
||||||
|
context_resource_id: str | None = None
|
||||||
|
workflow_state: str | None = None
|
||||||
|
workflow_steps: tuple[Mapping[str, object], ...] = ()
|
||||||
|
allow_anonymous: bool = False
|
||||||
|
allow_response_update: bool = True
|
||||||
|
min_choices: int = 1
|
||||||
|
max_choices: int | None = None
|
||||||
|
opens_at: datetime | None = None
|
||||||
|
closes_at: datetime | None = None
|
||||||
|
options: tuple[PollOptionRequest, ...] = ()
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollUpdateCommand:
|
||||||
|
"""Scheduling-owned Poll fields synchronized as one policy snapshot."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
description: str | None
|
||||||
|
visibility: str
|
||||||
|
result_visibility: str
|
||||||
|
allow_anonymous: bool
|
||||||
|
allow_response_update: bool
|
||||||
|
closes_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollOptionUpdateCommand:
|
||||||
|
"""Complete mutable option snapshot supplied by an owning module.
|
||||||
|
|
||||||
|
Providers must treat an exact repeat as a no-op. When the snapshot
|
||||||
|
changes, answers for this option are invalidated while answers for other
|
||||||
|
options are retained.
|
||||||
|
"""
|
||||||
|
|
||||||
|
label: str
|
||||||
|
description: str | None = None
|
||||||
|
value: Mapping[str, object] | None = None
|
||||||
|
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
|
||||||
|
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 PollAnswerRequest:
|
||||||
|
option_id: str | None = None
|
||||||
|
option_key: str | None = None
|
||||||
|
value: object = None
|
||||||
|
rank: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollSubmitResponseCommand:
|
||||||
|
respondent_id: str
|
||||||
|
respondent_label: str | None = None
|
||||||
|
answers: tuple[PollAnswerRequest, ...] = ()
|
||||||
|
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
|
||||||
|
position: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollRef:
|
||||||
|
id: str
|
||||||
|
status: str
|
||||||
|
options: tuple[PollOptionRef, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollInvitationRef:
|
||||||
|
id: str
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollAnswerRef:
|
||||||
|
option_id: str | None = None
|
||||||
|
option_key: str | None = None
|
||||||
|
value: object = None
|
||||||
|
rank: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollResponseRef:
|
||||||
|
invitation_id: str | None
|
||||||
|
submitted_at: datetime
|
||||||
|
# Optional for backwards compatibility with existing providers. A
|
||||||
|
# consumer can use it to reconcile an authenticated response without
|
||||||
|
# trusting client-supplied invitation metadata.
|
||||||
|
respondent_id: str | None = None
|
||||||
|
# Optional for backwards compatibility. Providers that support response
|
||||||
|
# editing expose the authoritative, still-valid answers here.
|
||||||
|
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(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str | None,
|
||||||
|
command: PollCreateCommand,
|
||||||
|
) -> PollRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def create_invitation(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollInvitationCommand,
|
||||||
|
) -> PollInvitationRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def update_poll(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollUpdateCommand,
|
||||||
|
) -> PollRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def update_option(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
option_id: str,
|
||||||
|
command: PollOptionUpdateCommand,
|
||||||
|
) -> PollOptionRef:
|
||||||
|
"""Update one option and invalidate only answers for that option."""
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
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:
|
||||||
|
...
|
||||||
|
|
||||||
|
def close_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def decide_poll(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
option_id: str | None,
|
||||||
|
) -> PollRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def set_workflow_context(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
workflow_state: str,
|
||||||
|
workflow_steps: Sequence[Mapping[str, object]],
|
||||||
|
context_module: str,
|
||||||
|
context_resource_type: str,
|
||||||
|
context_resource_id: str,
|
||||||
|
) -> PollRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def result_summary(self, session: object, *, tenant_id: str, poll_id: str) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def list_responses(self, session: object, *, tenant_id: str, poll_id: str) -> Sequence[PollResponseRef]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_response(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
respondent_ids: Sequence[str],
|
||||||
|
invitation_id: str | None = None,
|
||||||
|
) -> PollResponseRef | None:
|
||||||
|
"""Return one response matching server-trusted participant identities."""
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PollResponseSubmissionProvider(Protocol):
|
||||||
|
"""Optional extension for modules that collect responses through Poll."""
|
||||||
|
|
||||||
|
def submit_response(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollSubmitResponseCommand,
|
||||||
|
) -> PollResponseRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
if not registry.has_capability(CAPABILITY_POLL_SCHEDULING):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_POLL_SCHEDULING)
|
||||||
|
return capability if isinstance(capability, PollSchedulingProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def poll_response_submission_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> 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",
|
||||||
|
]
|
||||||
@@ -0,0 +1,575 @@
|
|||||||
|
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 PostboxWrappedKeyRef:
|
||||||
|
"""Opaque envelope-key record; key material remains crypto-provider owned."""
|
||||||
|
|
||||||
|
recipient_type: str
|
||||||
|
recipient_id: str
|
||||||
|
key_epoch: int
|
||||||
|
wrapped_key_ref: str
|
||||||
|
algorithm: str | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PostboxExternalRecipientTokenRef:
|
||||||
|
"""External grant state without the bearer secret itself."""
|
||||||
|
|
||||||
|
token_id: str
|
||||||
|
state: str
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
one_time: bool = False
|
||||||
|
key_fetched_at: datetime | None = None
|
||||||
|
revoked_at: datetime | None = None
|
||||||
|
assurance_profile: 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
|
||||||
|
wrapped_keys: tuple[PostboxWrappedKeyRef, ...] = ()
|
||||||
|
external_recipient_tokens: tuple[PostboxExternalRecipientTokenRef, ...] = ()
|
||||||
|
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
|
||||||
|
ciphertext_ref: str | None = None
|
||||||
|
signed_manifest_ref: str | None = None
|
||||||
|
wrapped_keys: tuple[PostboxWrappedKeyRef, ...] = ()
|
||||||
|
external_recipient_tokens: tuple[PostboxExternalRecipientTokenRef, ...] = ()
|
||||||
|
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
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,669 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import StrEnum
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
JSON,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
select,
|
||||||
|
)
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||||
|
|
||||||
|
from govoplan_core.core.runtime_coordination import LeaseClaim, assert_lease_fence
|
||||||
|
from govoplan_core.db.base import Base, TimestampMixin, utcnow
|
||||||
|
from govoplan_core.security.redaction import contains_plain_secret
|
||||||
|
|
||||||
|
|
||||||
|
class RecoveryMode(StrEnum):
|
||||||
|
ATOMIC = "atomic"
|
||||||
|
COMPENSATION = "compensation"
|
||||||
|
SNAPSHOT_RESTORE = "snapshot_restore"
|
||||||
|
FORWARD_RECOVERY = "forward_recovery"
|
||||||
|
IRREVERSIBLE = "irreversible"
|
||||||
|
|
||||||
|
|
||||||
|
class RecoveryStatus(StrEnum):
|
||||||
|
PLANNED = "planned"
|
||||||
|
PREPARED = "prepared"
|
||||||
|
RUNNING = "running"
|
||||||
|
SUCCEEDED = "succeeded"
|
||||||
|
FAILED = "failed"
|
||||||
|
OUTCOME_UNKNOWN = "outcome_unknown"
|
||||||
|
RECOVERY_REQUIRED = "recovery_required"
|
||||||
|
RECOVERING = "recovering"
|
||||||
|
RECOVERED = "recovered"
|
||||||
|
MANUAL_INTERVENTION = "manual_intervention"
|
||||||
|
|
||||||
|
|
||||||
|
TERMINAL_RECOVERY_STATUSES = frozenset(
|
||||||
|
{
|
||||||
|
RecoveryStatus.SUCCEEDED.value,
|
||||||
|
RecoveryStatus.FAILED.value,
|
||||||
|
RecoveryStatus.RECOVERED.value,
|
||||||
|
RecoveryStatus.MANUAL_INTERVENTION.value,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_TRANSITIONS: dict[str, frozenset[str]] = {
|
||||||
|
RecoveryStatus.PLANNED.value: frozenset(
|
||||||
|
{RecoveryStatus.PREPARED.value, RecoveryStatus.FAILED.value}
|
||||||
|
),
|
||||||
|
RecoveryStatus.PREPARED.value: frozenset(
|
||||||
|
{RecoveryStatus.RUNNING.value, RecoveryStatus.FAILED.value}
|
||||||
|
),
|
||||||
|
RecoveryStatus.RUNNING.value: frozenset(
|
||||||
|
{
|
||||||
|
RecoveryStatus.SUCCEEDED.value,
|
||||||
|
RecoveryStatus.FAILED.value,
|
||||||
|
RecoveryStatus.OUTCOME_UNKNOWN.value,
|
||||||
|
RecoveryStatus.RECOVERY_REQUIRED.value,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
RecoveryStatus.OUTCOME_UNKNOWN.value: frozenset(
|
||||||
|
{
|
||||||
|
RecoveryStatus.SUCCEEDED.value,
|
||||||
|
RecoveryStatus.RECOVERY_REQUIRED.value,
|
||||||
|
RecoveryStatus.MANUAL_INTERVENTION.value,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
RecoveryStatus.RECOVERY_REQUIRED.value: frozenset(
|
||||||
|
{
|
||||||
|
RecoveryStatus.RECOVERING.value,
|
||||||
|
RecoveryStatus.MANUAL_INTERVENTION.value,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
RecoveryStatus.RECOVERING.value: frozenset(
|
||||||
|
{
|
||||||
|
RecoveryStatus.RECOVERED.value,
|
||||||
|
RecoveryStatus.MANUAL_INTERVENTION.value,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RecoveryGuaranteeError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RecoveryIdempotencyConflict(RecoveryGuaranteeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RecoveryOperation(Base, TimestampMixin):
|
||||||
|
__tablename__ = "core_recovery_operations"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"installation_id",
|
||||||
|
"module_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_core_recovery_operation_idempotency",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_core_recovery_operations_status_updated",
|
||||||
|
"installation_id",
|
||||||
|
"status",
|
||||||
|
"updated_at",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_core_recovery_operations_resource",
|
||||||
|
"module_id",
|
||||||
|
"resource_type",
|
||||||
|
"resource_id",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
primary_key=True,
|
||||||
|
default=lambda: str(uuid4()),
|
||||||
|
)
|
||||||
|
installation_id: Mapped[str] = mapped_column(
|
||||||
|
String(100), nullable=False, index=True
|
||||||
|
)
|
||||||
|
module_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||||
|
operation_type: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||||
|
resource_type: Mapped[str | None] = mapped_column(String(100), index=True)
|
||||||
|
resource_id: Mapped[str | None] = mapped_column(String(255), index=True)
|
||||||
|
mode: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(40),
|
||||||
|
default=RecoveryStatus.PLANNED.value,
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
idempotency_key: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
plan: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
backup_reference: Mapped[str | None] = mapped_column(String(1000))
|
||||||
|
approval_reference: Mapped[str | None] = mapped_column(String(1000))
|
||||||
|
lease_resource_key: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
holder_node_id: Mapped[str | None] = mapped_column(String(200))
|
||||||
|
holder_incarnation: Mapped[str | None] = mapped_column(String(36))
|
||||||
|
fencing_token: Mapped[int | None] = mapped_column(
|
||||||
|
BigInteger().with_variant(Integer, "sqlite")
|
||||||
|
)
|
||||||
|
checkpoint_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
evidence_head_sha256: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
failure_summary: Mapped[str | None] = mapped_column(Text)
|
||||||
|
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
recovery_started_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True)
|
||||||
|
)
|
||||||
|
recovered_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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RecoveryCheckpoint(Base):
|
||||||
|
__tablename__ = "core_recovery_checkpoints"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"operation_id",
|
||||||
|
"sequence",
|
||||||
|
name="uq_core_recovery_checkpoint_sequence",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_core_recovery_checkpoints_operation_created",
|
||||||
|
"operation_id",
|
||||||
|
"created_at",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
primary_key=True,
|
||||||
|
default=lambda: str(uuid4()),
|
||||||
|
)
|
||||||
|
operation_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("core_recovery_operations.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||||
|
kind: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||||
|
summary: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
previous_sha256: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
checkpoint_sha256: Mapped[str] = mapped_column(
|
||||||
|
String(64), nullable=False, index=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=utcnow, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RecoveryPlan:
|
||||||
|
mode: RecoveryMode
|
||||||
|
preconditions: tuple[str, ...] = ()
|
||||||
|
compensation_steps: tuple[str, ...] = ()
|
||||||
|
forward_recovery_steps: tuple[str, ...] = ()
|
||||||
|
verification_steps: tuple[str, ...] = ()
|
||||||
|
backup_reference: str | None = None
|
||||||
|
approval_reference: str | None = None
|
||||||
|
|
||||||
|
def validate(self) -> None:
|
||||||
|
if not self.verification_steps:
|
||||||
|
raise RecoveryGuaranteeError(
|
||||||
|
"Recovery plans require at least one verification step"
|
||||||
|
)
|
||||||
|
if self.mode == RecoveryMode.COMPENSATION and not self.compensation_steps:
|
||||||
|
raise RecoveryGuaranteeError(
|
||||||
|
"Compensation recovery requires explicit compensation steps"
|
||||||
|
)
|
||||||
|
if self.mode == RecoveryMode.SNAPSHOT_RESTORE and not self.backup_reference:
|
||||||
|
raise RecoveryGuaranteeError(
|
||||||
|
"Snapshot restore requires a verified backup reference"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
self.mode == RecoveryMode.FORWARD_RECOVERY
|
||||||
|
and not self.forward_recovery_steps
|
||||||
|
):
|
||||||
|
raise RecoveryGuaranteeError(
|
||||||
|
"Forward recovery requires explicit forward-recovery steps"
|
||||||
|
)
|
||||||
|
if self.mode == RecoveryMode.IRREVERSIBLE and not self.approval_reference:
|
||||||
|
raise RecoveryGuaranteeError(
|
||||||
|
"Irreversible operations require an approval reference"
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"mode": self.mode.value,
|
||||||
|
"preconditions": list(self.preconditions),
|
||||||
|
"compensation_steps": list(self.compensation_steps),
|
||||||
|
"forward_recovery_steps": list(self.forward_recovery_steps),
|
||||||
|
"verification_steps": list(self.verification_steps),
|
||||||
|
"backup_reference": self.backup_reference,
|
||||||
|
"approval_reference": self.approval_reference,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def plan_recovery_operation(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
installation_id: str,
|
||||||
|
module_id: str,
|
||||||
|
operation_type: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
request: dict[str, Any],
|
||||||
|
recovery_plan: RecoveryPlan,
|
||||||
|
resource_type: str | None = None,
|
||||||
|
resource_id: str | None = None,
|
||||||
|
lease_claim: LeaseClaim | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> RecoveryOperation:
|
||||||
|
recovery_plan.validate()
|
||||||
|
request_sha256 = _canonical_sha256(request)
|
||||||
|
existing = session.execute(
|
||||||
|
select(RecoveryOperation).where(
|
||||||
|
RecoveryOperation.installation_id == installation_id,
|
||||||
|
RecoveryOperation.module_id == module_id,
|
||||||
|
RecoveryOperation.idempotency_key == idempotency_key,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
if existing.request_sha256 != request_sha256:
|
||||||
|
raise RecoveryIdempotencyConflict(
|
||||||
|
"Recovery operation idempotency key was reused for another request"
|
||||||
|
)
|
||||||
|
return existing
|
||||||
|
if contains_plain_secret(metadata or {}):
|
||||||
|
raise RecoveryGuaranteeError(
|
||||||
|
"Recovery metadata must contain secret references, not plaintext secrets"
|
||||||
|
)
|
||||||
|
if lease_claim is not None:
|
||||||
|
if lease_claim.installation_id != installation_id:
|
||||||
|
raise RecoveryGuaranteeError(
|
||||||
|
"Recovery operation and lease belong to different installations"
|
||||||
|
)
|
||||||
|
assert_lease_fence(session, lease_claim)
|
||||||
|
operation = RecoveryOperation(
|
||||||
|
installation_id=installation_id,
|
||||||
|
module_id=module_id,
|
||||||
|
operation_type=operation_type,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
mode=recovery_plan.mode.value,
|
||||||
|
status=RecoveryStatus.PLANNED.value,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
request_sha256=request_sha256,
|
||||||
|
plan=recovery_plan.as_dict(),
|
||||||
|
backup_reference=recovery_plan.backup_reference,
|
||||||
|
approval_reference=recovery_plan.approval_reference,
|
||||||
|
lease_resource_key=lease_claim.resource_key if lease_claim else None,
|
||||||
|
holder_node_id=lease_claim.holder_node_id if lease_claim else None,
|
||||||
|
holder_incarnation=lease_claim.holder_incarnation if lease_claim else None,
|
||||||
|
fencing_token=lease_claim.fencing_token if lease_claim else None,
|
||||||
|
metadata_=dict(metadata or {}),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with session.begin_nested():
|
||||||
|
session.add(operation)
|
||||||
|
session.flush()
|
||||||
|
except IntegrityError:
|
||||||
|
existing = session.execute(
|
||||||
|
select(RecoveryOperation).where(
|
||||||
|
RecoveryOperation.installation_id == installation_id,
|
||||||
|
RecoveryOperation.module_id == module_id,
|
||||||
|
RecoveryOperation.idempotency_key == idempotency_key,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is None:
|
||||||
|
raise
|
||||||
|
if existing.request_sha256 != request_sha256:
|
||||||
|
raise RecoveryIdempotencyConflict(
|
||||||
|
"Recovery operation idempotency key was reused for another request"
|
||||||
|
)
|
||||||
|
return existing
|
||||||
|
record_recovery_checkpoint(
|
||||||
|
session,
|
||||||
|
operation,
|
||||||
|
kind="plan",
|
||||||
|
summary="Recovery contract recorded before side effects",
|
||||||
|
evidence={"request_sha256": request_sha256, "plan": recovery_plan.as_dict()},
|
||||||
|
lease_claim=lease_claim,
|
||||||
|
)
|
||||||
|
return operation
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_recovery_operation(
|
||||||
|
session: Session,
|
||||||
|
operation: RecoveryOperation,
|
||||||
|
*,
|
||||||
|
evidence: dict[str, Any],
|
||||||
|
lease_claim: LeaseClaim | None = None,
|
||||||
|
) -> RecoveryOperation:
|
||||||
|
_verify_operation_fence(session, operation, lease_claim)
|
||||||
|
if not evidence:
|
||||||
|
raise RecoveryGuaranteeError(
|
||||||
|
"Recovery preparation requires durable precondition evidence"
|
||||||
|
)
|
||||||
|
return transition_recovery_operation(
|
||||||
|
session,
|
||||||
|
operation,
|
||||||
|
status=RecoveryStatus.PREPARED,
|
||||||
|
kind="prepared",
|
||||||
|
summary="Preconditions and recovery material verified",
|
||||||
|
evidence=evidence,
|
||||||
|
lease_claim=lease_claim,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def start_recovery_operation(
|
||||||
|
session: Session,
|
||||||
|
operation: RecoveryOperation,
|
||||||
|
*,
|
||||||
|
evidence: dict[str, Any] | None = None,
|
||||||
|
lease_claim: LeaseClaim | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> RecoveryOperation:
|
||||||
|
_verify_operation_fence(session, operation, lease_claim)
|
||||||
|
operation.started_at = _as_utc(now or utcnow())
|
||||||
|
return transition_recovery_operation(
|
||||||
|
session,
|
||||||
|
operation,
|
||||||
|
status=RecoveryStatus.RUNNING,
|
||||||
|
kind="started",
|
||||||
|
summary="Guarded operation started",
|
||||||
|
evidence=evidence or {},
|
||||||
|
lease_claim=lease_claim,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def transition_recovery_operation(
|
||||||
|
session: Session,
|
||||||
|
operation: RecoveryOperation,
|
||||||
|
*,
|
||||||
|
status: RecoveryStatus,
|
||||||
|
kind: str,
|
||||||
|
summary: str,
|
||||||
|
evidence: dict[str, Any] | None = None,
|
||||||
|
failure_summary: str | None = None,
|
||||||
|
lease_claim: LeaseClaim | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> RecoveryOperation:
|
||||||
|
locked = session.execute(
|
||||||
|
select(RecoveryOperation)
|
||||||
|
.where(RecoveryOperation.id == operation.id)
|
||||||
|
.with_for_update()
|
||||||
|
).scalar_one()
|
||||||
|
_verify_operation_fence(session, locked, lease_claim)
|
||||||
|
allowed = _TRANSITIONS.get(locked.status, frozenset())
|
||||||
|
if status.value not in allowed:
|
||||||
|
raise RecoveryGuaranteeError(
|
||||||
|
f"Recovery transition {locked.status!r} -> {status.value!r} is not allowed"
|
||||||
|
)
|
||||||
|
_validate_mode_transition(locked, status)
|
||||||
|
_validate_transition_evidence(
|
||||||
|
status=status,
|
||||||
|
evidence=evidence or {},
|
||||||
|
failure_summary=failure_summary,
|
||||||
|
)
|
||||||
|
observed_at = _as_utc(now or utcnow())
|
||||||
|
locked.status = status.value
|
||||||
|
locked.revision = int(locked.revision or 0) + 1
|
||||||
|
if failure_summary is not None:
|
||||||
|
locked.failure_summary = failure_summary
|
||||||
|
if status == RecoveryStatus.SUCCEEDED:
|
||||||
|
locked.completed_at = observed_at
|
||||||
|
elif status == RecoveryStatus.RECOVERING:
|
||||||
|
locked.recovery_started_at = observed_at
|
||||||
|
elif status == RecoveryStatus.RECOVERED:
|
||||||
|
locked.recovered_at = observed_at
|
||||||
|
locked.completed_at = observed_at
|
||||||
|
elif status in {RecoveryStatus.FAILED, RecoveryStatus.MANUAL_INTERVENTION}:
|
||||||
|
locked.completed_at = observed_at
|
||||||
|
session.add(locked)
|
||||||
|
record_recovery_checkpoint(
|
||||||
|
session,
|
||||||
|
locked,
|
||||||
|
kind=kind,
|
||||||
|
summary=summary,
|
||||||
|
evidence=evidence or {},
|
||||||
|
lease_claim=lease_claim,
|
||||||
|
now=observed_at,
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
return locked
|
||||||
|
|
||||||
|
|
||||||
|
def record_recovery_checkpoint(
|
||||||
|
session: Session,
|
||||||
|
operation: RecoveryOperation,
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
summary: str,
|
||||||
|
evidence: dict[str, Any],
|
||||||
|
lease_claim: LeaseClaim | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> RecoveryCheckpoint:
|
||||||
|
if contains_plain_secret(evidence):
|
||||||
|
raise RecoveryGuaranteeError(
|
||||||
|
"Recovery evidence must contain secret references, not plaintext secrets"
|
||||||
|
)
|
||||||
|
locked = session.execute(
|
||||||
|
select(RecoveryOperation)
|
||||||
|
.where(RecoveryOperation.id == operation.id)
|
||||||
|
.with_for_update()
|
||||||
|
).scalar_one()
|
||||||
|
_verify_operation_fence(session, locked, lease_claim)
|
||||||
|
observed_at = _as_utc(now or utcnow())
|
||||||
|
sequence = int(locked.checkpoint_count or 0) + 1
|
||||||
|
payload = {
|
||||||
|
"operation_id": locked.id,
|
||||||
|
"sequence": sequence,
|
||||||
|
"status": locked.status,
|
||||||
|
"kind": kind,
|
||||||
|
"summary": summary,
|
||||||
|
"evidence": evidence,
|
||||||
|
"previous_sha256": locked.evidence_head_sha256,
|
||||||
|
"created_at": observed_at.isoformat(),
|
||||||
|
}
|
||||||
|
checkpoint_hash = _canonical_sha256(payload)
|
||||||
|
checkpoint = RecoveryCheckpoint(
|
||||||
|
operation_id=locked.id,
|
||||||
|
sequence=sequence,
|
||||||
|
status=locked.status,
|
||||||
|
kind=kind,
|
||||||
|
summary=summary,
|
||||||
|
evidence=dict(evidence),
|
||||||
|
previous_sha256=locked.evidence_head_sha256,
|
||||||
|
checkpoint_sha256=checkpoint_hash,
|
||||||
|
created_at=observed_at,
|
||||||
|
)
|
||||||
|
locked.checkpoint_count = sequence
|
||||||
|
locked.evidence_head_sha256 = checkpoint_hash
|
||||||
|
session.add(locked)
|
||||||
|
session.add(checkpoint)
|
||||||
|
session.flush()
|
||||||
|
return checkpoint
|
||||||
|
|
||||||
|
|
||||||
|
def verify_recovery_evidence_chain(
|
||||||
|
session: Session,
|
||||||
|
operation_id: str,
|
||||||
|
) -> bool:
|
||||||
|
operation = session.get(RecoveryOperation, operation_id)
|
||||||
|
if operation is None:
|
||||||
|
raise RecoveryGuaranteeError("Recovery operation was not found")
|
||||||
|
checkpoints = (
|
||||||
|
session.execute(
|
||||||
|
select(RecoveryCheckpoint)
|
||||||
|
.where(RecoveryCheckpoint.operation_id == operation_id)
|
||||||
|
.order_by(RecoveryCheckpoint.sequence)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
previous: str | None = None
|
||||||
|
for expected_sequence, checkpoint in enumerate(checkpoints, start=1):
|
||||||
|
if (
|
||||||
|
checkpoint.sequence != expected_sequence
|
||||||
|
or checkpoint.previous_sha256 != previous
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
payload = {
|
||||||
|
"operation_id": checkpoint.operation_id,
|
||||||
|
"sequence": checkpoint.sequence,
|
||||||
|
"status": checkpoint.status,
|
||||||
|
"kind": checkpoint.kind,
|
||||||
|
"summary": checkpoint.summary,
|
||||||
|
"evidence": checkpoint.evidence,
|
||||||
|
"previous_sha256": checkpoint.previous_sha256,
|
||||||
|
"created_at": _as_utc(checkpoint.created_at).isoformat(),
|
||||||
|
}
|
||||||
|
if _canonical_sha256(payload) != checkpoint.checkpoint_sha256:
|
||||||
|
return False
|
||||||
|
previous = checkpoint.checkpoint_sha256
|
||||||
|
return (
|
||||||
|
len(checkpoints) == int(operation.checkpoint_count or 0)
|
||||||
|
and previous == operation.evidence_head_sha256
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def operation_recovery_action(mode: RecoveryMode) -> RecoveryStatus:
|
||||||
|
if mode == RecoveryMode.ATOMIC:
|
||||||
|
return RecoveryStatus.FAILED
|
||||||
|
if mode in {
|
||||||
|
RecoveryMode.COMPENSATION,
|
||||||
|
RecoveryMode.SNAPSHOT_RESTORE,
|
||||||
|
RecoveryMode.FORWARD_RECOVERY,
|
||||||
|
}:
|
||||||
|
return RecoveryStatus.RECOVERY_REQUIRED
|
||||||
|
return RecoveryStatus.MANUAL_INTERVENTION
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_mode_transition(
|
||||||
|
operation: RecoveryOperation,
|
||||||
|
status: RecoveryStatus,
|
||||||
|
) -> None:
|
||||||
|
mode = RecoveryMode(operation.mode)
|
||||||
|
if (
|
||||||
|
operation.status == RecoveryStatus.RUNNING.value
|
||||||
|
and status == RecoveryStatus.FAILED
|
||||||
|
and mode != RecoveryMode.ATOMIC
|
||||||
|
):
|
||||||
|
raise RecoveryGuaranteeError(
|
||||||
|
"A non-atomic operation cannot be marked failed after it starts; "
|
||||||
|
"record recovery_required, outcome_unknown, or manual_intervention"
|
||||||
|
)
|
||||||
|
if mode == RecoveryMode.ATOMIC and status in {
|
||||||
|
RecoveryStatus.RECOVERY_REQUIRED,
|
||||||
|
RecoveryStatus.RECOVERING,
|
||||||
|
RecoveryStatus.RECOVERED,
|
||||||
|
}:
|
||||||
|
raise RecoveryGuaranteeError(
|
||||||
|
"Atomic operations must roll back in their transaction instead of entering recovery"
|
||||||
|
)
|
||||||
|
if mode == RecoveryMode.IRREVERSIBLE and status in {
|
||||||
|
RecoveryStatus.RECOVERY_REQUIRED,
|
||||||
|
RecoveryStatus.RECOVERING,
|
||||||
|
RecoveryStatus.RECOVERED,
|
||||||
|
}:
|
||||||
|
raise RecoveryGuaranteeError(
|
||||||
|
"Irreversible operations cannot claim automated recovery"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_transition_evidence(
|
||||||
|
*,
|
||||||
|
status: RecoveryStatus,
|
||||||
|
evidence: dict[str, Any],
|
||||||
|
failure_summary: str | None,
|
||||||
|
) -> None:
|
||||||
|
if status in {RecoveryStatus.SUCCEEDED, RecoveryStatus.RECOVERED}:
|
||||||
|
checks = evidence.get("checks")
|
||||||
|
if (
|
||||||
|
evidence.get("verified") is not True
|
||||||
|
or not isinstance(
|
||||||
|
checks,
|
||||||
|
(dict, list),
|
||||||
|
)
|
||||||
|
or not checks
|
||||||
|
):
|
||||||
|
raise RecoveryGuaranteeError(
|
||||||
|
"Successful recovery transitions require verified evidence and check results"
|
||||||
|
)
|
||||||
|
if status == RecoveryStatus.MANUAL_INTERVENTION and not failure_summary:
|
||||||
|
raise RecoveryGuaranteeError(
|
||||||
|
"Manual intervention requires an operator-facing failure summary"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_operation_fence(
|
||||||
|
session: Session,
|
||||||
|
operation: RecoveryOperation,
|
||||||
|
lease_claim: LeaseClaim | None,
|
||||||
|
) -> None:
|
||||||
|
if operation.lease_resource_key is None:
|
||||||
|
return
|
||||||
|
if lease_claim is None:
|
||||||
|
raise RecoveryGuaranteeError(
|
||||||
|
"This recovery operation requires its distributed lease fence"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
lease_claim.resource_key != operation.lease_resource_key
|
||||||
|
or lease_claim.holder_node_id != operation.holder_node_id
|
||||||
|
or lease_claim.holder_incarnation != operation.holder_incarnation
|
||||||
|
or lease_claim.fencing_token != operation.fencing_token
|
||||||
|
):
|
||||||
|
raise RecoveryGuaranteeError(
|
||||||
|
"Recovery operation lease does not match its recorded fence"
|
||||||
|
)
|
||||||
|
assert_lease_fence(session, lease_claim)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_sha256(value: dict[str, Any]) -> str:
|
||||||
|
encoded = json.dumps(
|
||||||
|
value,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=False,
|
||||||
|
default=str,
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _as_utc(value: datetime) -> datetime:
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"RecoveryCheckpoint",
|
||||||
|
"RecoveryGuaranteeError",
|
||||||
|
"RecoveryIdempotencyConflict",
|
||||||
|
"RecoveryMode",
|
||||||
|
"RecoveryOperation",
|
||||||
|
"RecoveryPlan",
|
||||||
|
"RecoveryStatus",
|
||||||
|
"TERMINAL_RECOVERY_STATUSES",
|
||||||
|
"operation_recovery_action",
|
||||||
|
"plan_recovery_operation",
|
||||||
|
"prepare_recovery_operation",
|
||||||
|
"record_recovery_checkpoint",
|
||||||
|
"start_recovery_operation",
|
||||||
|
"transition_recovery_operation",
|
||||||
|
"verify_recovery_evidence_chain",
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user