Compare commits
82
Commits
c4b90181e0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
+7
-1
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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,13 +157,15 @@ 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,notifications,calendar,default` | Queue list expected by worker/process manager definitions. The Calendar queue drains durable external-calendar operations. |
|
| `CELERY_QUEUES` | `send_email,append_sent,notifications,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. |
|
||||||
|
| `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,notifications,calendar,default \
|
--queues send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default \
|
||||||
--loglevel INFO
|
--loglevel INFO
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -183,6 +189,11 @@ python -m celery -A govoplan_core.celery_app:celery beat --loglevel INFO
|
|||||||
| `FILE_STORAGE_S3_ACCESS_KEY_ID` | empty | Secret; inject through deployment environment. |
|
| `FILE_STORAGE_S3_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_*`.
|
||||||
@@ -205,9 +216,13 @@ prefer `FILE_STORAGE_*`.
|
|||||||
Interactive password login is enabled with fixed-window limits of 10 failures
|
Interactive password login is enabled with fixed-window limits of 10 failures
|
||||||
per normalized identity and 100 failures per direct client over 900 seconds.
|
per normalized identity and 100 failures per direct client over 900 seconds.
|
||||||
`AUTH_LOGIN_THROTTLE_*` settings change those limits. Counters use `REDIS_URL`
|
`AUTH_LOGIN_THROTTLE_*` settings change those limits. Counters use `REDIS_URL`
|
||||||
when Redis is reachable so replicas share state; a bounded process-local
|
when Redis is reachable so replicas share state. Production-like startup fails
|
||||||
fallback keeps development and Redis outages functional, with per-process
|
when throttling is enabled without `REDIS_URL`. Set
|
||||||
enforcement until Redis recovers.
|
`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
|
### Outbound Connector Egress
|
||||||
|
|
||||||
@@ -328,7 +343,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,notifications,calendar,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`
|
||||||
|
|
||||||
@@ -447,7 +462,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
|
||||||
|
|||||||
@@ -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`.
|
||||||
+110
-95
@@ -10,12 +10,15 @@ gates. Issues are the active backlog; this document is durable architecture
|
|||||||
planning context and should be mirrored to the Gitea wiki.
|
planning context and should be mirrored to the Gitea wiki.
|
||||||
|
|
||||||
The meta repository's
|
The meta repository's
|
||||||
[Connected Governance Platform Roadmap](https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
|
[Connected Governance Platform Roadmap](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
|
||||||
describes the corresponding cross-product stakeholder visions, configurable
|
describes the corresponding cross-product stakeholder visions, configurable
|
||||||
service and operating configurations, connected outcome stories, and
|
service and operating configurations, connected outcome stories, and
|
||||||
capability horizons. The selected five-stage delivery sequence and its gates
|
capability horizons. The selected five-stage delivery sequence and its gates
|
||||||
are in the meta repository's
|
are in the meta repository's
|
||||||
[Reference Journey Program](https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/REFERENCE_JOURNEY_PROGRAM.md).
|
[Reference Journey Program](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/REFERENCE_JOURNEY_PROGRAM.md).
|
||||||
|
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
|
Those product documents are canonical; this Core roadmap remains their
|
||||||
technical sequencing and module-routing companion.
|
technical sequencing and module-routing companion.
|
||||||
|
|
||||||
@@ -64,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`.
|
||||||
@@ -144,8 +148,9 @@ 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` |
|
||||||
@@ -153,13 +158,18 @@ pattern exists.
|
|||||||
| Organizational structures, units, and functions | `govoplan-organizations` |
|
| Organizational structures, units, and functions | `govoplan-organizations` |
|
||||||
| Identity-to-function assignments and directory synchronization | `govoplan-idm` |
|
| 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` |
|
||||||
| Authentication projection, roles, permissions, acting context | `govoplan-access` |
|
| Authentication projection, roles, permissions, acting context | `govoplan-access` |
|
||||||
| Tenants, policy, and audit | `govoplan-tenancy`, `govoplan-policy`, `govoplan-audit` |
|
| 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
|
||||||
@@ -204,8 +214,9 @@ an editor applies a high-impact configuration change.
|
|||||||
|
|
||||||
## Reference Journeys
|
## Reference Journeys
|
||||||
|
|
||||||
The active sequence is selected. Workflow remains deliberately deferred and is
|
The active sequence is selected. Workflow Engine and its optional editor are
|
||||||
not a dependency of these journeys.
|
now available foundations, but a reference journey does not depend on Workflow
|
||||||
|
unless its package explicitly composes and proves it.
|
||||||
|
|
||||||
### Journey 1: Campaign Demonstration Composition
|
### Journey 1: Campaign Demonstration Composition
|
||||||
|
|
||||||
@@ -256,9 +267,11 @@ The result must preserve official-key mappings, organizational and reporting
|
|||||||
date semantics, quality findings, quarantine/replay, transparent calculation,
|
date semantics, quality findings, quarantine/replay, transparent calculation,
|
||||||
and reproducible promotion between development, test, and production.
|
and reproducible promotion between development, test, and production.
|
||||||
|
|
||||||
Reporting consumes the product. Create `govoplan-datasources` or
|
Reporting consumes the product. Datasources owns the governed source and
|
||||||
`govoplan-dataflow` only after the concrete path proves repeated ownership that
|
materialization lifecycle; Dataflow owns typed transformation/run lineage;
|
||||||
does not belong to connectors, Reporting, or the producing domain module.
|
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.
|
||||||
|
|
||||||
### Journey 5: Collaborative Document Lifecycle
|
### Journey 5: Collaborative Document Lifecycle
|
||||||
|
|
||||||
@@ -336,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
|
||||||
@@ -412,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.
|
||||||
@@ -526,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
|
||||||
@@ -591,23 +608,25 @@ in the capability waves:
|
|||||||
4. Implement one data-backed Templates/Reporting path and safe HIS-style deep
|
4. Implement one data-backed Templates/Reporting path and safe HIS-style deep
|
||||||
launch.
|
launch.
|
||||||
5. Extend that concrete source into one governed university analytical data
|
5. Extend that concrete source into one governed university analytical data
|
||||||
product before generalizing data-source or dataflow ownership.
|
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
|
6. Implement Files-backed DMS versions and one provider-neutral collaborative
|
||||||
editing lifecycle, then connect Records handoff.
|
editing lifecycle, then connect Records handoff.
|
||||||
7. Maintain already integrated Calendar/Scheduling/Poll and other foundations;
|
7. Maintain already integrated Calendar/Scheduling/Poll and other foundations;
|
||||||
activate another capability cluster only when the current journey needs it
|
activate another capability cluster only when the current journey needs it
|
||||||
or the product roadmap explicitly reprioritizes it.
|
or the product roadmap explicitly reprioritizes it.
|
||||||
8. Resume Workflow only by explicit product decision and constrain it with
|
8. Extend Workflow Engine and the optional editor only through stable actions
|
||||||
stable actions from one demonstrated package.
|
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
|
||||||
- an unbounded general-purpose dataflow platform; the bounded governed BI
|
boundary is proved in a reference journey
|
||||||
reference journey is selected
|
- 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
|
||||||
@@ -625,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
|
||||||
@@ -705,7 +720,7 @@ Release composition and tag-only repository handling are documented in
|
|||||||
## Next Practical Work
|
## Next Practical Work
|
||||||
|
|
||||||
The active cross-product story is
|
The active cross-product story is
|
||||||
[`add-ideas/govoplan#14`](https://git.add-ideas.de/add-ideas/govoplan/issues/14).
|
[`GovOPlaN/govoplan#14`](https://git.add-ideas.de/GovOPlaN/govoplan/issues/14).
|
||||||
Module repositories own implementation issues; do not clone their state here.
|
Module repositories own implementation issues; do not clone their state here.
|
||||||
|
|
||||||
Immediate issue buckets:
|
Immediate issue buckets:
|
||||||
|
|||||||
@@ -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.
|
||||||
+242
-4
@@ -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:
|
||||||
|
|
||||||
@@ -134,6 +211,16 @@ Other stable runtime capabilities currently include:
|
|||||||
- `calendar.outbox` and `calendar.scheduling`
|
- `calendar.outbox` and `calendar.scheduling`
|
||||||
- `poll.scheduling`
|
- `poll.scheduling`
|
||||||
- `notifications.dispatch`
|
- `notifications.dispatch`
|
||||||
|
- `workflow.definitionContributions` and `workflow.runtimeWorker`
|
||||||
|
|
||||||
|
Modules contribute reusable process baselines through
|
||||||
|
`ModuleManifest.workflow_definitions`. Each contribution pins its origin module
|
||||||
|
and version, stable key, schema and content hash, native graph/BPMN content,
|
||||||
|
governance ceilings, execution mode, and required capabilities/interfaces.
|
||||||
|
`govoplan-workflow-engine` reconciles these declarations idempotently. A module
|
||||||
|
upgrade appends a baseline revision without replacing the active revision or
|
||||||
|
mutating a local override; the optional `govoplan-workflow` package supplies
|
||||||
|
the comparison, derivation, and reset UI.
|
||||||
|
|
||||||
### Named Interface Contracts
|
### Named Interface Contracts
|
||||||
|
|
||||||
@@ -156,6 +243,45 @@ 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.
|
||||||
|
|
||||||
|
### Source Authority And Provider Operations
|
||||||
|
|
||||||
|
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
|
Current named interfaces, generated from the source manifests by the workspace
|
||||||
contract checks, are:
|
contract checks, are:
|
||||||
|
|
||||||
@@ -263,6 +389,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
|
||||||
@@ -333,6 +476,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,
|
||||||
@@ -453,6 +621,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
|
||||||
@@ -515,6 +695,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
|
||||||
@@ -573,11 +786,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",
|
||||||
@@ -592,6 +812,11 @@ export const filesModule: PlatformWebModule = {
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Route pages and substantial panels must use stable lazy imports. Core supplies
|
||||||
|
the shared loading and retryable error state around route rendering. The
|
||||||
|
initial static import closure and largest asynchronous chunk are enforced by
|
||||||
|
the budgets documented in [WEBUI_BUNDLE_BUDGETS.md](WEBUI_BUNDLE_BUDGETS.md).
|
||||||
|
|
||||||
WebUI modules receive only the core route context:
|
WebUI modules receive only the core route context:
|
||||||
|
|
||||||
- `settings`
|
- `settings`
|
||||||
@@ -809,7 +1034,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
|
||||||
@@ -872,7 +1097,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
|
||||||
@@ -885,6 +1110,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`,
|
||||||
@@ -1129,6 +1364,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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -111,6 +112,20 @@ matching compatibility DTOs only for its legacy admin surface. Admin overview
|
|||||||
responses remain module-local because the same counters are exposed from
|
responses remain module-local because the same counters are exposed from
|
||||||
different menu contexts and are not yet a separately versioned platform API.
|
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,26 +40,26 @@ cd /mnt/DATA/git/govoplan
|
|||||||
Update those refs when cutting a release:
|
Update those refs when cutting a release:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
govoplan-tenancy git@git.add-ideas.de:add-ideas/govoplan-tenancy.git v0.1.8
|
govoplan-tenancy git@git.add-ideas.de:GovOPlaN/govoplan-tenancy.git v0.1.8
|
||||||
govoplan-organizations git@git.add-ideas.de:add-ideas/govoplan-organizations.git v0.1.8
|
govoplan-organizations git@git.add-ideas.de:GovOPlaN/govoplan-organizations.git v0.1.8
|
||||||
govoplan-identity git@git.add-ideas.de:add-ideas/govoplan-identity.git v0.1.8
|
govoplan-identity git@git.add-ideas.de:GovOPlaN/govoplan-identity.git v0.1.8
|
||||||
govoplan-idm git@git.add-ideas.de:add-ideas/govoplan-idm.git v0.1.8
|
govoplan-idm git@git.add-ideas.de:GovOPlaN/govoplan-idm.git v0.1.8
|
||||||
govoplan-access git@git.add-ideas.de:add-ideas/govoplan-access.git v0.1.8
|
govoplan-access git@git.add-ideas.de:GovOPlaN/govoplan-access.git v0.1.8
|
||||||
govoplan-admin git@git.add-ideas.de:add-ideas/govoplan-admin.git v0.1.8
|
govoplan-admin git@git.add-ideas.de:GovOPlaN/govoplan-admin.git v0.1.8
|
||||||
govoplan-policy git@git.add-ideas.de:add-ideas/govoplan-policy.git v0.1.8
|
govoplan-policy git@git.add-ideas.de:GovOPlaN/govoplan-policy.git v0.1.8
|
||||||
govoplan-audit git@git.add-ideas.de:add-ideas/govoplan-audit.git v0.1.8
|
govoplan-audit git@git.add-ideas.de:GovOPlaN/govoplan-audit.git v0.1.8
|
||||||
govoplan-dashboard git@git.add-ideas.de:add-ideas/govoplan-dashboard.git v0.1.8
|
govoplan-dashboard git@git.add-ideas.de:GovOPlaN/govoplan-dashboard.git v0.1.8
|
||||||
govoplan-addresses git@git.add-ideas.de:add-ideas/govoplan-addresses.git v0.1.8
|
govoplan-addresses git@git.add-ideas.de:GovOPlaN/govoplan-addresses.git v0.1.8
|
||||||
govoplan-files git@git.add-ideas.de:add-ideas/govoplan-files.git v0.1.8
|
govoplan-files git@git.add-ideas.de:GovOPlaN/govoplan-files.git v0.1.8
|
||||||
govoplan-mail git@git.add-ideas.de:add-ideas/govoplan-mail.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:add-ideas/govoplan-campaign.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:add-ideas/govoplan-calendar.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:add-ideas/govoplan-poll.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:add-ideas/govoplan-scheduling.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:add-ideas/govoplan-notifications.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:add-ideas/govoplan-evaluation.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:add-ideas/govoplan-docs.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:add-ideas/govoplan-ops.git v0.1.8
|
govoplan-ops git@git.add-ideas.de:GovOPlaN/govoplan-ops.git v0.1.8
|
||||||
```
|
```
|
||||||
|
|
||||||
## WebUI Packages
|
## WebUI Packages
|
||||||
@@ -151,7 +151,8 @@ Current tag-only module repositories:
|
|||||||
- `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`
|
||||||
@@ -788,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
|
||||||
@@ -881,7 +899,7 @@ before that baseline, so pre-v0.1.7 development revisions are not release
|
|||||||
upgrade targets. Future release-to-release changes must start from a recorded
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
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 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
@@ -46,6 +46,10 @@ contestability, responsibility, and traceability at the point of action.
|
|||||||
| UX-020 | Centrally exported Core components are mandatory wherever their contract covers the interaction. A custom reusable control, presentation primitive, or module-local substitute requires explicit product-owner authorization, a narrowly specific purpose, and documented rationale and scope; it must not duplicate a central component. | Accepted | Core WebUI and all module WebUIs |
|
| UX-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-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-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
|
||||||
|
|
||||||
@@ -191,9 +195,10 @@ explicitly retains the exception.
|
|||||||
Decision: Scheduling requests provide a concrete reference application of the
|
Decision: Scheduling requests provide a concrete reference application of the
|
||||||
universal placement and component rules.
|
universal placement and component rules.
|
||||||
|
|
||||||
- The `Scheduling requests` header owns one `Add` action.
|
- The persistent left panel stacks `My scheduling requests` and `Scheduling
|
||||||
- Its left panel is used for the creation view only, not as a permanent second
|
requests for me`; it is list context, not a second creation affordance.
|
||||||
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
|
- Basic information, Calendar integration, candidate slots, and participants
|
||||||
use the central `Card` component as four logical sections.
|
use the central `Card` component as four logical sections.
|
||||||
- Candidate slots and participants use the central `DataGrid`, including its
|
- Candidate slots and participants use the central `DataGrid`, including its
|
||||||
@@ -207,6 +212,78 @@ universal placement and component rules.
|
|||||||
Equivalent list/create/edit surfaces use the same underlying rules. These are
|
Equivalent list/create/edit surfaces use the same underlying rules. These are
|
||||||
not Scheduling-local component variants.
|
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 |
|
||||||
@@ -277,6 +354,14 @@ Every new or changed admin/configuration surface should answer:
|
|||||||
- Are logical sections, tabular collections, boolean settings, and repeatable
|
- Are logical sections, tabular collections, boolean settings, and repeatable
|
||||||
people/contact rows composed with `Card`, `DataGrid`, `ToggleSwitch`, and one
|
people/contact rows composed with `Card`, `DataGrid`, `ToggleSwitch`, and one
|
||||||
structured row per person respectively?
|
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.
|
||||||
@@ -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,8 @@
|
|||||||
],
|
],
|
||||||
"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": [
|
"heads": [
|
||||||
@@ -165,8 +165,504 @@
|
|||||||
],
|
],
|
||||||
"recorded_at": "2026-07-20T02:45:41Z",
|
"recorded_at": "2026-07-20T02:45:41Z",
|
||||||
"release": "0.1.8",
|
"release": "0.1.8",
|
||||||
"track": "release",
|
"squash_policy": "reviewed-manual",
|
||||||
"squash_policy": "reviewed-manual"
|
"track": "release"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revision": "4d5e6f7a9203"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revision": "608192abcdef"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revision": "6e7f8a9b0c1d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revision": "6f7a8b9c0d1e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revision": "8f9a0b1c2d3e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revision": "a7b8c9d0e1f3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revision": "af1b2c3d4e5f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revision": "c9d4e7f1a2b3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revision": "e1f2a4b5c6d"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"owner_heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-access",
|
||||||
|
"revisions": [
|
||||||
|
"4a5b6c7d8e9f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revisions": [
|
||||||
|
"e1f2a4b5c6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revisions": [
|
||||||
|
"af1b2c3d4e5f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revisions": [
|
||||||
|
"4d5e6f7a9203"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-core",
|
||||||
|
"revisions": [
|
||||||
|
"4f2a9c8e7b6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revisions": [
|
||||||
|
"a7b8c9d0e1f3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-identity",
|
||||||
|
"revisions": [
|
||||||
|
"5c6d7e8f9a10"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revisions": [
|
||||||
|
"8f9a0b1c2d3e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revisions": [
|
||||||
|
"608192abcdef"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revisions": [
|
||||||
|
"6f7a8b9c0d1e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-organizations",
|
||||||
|
"revisions": [
|
||||||
|
"6d7e8f9a0b1c"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revisions": [
|
||||||
|
"6e7f8a9b0c1d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revisions": [
|
||||||
|
"c9d4e7f1a2b3"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recorded_at": "2026-07-22T02:42:27Z",
|
||||||
|
"release": "0.1.11",
|
||||||
|
"squash_policy": "reviewed-manual",
|
||||||
|
"track": "release"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revision": "608192abcdef"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revision": "6e7f8a9b0c1d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revision": "6f7a8b9c0d1e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revision": "8f9a0b1c2d3e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revision": "a7b8c9d0e1f3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revision": "af1b2c3d4e5f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revision": "c9d4e7f1a2b3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revision": "d8b3e2c1f4a5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revision": "e1f2a4b5c6d"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"owner_heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-access",
|
||||||
|
"revisions": [
|
||||||
|
"4a5b6c7d8e9f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revisions": [
|
||||||
|
"e1f2a4b5c6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revisions": [
|
||||||
|
"af1b2c3d4e5f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revisions": [
|
||||||
|
"d8b3e2c1f4a5"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-core",
|
||||||
|
"revisions": [
|
||||||
|
"4f2a9c8e7b6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revisions": [
|
||||||
|
"a7b8c9d0e1f3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-identity",
|
||||||
|
"revisions": [
|
||||||
|
"5c6d7e8f9a10"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revisions": [
|
||||||
|
"8f9a0b1c2d3e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revisions": [
|
||||||
|
"608192abcdef"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revisions": [
|
||||||
|
"6f7a8b9c0d1e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-organizations",
|
||||||
|
"revisions": [
|
||||||
|
"6d7e8f9a0b1c"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revisions": [
|
||||||
|
"6e7f8a9b0c1d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revisions": [
|
||||||
|
"c9d4e7f1a2b3"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recorded_at": "2026-07-22T07:06:21Z",
|
||||||
|
"release": "0.1.12",
|
||||||
|
"squash_policy": "reviewed-manual",
|
||||||
|
"track": "release"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revision": "608192abcdef"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revision": "6e7f8a9b0c1d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revision": "6f7a8b9c0d1e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revision": "8f9a0b1c2d3e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revision": "a7b8c9d0e1f3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revision": "af1b2c3d4e5f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revision": "c9d4e7f1a2b3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revision": "d8b3e2c1f4a5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revision": "e1f2a4b5c6d"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"owner_heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-access",
|
||||||
|
"revisions": [
|
||||||
|
"4a5b6c7d8e9f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revisions": [
|
||||||
|
"e1f2a4b5c6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revisions": [
|
||||||
|
"af1b2c3d4e5f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revisions": [
|
||||||
|
"d8b3e2c1f4a5"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-core",
|
||||||
|
"revisions": [
|
||||||
|
"4f2a9c8e7b6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revisions": [
|
||||||
|
"a7b8c9d0e1f3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-identity",
|
||||||
|
"revisions": [
|
||||||
|
"5c6d7e8f9a10"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revisions": [
|
||||||
|
"8f9a0b1c2d3e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revisions": [
|
||||||
|
"608192abcdef"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revisions": [
|
||||||
|
"6f7a8b9c0d1e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-organizations",
|
||||||
|
"revisions": [
|
||||||
|
"6d7e8f9a0b1c"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revisions": [
|
||||||
|
"6e7f8a9b0c1d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revisions": [
|
||||||
|
"c9d4e7f1a2b3"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recorded_at": "2026-07-22T08:39:02Z",
|
||||||
|
"release": "0.1.13",
|
||||||
|
"squash_policy": "reviewed-manual",
|
||||||
|
"track": "release"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revision": "608192abcdef"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revision": "6e7f8a9b0c1d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revision": "6f7a8b9c0d1e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revision": "8f9a0b1c2d3e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revision": "a7b8c9d0e1f3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revision": "af1b2c3d4e5f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revision": "c9d4e7f1a2b3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revision": "d8b3e2c1f4a5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revision": "e1f2a4b5c6d"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"owner_heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-access",
|
||||||
|
"revisions": [
|
||||||
|
"4a5b6c7d8e9f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revisions": [
|
||||||
|
"e1f2a4b5c6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revisions": [
|
||||||
|
"af1b2c3d4e5f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revisions": [
|
||||||
|
"d8b3e2c1f4a5"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-core",
|
||||||
|
"revisions": [
|
||||||
|
"4f2a9c8e7b6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revisions": [
|
||||||
|
"a7b8c9d0e1f3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-identity",
|
||||||
|
"revisions": [
|
||||||
|
"5c6d7e8f9a10"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revisions": [
|
||||||
|
"8f9a0b1c2d3e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revisions": [
|
||||||
|
"608192abcdef"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revisions": [
|
||||||
|
"6f7a8b9c0d1e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-organizations",
|
||||||
|
"revisions": [
|
||||||
|
"6d7e8f9a0b1c"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revisions": [
|
||||||
|
"6e7f8a9b0c1d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revisions": [
|
||||||
|
"c9d4e7f1a2b3"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recorded_at": "2026-07-22T18:15:01Z",
|
||||||
|
"release": "0.1.14",
|
||||||
|
"squash_policy": "reviewed-manual",
|
||||||
|
"track": "release"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version": 1
|
"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,9 +78,9 @@
|
|||||||
"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",
|
||||||
@@ -97,14 +97,14 @@
|
|||||||
],
|
],
|
||||||
"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",
|
||||||
@@ -121,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",
|
||||||
|
|||||||
+8
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-core"
|
name = "govoplan-core"
|
||||||
version = "0.1.9"
|
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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -160,6 +184,7 @@ 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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
+723
-15
@@ -1,17 +1,77 @@
|
|||||||
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
|
||||||
|
|
||||||
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider
|
from celery import Celery
|
||||||
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_OUTBOX, CalendarOutboxProvider
|
from celery.signals import heartbeat_sent, worker_ready, worker_shutdown
|
||||||
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
|
||||||
|
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.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.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)
|
||||||
|
|
||||||
@@ -28,7 +88,18 @@ celery.conf.update(
|
|||||||
"govoplan.campaigns.append_sent": {"queue": "append_sent"},
|
"govoplan.campaigns.append_sent": {"queue": "append_sent"},
|
||||||
"govoplan.notifications.deliver": {"queue": "notifications"},
|
"govoplan.notifications.deliver": {"queue": "notifications"},
|
||||||
"govoplan.notifications.deliver_pending": {"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.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,
|
||||||
@@ -39,9 +110,177 @@ celery.conf.update(
|
|||||||
"schedule": 60.0,
|
"schedule": 60.0,
|
||||||
"args": (None, 100),
|
"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
|
||||||
|
|
||||||
|
|
||||||
|
@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={"process": "celery-worker"},
|
||||||
|
)
|
||||||
|
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={"process": "celery-worker"},
|
||||||
|
)
|
||||||
|
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="govoplan.ping")
|
@celery.task(name="govoplan.ping")
|
||||||
def ping():
|
def ping():
|
||||||
@@ -50,9 +289,15 @@ def ping():
|
|||||||
|
|
||||||
def _platform_registry() -> PlatformRegistry:
|
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)
|
||||||
@@ -86,6 +331,110 @@ def _calendar_outbox() -> CalendarOutboxProvider | None:
|
|||||||
return capability
|
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)
|
@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.
|
||||||
@@ -98,7 +447,11 @@ 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="govoplan.campaigns.append_sent", bind=True, max_retries=None)
|
@celery.task(name="govoplan.campaigns.append_sent", bind=True, max_retries=None)
|
||||||
@@ -109,7 +462,9 @@ 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)
|
||||||
@@ -121,7 +476,11 @@ def deliver_notification(self, notification_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:
|
||||||
result = dict(_notification_dispatch().deliver_notification(session, notification_id=notification_id))
|
result = dict(
|
||||||
|
_notification_dispatch().deliver_notification(
|
||||||
|
session, notification_id=notification_id
|
||||||
|
)
|
||||||
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -131,7 +490,80 @@ def deliver_pending_notifications(self, tenant_id: str | None = None, limit: int
|
|||||||
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:
|
||||||
result = dict(_notification_dispatch().deliver_pending(session, tenant_id=tenant_id, limit=limit))
|
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()
|
session.commit()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -145,7 +577,283 @@ def dispatch_calendar_outbox(self, tenant_id: str | None = None, limit: int = 50
|
|||||||
with get_database().SessionLocal() as session:
|
with get_database().SessionLocal() as session:
|
||||||
provider = _calendar_outbox()
|
provider = _calendar_outbox()
|
||||||
if provider is None:
|
if provider is None:
|
||||||
return {"processed": 0, "succeeded": 0, "retrying": 0, "failed": 0, "operations": []}
|
return {
|
||||||
|
"processed": 0,
|
||||||
|
"succeeded": 0,
|
||||||
|
"retrying": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"operations": [],
|
||||||
|
}
|
||||||
result = dict(provider.dispatch_due(session, tenant_id=tenant_id, limit=limit))
|
result = dict(provider.dispatch_due(session, tenant_id=tenant_id, limit=limit))
|
||||||
session.commit()
|
session.commit()
|
||||||
return result
|
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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -8,6 +8,7 @@ from typing import Protocol, runtime_checkable
|
|||||||
|
|
||||||
CAPABILITY_CALENDAR_SCHEDULING = "calendar.scheduling"
|
CAPABILITY_CALENDAR_SCHEDULING = "calendar.scheduling"
|
||||||
CAPABILITY_CALENDAR_OUTBOX = "calendar.outbox"
|
CAPABILITY_CALENDAR_OUTBOX = "calendar.outbox"
|
||||||
|
CAPABILITY_CALENDAR_INVITATIONS = "calendar.invitations"
|
||||||
CALENDAR_AVAILABILITY_READ_SCOPE = "calendar:availability:read"
|
CALENDAR_AVAILABILITY_READ_SCOPE = "calendar:availability:read"
|
||||||
CALENDAR_EVENT_WRITE_SCOPE = "calendar:event:write"
|
CALENDAR_EVENT_WRITE_SCOPE = "calendar:event:write"
|
||||||
|
|
||||||
@@ -43,6 +44,52 @@ class CalendarEventRef:
|
|||||||
outbox_operation_id: str | None = None
|
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, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class CalendarSchedulingProvider(Protocol):
|
class CalendarSchedulingProvider(Protocol):
|
||||||
def list_freebusy(
|
def list_freebusy(
|
||||||
@@ -80,6 +127,44 @@ class CalendarOutboxProvider(Protocol):
|
|||||||
) -> Mapping[str, object]:
|
) -> Mapping[str, object]:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class CalendarInvitationProvider(Protocol):
|
||||||
|
"""Correlation-aware invitation boundary for Campaign and Mail adapters."""
|
||||||
|
|
||||||
|
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 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 calendar_scheduling_provider(registry: object | None) -> CalendarSchedulingProvider | None:
|
def calendar_scheduling_provider(registry: object | None) -> CalendarSchedulingProvider | None:
|
||||||
if registry is None or not hasattr(registry, "has_capability"):
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
return None
|
return None
|
||||||
@@ -96,3 +181,14 @@ def calendar_outbox_provider(registry: object | None) -> CalendarOutboxProvider
|
|||||||
return None
|
return None
|
||||||
capability = registry.capability(CAPABILITY_CALENDAR_OUTBOX)
|
capability = registry.capability(CAPABILITY_CALENDAR_OUTBOX)
|
||||||
return capability if isinstance(capability, CalendarOutboxProvider) else None
|
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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -7,7 +7,8 @@ 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
|
||||||
|
from typing import Any, Literal, Protocol, cast, runtime_checkable
|
||||||
|
|
||||||
from govoplan_core.core.module_package_catalog import (
|
from govoplan_core.core.module_package_catalog import (
|
||||||
_canonical_catalog_bytes,
|
_canonical_catalog_bytes,
|
||||||
@@ -21,6 +22,12 @@ from govoplan_core.core.module_package_catalog import (
|
|||||||
_load_private_key,
|
_load_private_key,
|
||||||
_parse_trusted_keys,
|
_parse_trusted_keys,
|
||||||
)
|
)
|
||||||
|
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
|
from govoplan_core.security.http_fetch import fetch_http_text
|
||||||
|
|
||||||
|
|
||||||
@@ -28,6 +35,166 @@ 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)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -75,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
|
||||||
@@ -88,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":
|
||||||
@@ -95,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"),
|
||||||
@@ -108,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]:
|
||||||
@@ -115,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),
|
||||||
@@ -221,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
|
||||||
|
|
||||||
|
|
||||||
@@ -286,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)
|
||||||
@@ -369,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)
|
||||||
@@ -665,6 +887,154 @@ def record_configuration_package_catalog_acceptance(validation: dict[str, object
|
|||||||
state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def configuration_package_claim_issues(
|
||||||
|
manifest: ConfigurationPackageManifest,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
evidence_kinds = {item.kind for item in manifest.evidence}
|
||||||
|
required_evidence: dict[str, frozenset[str]] = {
|
||||||
|
"reference": frozenset(
|
||||||
|
{
|
||||||
|
"target_test",
|
||||||
|
"recovery",
|
||||||
|
"security",
|
||||||
|
"operations",
|
||||||
|
"accessibility",
|
||||||
|
"privacy",
|
||||||
|
"documentation",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"product": frozenset(),
|
||||||
|
"sector": frozenset({"documentation"}),
|
||||||
|
"deployment": frozenset(
|
||||||
|
{"target_test", "recovery", "security", "operations"}
|
||||||
|
),
|
||||||
|
"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:
|
||||||
if isinstance(package, ConfigurationPackageManifest):
|
if isinstance(package, ConfigurationPackageManifest):
|
||||||
return package
|
return package
|
||||||
@@ -716,6 +1086,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] = []
|
||||||
|
|||||||
@@ -301,6 +301,18 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
|||||||
maintenance_required=True,
|
maintenance_required=True,
|
||||||
notes="This deployment-wide egress boundary remains out of band and applies to every connector worker.",
|
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(
|
ConfigurationFieldSafety(
|
||||||
key="GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
|
key="GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
|
||||||
label="Structured connector response limit",
|
label="Structured connector response limit",
|
||||||
|
|||||||
@@ -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_")]
|
||||||
@@ -0,0 +1,692 @@
|
|||||||
|
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_ENCRYPTION_KEY_VAULT = "encryption.keyVault"
|
||||||
|
CAPABILITY_ENCRYPTION_CONTENT_PROTECTION = "encryption.contentProtection"
|
||||||
|
CAPABILITY_ENCRYPTION_RECOVERY = "encryption.recoveryCeremony"
|
||||||
|
CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT = "encryption.disablePreflight"
|
||||||
|
CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX = "encryption.keyMaterialProvider."
|
||||||
|
ENCRYPTION_CONTRACT_VERSION = "1"
|
||||||
|
|
||||||
|
ProtectionProfileKind = Literal[
|
||||||
|
"server_envelope",
|
||||||
|
"tenant_held",
|
||||||
|
"end_to_end",
|
||||||
|
]
|
||||||
|
KeyLifecycleState = Literal[
|
||||||
|
"active",
|
||||||
|
"rotation_pending",
|
||||||
|
"revoked",
|
||||||
|
"destruction_scheduled",
|
||||||
|
"destroyed",
|
||||||
|
"unavailable",
|
||||||
|
]
|
||||||
|
ProtectionState = Literal[
|
||||||
|
"active",
|
||||||
|
"migration_pending",
|
||||||
|
"migrating",
|
||||||
|
"migrated",
|
||||||
|
"decrypted",
|
||||||
|
"exported",
|
||||||
|
"destroyed",
|
||||||
|
"unavailable",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class KeyMaterialProvisionRequest:
|
||||||
|
tenant_id: str
|
||||||
|
vault_id: str
|
||||||
|
key_version: int
|
||||||
|
algorithm_suite: str
|
||||||
|
purpose: str
|
||||||
|
idempotency_key: str
|
||||||
|
provider_policy: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
import_reference: str | None = None
|
||||||
|
contract_version: str = ENCRYPTION_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract(self.contract_version)
|
||||||
|
if self.key_version < 1:
|
||||||
|
raise ValueError("Key version must be positive")
|
||||||
|
for label, value in (
|
||||||
|
("tenant id", self.tenant_id),
|
||||||
|
("vault id", self.vault_id),
|
||||||
|
("algorithm suite", self.algorithm_suite),
|
||||||
|
("purpose", self.purpose),
|
||||||
|
("idempotency key", self.idempotency_key),
|
||||||
|
):
|
||||||
|
_require_text(value, label)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class KeyMaterialDescriptor:
|
||||||
|
provider_id: str
|
||||||
|
provider_key_ref: str
|
||||||
|
algorithm_suite: str
|
||||||
|
state: KeyLifecycleState
|
||||||
|
created_at: datetime
|
||||||
|
public_key_ref: str | None = None
|
||||||
|
imported: bool = False
|
||||||
|
exportable: bool = False
|
||||||
|
provider_version: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
contract_version: str = ENCRYPTION_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract(self.contract_version)
|
||||||
|
for label, value in (
|
||||||
|
("provider id", self.provider_id),
|
||||||
|
("provider key reference", self.provider_key_ref),
|
||||||
|
("algorithm suite", self.algorithm_suite),
|
||||||
|
):
|
||||||
|
_require_text(value, label)
|
||||||
|
_reject_secret_mapping(self.provenance, "Provider provenance")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class KeyVaultCreateRequest:
|
||||||
|
tenant_id: str
|
||||||
|
vault_id: str
|
||||||
|
name: str
|
||||||
|
provider_id: str
|
||||||
|
purpose: str
|
||||||
|
algorithm_suite: str
|
||||||
|
scope_type: str
|
||||||
|
scope_id: str | None
|
||||||
|
policy_ref: str
|
||||||
|
idempotency_key: str
|
||||||
|
recovery_quorum: int = 2
|
||||||
|
profile_kind: ProtectionProfileKind = "server_envelope"
|
||||||
|
import_reference: str | None = None
|
||||||
|
provider_policy: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
contract_version: str = ENCRYPTION_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract(self.contract_version)
|
||||||
|
if self.recovery_quorum < 1:
|
||||||
|
raise ValueError("Recovery quorum must be positive")
|
||||||
|
for label, value in (
|
||||||
|
("tenant id", self.tenant_id),
|
||||||
|
("vault id", self.vault_id),
|
||||||
|
("name", self.name),
|
||||||
|
("provider id", self.provider_id),
|
||||||
|
("purpose", self.purpose),
|
||||||
|
("algorithm suite", self.algorithm_suite),
|
||||||
|
("scope type", self.scope_type),
|
||||||
|
("policy reference", self.policy_ref),
|
||||||
|
("idempotency key", self.idempotency_key),
|
||||||
|
):
|
||||||
|
_require_text(value, label)
|
||||||
|
_reject_secret_mapping(self.provider_policy, "Provider policy")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class KeyVersionRef:
|
||||||
|
tenant_id: str
|
||||||
|
vault_id: str
|
||||||
|
version: int
|
||||||
|
provider_id: str
|
||||||
|
provider_key_ref: str
|
||||||
|
algorithm_suite: str
|
||||||
|
state: KeyLifecycleState
|
||||||
|
created_at: datetime
|
||||||
|
activated_at: datetime | None = None
|
||||||
|
revoked_at: datetime | None = None
|
||||||
|
destruction_scheduled_at: datetime | None = None
|
||||||
|
destroyed_at: datetime | None = None
|
||||||
|
public_key_ref: str | None = None
|
||||||
|
imported: bool = False
|
||||||
|
exportable: bool = False
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
contract_version: str = ENCRYPTION_CONTRACT_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class KeyVaultRef:
|
||||||
|
tenant_id: str
|
||||||
|
vault_id: str
|
||||||
|
name: str
|
||||||
|
provider_id: str
|
||||||
|
purpose: str
|
||||||
|
profile_kind: ProtectionProfileKind
|
||||||
|
scope_type: str
|
||||||
|
scope_id: str | None
|
||||||
|
policy_ref: str
|
||||||
|
recovery_quorum: int
|
||||||
|
state: str
|
||||||
|
revision: int
|
||||||
|
current_key: KeyVersionRef | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
contract_version: str = ENCRYPTION_CONTRACT_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class KeyRotationRequest:
|
||||||
|
tenant_id: str
|
||||||
|
vault_id: str
|
||||||
|
expected_revision: int
|
||||||
|
reason: str
|
||||||
|
policy_decision_ref: str
|
||||||
|
assurance_evidence_ref: str
|
||||||
|
idempotency_key: str
|
||||||
|
algorithm_suite: str | None = None
|
||||||
|
provider_policy: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
contract_version: str = ENCRYPTION_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract(self.contract_version)
|
||||||
|
if self.expected_revision < 1:
|
||||||
|
raise ValueError("Expected revision must be positive")
|
||||||
|
for label, value in (
|
||||||
|
("tenant id", self.tenant_id),
|
||||||
|
("vault id", self.vault_id),
|
||||||
|
("reason", self.reason),
|
||||||
|
("policy decision reference", self.policy_decision_ref),
|
||||||
|
("assurance evidence reference", self.assurance_evidence_ref),
|
||||||
|
("idempotency key", self.idempotency_key),
|
||||||
|
):
|
||||||
|
_require_text(value, label)
|
||||||
|
_reject_secret_mapping(self.provider_policy, "Provider policy")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class KeyLifecycleRequest:
|
||||||
|
tenant_id: str
|
||||||
|
vault_id: str
|
||||||
|
key_version: int
|
||||||
|
expected_revision: int
|
||||||
|
reason: str
|
||||||
|
policy_decision_ref: str
|
||||||
|
assurance_evidence_ref: str
|
||||||
|
idempotency_key: str
|
||||||
|
effective_at: datetime | None = None
|
||||||
|
contract_version: str = ENCRYPTION_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract(self.contract_version)
|
||||||
|
if self.key_version < 1 or self.expected_revision < 1:
|
||||||
|
raise ValueError("Key version and expected revision must be positive")
|
||||||
|
for label, value in (
|
||||||
|
("tenant id", self.tenant_id),
|
||||||
|
("vault id", self.vault_id),
|
||||||
|
("reason", self.reason),
|
||||||
|
("policy decision reference", self.policy_decision_ref),
|
||||||
|
("assurance evidence reference", self.assurance_evidence_ref),
|
||||||
|
("idempotency key", self.idempotency_key),
|
||||||
|
):
|
||||||
|
_require_text(value, label)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ContentProtectionEnvelope:
|
||||||
|
envelope_id: str
|
||||||
|
tenant_id: str
|
||||||
|
owner_module: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
profile_kind: ProtectionProfileKind
|
||||||
|
profile_id: str
|
||||||
|
provider_id: str
|
||||||
|
vault_id: str
|
||||||
|
key_version: int
|
||||||
|
algorithm_suite: str
|
||||||
|
ciphertext_ref: str
|
||||||
|
ciphertext_digest: str
|
||||||
|
authenticated_context_digest: str
|
||||||
|
state: ProtectionState
|
||||||
|
created_at: datetime
|
||||||
|
wrapped_key_refs: tuple[str, ...] = ()
|
||||||
|
source_envelope_id: str | None = None
|
||||||
|
migration_id: str | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
contract_version: str = ENCRYPTION_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract(self.contract_version)
|
||||||
|
if self.key_version < 1:
|
||||||
|
raise ValueError("Envelope key version must be positive")
|
||||||
|
for label, value in (
|
||||||
|
("envelope id", self.envelope_id),
|
||||||
|
("tenant id", self.tenant_id),
|
||||||
|
("owner module", self.owner_module),
|
||||||
|
("resource type", self.resource_type),
|
||||||
|
("resource id", self.resource_id),
|
||||||
|
("profile id", self.profile_id),
|
||||||
|
("provider id", self.provider_id),
|
||||||
|
("vault id", self.vault_id),
|
||||||
|
("algorithm suite", self.algorithm_suite),
|
||||||
|
("ciphertext reference", self.ciphertext_ref),
|
||||||
|
("ciphertext digest", self.ciphertext_digest),
|
||||||
|
("authenticated context digest", self.authenticated_context_digest),
|
||||||
|
):
|
||||||
|
_require_text(value, label)
|
||||||
|
_reject_secret_mapping(self.metadata, "Protection metadata")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ProtectionRegistrationRequest:
|
||||||
|
envelope: ContentProtectionEnvelope
|
||||||
|
idempotency_key: str
|
||||||
|
policy_decision_ref: str
|
||||||
|
contract_version: str = ENCRYPTION_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract(self.contract_version)
|
||||||
|
_require_text(self.idempotency_key, "idempotency key")
|
||||||
|
_require_text(self.policy_decision_ref, "policy decision reference")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ProtectionMigrationRequest:
|
||||||
|
tenant_id: str
|
||||||
|
envelope_id: str
|
||||||
|
target_provider_id: str
|
||||||
|
target_vault_id: str
|
||||||
|
target_key_version: int
|
||||||
|
target_algorithm_suite: str
|
||||||
|
mode: Literal["rewrap", "reencrypt", "decrypt", "export", "destroy"]
|
||||||
|
policy_decision_ref: str
|
||||||
|
assurance_evidence_ref: str
|
||||||
|
idempotency_key: str
|
||||||
|
contract_version: str = ENCRYPTION_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract(self.contract_version)
|
||||||
|
if self.target_key_version < 1:
|
||||||
|
raise ValueError("Target key version must be positive")
|
||||||
|
for label, value in (
|
||||||
|
("tenant id", self.tenant_id),
|
||||||
|
("envelope id", self.envelope_id),
|
||||||
|
("target provider id", self.target_provider_id),
|
||||||
|
("target vault id", self.target_vault_id),
|
||||||
|
("target algorithm suite", self.target_algorithm_suite),
|
||||||
|
("policy decision reference", self.policy_decision_ref),
|
||||||
|
("assurance evidence reference", self.assurance_evidence_ref),
|
||||||
|
("idempotency key", self.idempotency_key),
|
||||||
|
):
|
||||||
|
_require_text(value, label)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ProtectionMigrationResult:
|
||||||
|
migration_id: str
|
||||||
|
state: Literal[
|
||||||
|
"requested",
|
||||||
|
"running",
|
||||||
|
"succeeded",
|
||||||
|
"rejected",
|
||||||
|
"outcome_unknown",
|
||||||
|
"reconciled",
|
||||||
|
]
|
||||||
|
source_envelope_id: str
|
||||||
|
target_envelope: ContentProtectionEnvelope | None = None
|
||||||
|
error: str | None = None
|
||||||
|
evidence_refs: tuple[str, ...] = ()
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
contract_version: str = ENCRYPTION_CONTRACT_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RecoveryRequest:
|
||||||
|
tenant_id: str
|
||||||
|
vault_id: str
|
||||||
|
reason: str
|
||||||
|
requested_scope: str
|
||||||
|
policy_decision_ref: str
|
||||||
|
assurance_evidence_ref: str
|
||||||
|
idempotency_key: str
|
||||||
|
expires_at: datetime
|
||||||
|
contract_version: str = ENCRYPTION_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract(self.contract_version)
|
||||||
|
for label, value in (
|
||||||
|
("tenant id", self.tenant_id),
|
||||||
|
("vault id", self.vault_id),
|
||||||
|
("reason", self.reason),
|
||||||
|
("requested scope", self.requested_scope),
|
||||||
|
("policy decision reference", self.policy_decision_ref),
|
||||||
|
("assurance evidence reference", self.assurance_evidence_ref),
|
||||||
|
("idempotency key", self.idempotency_key),
|
||||||
|
):
|
||||||
|
_require_text(value, label)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RecoveryApprovalRequest:
|
||||||
|
tenant_id: str
|
||||||
|
recovery_id: str
|
||||||
|
decision: Literal["approve", "reject"]
|
||||||
|
reason: str
|
||||||
|
assurance_evidence_ref: str
|
||||||
|
expected_revision: int
|
||||||
|
idempotency_key: str
|
||||||
|
contract_version: str = ENCRYPTION_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_validate_contract(self.contract_version)
|
||||||
|
if self.expected_revision < 1:
|
||||||
|
raise ValueError("Expected revision must be positive")
|
||||||
|
for label, value in (
|
||||||
|
("tenant id", self.tenant_id),
|
||||||
|
("recovery id", self.recovery_id),
|
||||||
|
("reason", self.reason),
|
||||||
|
("assurance evidence reference", self.assurance_evidence_ref),
|
||||||
|
("idempotency key", self.idempotency_key),
|
||||||
|
):
|
||||||
|
_require_text(value, label)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RecoveryRef:
|
||||||
|
tenant_id: str
|
||||||
|
recovery_id: str
|
||||||
|
vault_id: str
|
||||||
|
state: Literal["pending", "approved", "rejected", "expired", "executed"]
|
||||||
|
requested_scope: str
|
||||||
|
quorum: int
|
||||||
|
approvals: int
|
||||||
|
rejections: int
|
||||||
|
revision: int
|
||||||
|
expires_at: datetime
|
||||||
|
policy_decision_ref: str
|
||||||
|
execution_ref: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
contract_version: str = ENCRYPTION_CONTRACT_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DisablePreflightReport:
|
||||||
|
allowed: bool
|
||||||
|
protected_count: int
|
||||||
|
unresolved_count: int
|
||||||
|
state_counts: Mapping[str, int]
|
||||||
|
blocking_envelope_refs: tuple[str, ...]
|
||||||
|
required_actions: tuple[str, ...]
|
||||||
|
generated_at: datetime
|
||||||
|
contract_version: str = ENCRYPTION_CONTRACT_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class EncryptionKeyMaterialProvider(Protocol):
|
||||||
|
def provision_key(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: KeyMaterialProvisionRequest,
|
||||||
|
) -> KeyMaterialDescriptor: ...
|
||||||
|
|
||||||
|
def schedule_key_destruction(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
provider_key_ref: str,
|
||||||
|
effective_at: datetime,
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> KeyMaterialDescriptor: ...
|
||||||
|
|
||||||
|
def revoke_key(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
provider_key_ref: str,
|
||||||
|
reason: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> KeyMaterialDescriptor: ...
|
||||||
|
|
||||||
|
def key_status(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
provider_key_ref: str,
|
||||||
|
) -> KeyMaterialDescriptor | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class EncryptionKeyVault(Protocol):
|
||||||
|
def create_vault(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: KeyVaultCreateRequest,
|
||||||
|
) -> KeyVaultRef: ...
|
||||||
|
|
||||||
|
def rotate_key(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: KeyRotationRequest,
|
||||||
|
) -> KeyVaultRef: ...
|
||||||
|
|
||||||
|
def revoke_key(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: KeyLifecycleRequest,
|
||||||
|
) -> KeyVaultRef: ...
|
||||||
|
|
||||||
|
def schedule_destruction(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: KeyLifecycleRequest,
|
||||||
|
) -> KeyVaultRef: ...
|
||||||
|
|
||||||
|
def get_vault(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
vault_id: str,
|
||||||
|
) -> KeyVaultRef | None: ...
|
||||||
|
|
||||||
|
def reconcile_vault(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
vault_id: str,
|
||||||
|
) -> KeyVaultRef: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ContentProtectionRegistry(Protocol):
|
||||||
|
def register_envelope(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ProtectionRegistrationRequest,
|
||||||
|
) -> ContentProtectionEnvelope: ...
|
||||||
|
|
||||||
|
def request_migration(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ProtectionMigrationRequest,
|
||||||
|
) -> ProtectionMigrationResult: ...
|
||||||
|
|
||||||
|
def reconcile_migration(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
migration_id: str,
|
||||||
|
) -> ProtectionMigrationResult: ...
|
||||||
|
|
||||||
|
def get_envelope(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
envelope_id: str,
|
||||||
|
) -> ContentProtectionEnvelope | None: ...
|
||||||
|
|
||||||
|
def record_migration_outcome(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
migration_id: str,
|
||||||
|
state: Literal["succeeded", "rejected", "outcome_unknown"],
|
||||||
|
evidence_refs: tuple[str, ...],
|
||||||
|
target_envelope: ContentProtectionEnvelope | None = None,
|
||||||
|
error_code: str | None = None,
|
||||||
|
) -> ProtectionMigrationResult: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class EncryptionRecoveryCeremony(Protocol):
|
||||||
|
def request_recovery(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: RecoveryRequest,
|
||||||
|
) -> RecoveryRef: ...
|
||||||
|
|
||||||
|
def decide_recovery(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: RecoveryApprovalRequest,
|
||||||
|
) -> RecoveryRef: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class EncryptionDisablePreflight(Protocol):
|
||||||
|
def assess_disable(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> DisablePreflightReport: ...
|
||||||
|
|
||||||
|
|
||||||
|
def key_material_provider(
|
||||||
|
registry: object | None,
|
||||||
|
provider_id: str,
|
||||||
|
) -> EncryptionKeyMaterialProvider | None:
|
||||||
|
capability = _capability(
|
||||||
|
registry,
|
||||||
|
f"{CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX}{provider_id}",
|
||||||
|
)
|
||||||
|
return capability if isinstance(capability, EncryptionKeyMaterialProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def encryption_key_vault(registry: object | None) -> EncryptionKeyVault | None:
|
||||||
|
value = _capability(registry, CAPABILITY_ENCRYPTION_KEY_VAULT)
|
||||||
|
return value if isinstance(value, EncryptionKeyVault) else None
|
||||||
|
|
||||||
|
|
||||||
|
def content_protection_registry(
|
||||||
|
registry: object | None,
|
||||||
|
) -> ContentProtectionRegistry | None:
|
||||||
|
value = _capability(registry, CAPABILITY_ENCRYPTION_CONTENT_PROTECTION)
|
||||||
|
return value if isinstance(value, ContentProtectionRegistry) else None
|
||||||
|
|
||||||
|
|
||||||
|
def encryption_recovery_ceremony(
|
||||||
|
registry: object | None,
|
||||||
|
) -> EncryptionRecoveryCeremony | None:
|
||||||
|
value = _capability(registry, CAPABILITY_ENCRYPTION_RECOVERY)
|
||||||
|
return value if isinstance(value, EncryptionRecoveryCeremony) else None
|
||||||
|
|
||||||
|
|
||||||
|
def encryption_disable_preflight(
|
||||||
|
registry: object | None,
|
||||||
|
) -> EncryptionDisablePreflight | None:
|
||||||
|
value = _capability(registry, CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT)
|
||||||
|
return value if isinstance(value, EncryptionDisablePreflight) 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 != ENCRYPTION_CONTRACT_VERSION:
|
||||||
|
raise ValueError("Unsupported encryption contract version")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_text(value: str, label: str) -> None:
|
||||||
|
if not value.strip():
|
||||||
|
raise ValueError(f"{label.capitalize()} is required")
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_secret_mapping(value: Mapping[str, object], label: str) -> None:
|
||||||
|
forbidden = {
|
||||||
|
"ciphertext",
|
||||||
|
"credential",
|
||||||
|
"key",
|
||||||
|
"key_material",
|
||||||
|
"password",
|
||||||
|
"plaintext",
|
||||||
|
"private_key",
|
||||||
|
"secret",
|
||||||
|
"token",
|
||||||
|
}
|
||||||
|
keys = {str(key).strip().casefold() for key in value}
|
||||||
|
if forbidden & keys:
|
||||||
|
raise ValueError(f"{label} must not contain secret material")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_ENCRYPTION_CONTENT_PROTECTION",
|
||||||
|
"CAPABILITY_ENCRYPTION_DISABLE_PREFLIGHT",
|
||||||
|
"CAPABILITY_ENCRYPTION_KEY_MATERIAL_PROVIDER_PREFIX",
|
||||||
|
"CAPABILITY_ENCRYPTION_KEY_VAULT",
|
||||||
|
"CAPABILITY_ENCRYPTION_RECOVERY",
|
||||||
|
"ContentProtectionEnvelope",
|
||||||
|
"ContentProtectionRegistry",
|
||||||
|
"DisablePreflightReport",
|
||||||
|
"EncryptionDisablePreflight",
|
||||||
|
"EncryptionKeyMaterialProvider",
|
||||||
|
"EncryptionKeyVault",
|
||||||
|
"EncryptionRecoveryCeremony",
|
||||||
|
"KeyLifecycleRequest",
|
||||||
|
"KeyMaterialDescriptor",
|
||||||
|
"KeyMaterialProvisionRequest",
|
||||||
|
"KeyRotationRequest",
|
||||||
|
"KeyVaultCreateRequest",
|
||||||
|
"KeyVaultRef",
|
||||||
|
"KeyVersionRef",
|
||||||
|
"ProtectionMigrationRequest",
|
||||||
|
"ProtectionMigrationResult",
|
||||||
|
"ProtectionRegistrationRequest",
|
||||||
|
"RecoveryApprovalRequest",
|
||||||
|
"RecoveryRef",
|
||||||
|
"RecoveryRequest",
|
||||||
|
"content_protection_registry",
|
||||||
|
"encryption_disable_preflight",
|
||||||
|
"encryption_key_vault",
|
||||||
|
"encryption_recovery_ceremony",
|
||||||
|
"key_material_provider",
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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,6 +1,6 @@
|
|||||||
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
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Literal, Protocol, runtime_checkable
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
@@ -8,6 +8,8 @@ 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"
|
||||||
|
|
||||||
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"]
|
||||||
@@ -30,6 +32,18 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@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 +54,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 +63,80 @@ 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 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
|
||||||
|
)
|
||||||
|
|||||||
@@ -90,7 +90,9 @@ class _ConfigIssueCollector:
|
|||||||
def add(self, level: ConfigIssueLevel, key: str, message: str, action: str) -> None:
|
def add(self, level: ConfigIssueLevel, key: str, message: str, action: str) -> None:
|
||||||
if self.strict and level == "warning":
|
if self.strict and level == "warning":
|
||||||
level = "error"
|
level = "error"
|
||||||
self.issues.append(ConfigIssue(level=level, key=key, message=message, action=action))
|
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"}
|
||||||
@@ -120,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)
|
||||||
@@ -144,14 +152,19 @@ def validate_runtime_configuration(
|
|||||||
_validate_async_and_auth_settings(env, runtime, collector)
|
_validate_async_and_auth_settings(env, runtime, collector)
|
||||||
_validate_cors_settings(env, runtime, collector)
|
_validate_cors_settings(env, runtime, collector)
|
||||||
_validate_file_storage_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_outbound_connector_policy(env, runtime, collector)
|
||||||
_validate_module_catalog_trust(env, runtime, collector)
|
_validate_module_catalog_trust(env, runtime, collector)
|
||||||
return ConfigValidationResult(profile=runtime.name, issues=tuple(collector.issues))
|
return ConfigValidationResult(profile=runtime.name, issues=tuple(collector.issues))
|
||||||
|
|
||||||
|
|
||||||
def _runtime_profile(env: Mapping[str, str], *, profile: str | None) -> _RuntimeProfile:
|
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"))
|
clean_profile = normalize_install_profile(
|
||||||
production = clean_profile in _PRODUCTION_PROFILES or env.get("APP_ENV", "").strip().lower() in {"prod", "production"}
|
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
|
||||||
return _RuntimeProfile(
|
return _RuntimeProfile(
|
||||||
name=clean_profile,
|
name=clean_profile,
|
||||||
@@ -161,103 +174,441 @@ def _runtime_profile(env: Mapping[str, str], *, profile: str | None) -> _Runtime
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_app_env(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
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 runtime.production_like:
|
if not app_env and runtime.production_like:
|
||||||
collector.add("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(
|
||||||
|
"error",
|
||||||
|
"APP_ENV",
|
||||||
|
"APP_ENV is missing for a production-like install.",
|
||||||
|
"Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.",
|
||||||
|
)
|
||||||
elif app_env.lower() in {"dev", "test", "local"} and runtime.production_like:
|
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.")
|
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:
|
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:
|
||||||
collector.add("error", "DATABASE_URL", "DATABASE_URL is missing.", "Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.")
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"DATABASE_URL",
|
||||||
|
"DATABASE_URL is missing.",
|
||||||
|
"Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.",
|
||||||
|
)
|
||||||
return
|
return
|
||||||
backend = _database_backend(database_url)
|
backend = _database_backend(database_url)
|
||||||
if backend is None:
|
if backend is None:
|
||||||
collector.add("error", "DATABASE_URL", "DATABASE_URL is not a valid SQLAlchemy URL.", "Use a value like postgresql+psycopg://user:password@host:5432/database.")
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"DATABASE_URL",
|
||||||
|
"DATABASE_URL is not a valid SQLAlchemy URL.",
|
||||||
|
"Use a value like postgresql+psycopg://user:password@host:5432/database.",
|
||||||
|
)
|
||||||
return
|
return
|
||||||
if backend == "sqlite" and runtime.production_like:
|
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.")
|
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:
|
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.")
|
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")):
|
||||||
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.")
|
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:
|
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 runtime.local:
|
if not master_key and not runtime.local:
|
||||||
collector.add("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(
|
||||||
|
"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
|
return
|
||||||
if not master_key:
|
if not master_key:
|
||||||
return
|
return
|
||||||
error = _master_key_error(master_key)
|
error = _master_key_error(master_key)
|
||||||
if error:
|
if error:
|
||||||
collector.add("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():
|
||||||
collector.add("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:
|
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 runtime.production_like:
|
if not enabled_modules and runtime.production_like:
|
||||||
collector.add("error", "ENABLED_MODULES", "ENABLED_MODULES is missing.", "Set ENABLED_MODULES explicitly so startup module composition is intentional.")
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"ENABLED_MODULES",
|
||||||
|
"ENABLED_MODULES is missing.",
|
||||||
|
"Set ENABLED_MODULES explicitly so startup module composition is intentional.",
|
||||||
|
)
|
||||||
elif "access" not in enabled_modules and runtime.production_like:
|
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.")
|
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:
|
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.")
|
collector.add(
|
||||||
|
"warning",
|
||||||
|
"ENABLED_MODULES",
|
||||||
|
"The admin module is not enabled.",
|
||||||
|
"Keep `admin` enabled for operator UI unless this is a deliberately headless install.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_async_and_auth_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
def _validate_async_and_auth_settings(
|
||||||
|
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
|
||||||
|
) -> None:
|
||||||
if _truthy(env.get("CELERY_ENABLED")) and not _clean(env.get("REDIS_URL")):
|
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.")
|
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")):
|
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.")
|
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")):
|
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.")
|
collector.add(
|
||||||
|
"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:
|
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 runtime.production_like and not cors_origins:
|
if runtime.production_like and not cors_origins:
|
||||||
collector.add("error", "CORS_ORIGINS", "CORS_ORIGINS is missing.", "Set CORS_ORIGINS to the exact WebUI origin or origins.")
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"CORS_ORIGINS",
|
||||||
|
"CORS_ORIGINS is missing.",
|
||||||
|
"Set CORS_ORIGINS to the exact WebUI origin or origins.",
|
||||||
|
)
|
||||||
elif "*" in cors_origins and runtime.production_like:
|
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.")
|
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:
|
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.")
|
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"))
|
trusted_hosts = _csv(env.get("GOVOPLAN_TRUSTED_HOSTS"))
|
||||||
if runtime.production_like and not 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.")
|
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:
|
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.")
|
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"))
|
forwarded_allow_ips = _csv(env.get("FORWARDED_ALLOW_IPS"))
|
||||||
if runtime.production_like and "*" in 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.")
|
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:
|
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 deployment_managed or endpoint_trusted:
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"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)
|
_validate_local_file_storage(env, runtime, collector)
|
||||||
elif storage_backend == "s3":
|
elif storage_backend == "s3":
|
||||||
_validate_s3_file_storage(env, collector)
|
_validate_s3_file_storage(
|
||||||
|
env,
|
||||||
|
collector,
|
||||||
|
deployment_managed=deployment_managed,
|
||||||
|
endpoint_trusted=endpoint_trusted,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
collector.add("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`.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_local_file_storage(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
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:
|
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.")
|
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:
|
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.")
|
collector.add(
|
||||||
|
"warning",
|
||||||
|
"FILE_STORAGE_BACKEND",
|
||||||
|
"Production is configured for local file storage.",
|
||||||
|
"Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_s3_file_storage(env: Mapping[str, str], collector: _ConfigIssueCollector) -> None:
|
def _validate_s3_file_storage(
|
||||||
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"):
|
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)):
|
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.")
|
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(
|
def _validate_outbound_connector_policy(
|
||||||
@@ -265,8 +616,19 @@ def _validate_outbound_connector_policy(
|
|||||||
runtime: _RuntimeProfile,
|
runtime: _RuntimeProfile,
|
||||||
collector: _ConfigIssueCollector,
|
collector: _ConfigIssueCollector,
|
||||||
) -> None:
|
) -> None:
|
||||||
private_networks = _clean(env.get("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS")).lower()
|
private_networks = _clean(
|
||||||
if runtime.production_like and private_networks not in {"true", "false", "1", "0", "yes", "no", "on", "off"}:
|
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(
|
collector.add(
|
||||||
"error",
|
"error",
|
||||||
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
|
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
|
||||||
@@ -286,13 +648,21 @@ def _validate_outbound_connector_policy(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
parsed = 0
|
parsed = 0
|
||||||
if parsed <= 0:
|
if parsed <= 0:
|
||||||
collector.add("error", key, f"{key} must be a positive byte count.", "Use a positive integer byte limit.")
|
collector.add(
|
||||||
|
"error",
|
||||||
|
key,
|
||||||
|
f"{key} must be a positive byte count.",
|
||||||
|
"Use a positive integer byte limit.",
|
||||||
|
)
|
||||||
secret_env_names = [
|
secret_env_names = [
|
||||||
item.strip()
|
item.strip()
|
||||||
for item in env.get("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST", "").split(",")
|
for item in env.get("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST", "").split(",")
|
||||||
if item.strip()
|
if item.strip()
|
||||||
]
|
]
|
||||||
if any(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", item) is None for item in secret_env_names):
|
if any(
|
||||||
|
re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", item) is None
|
||||||
|
for item in secret_env_names
|
||||||
|
):
|
||||||
collector.add(
|
collector.add(
|
||||||
"error",
|
"error",
|
||||||
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
|
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
|
||||||
@@ -313,14 +683,28 @@ def _validate_outbound_connector_policy(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_module_catalog_trust(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
def _validate_module_catalog_trust(
|
||||||
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG"))
|
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:
|
if not runtime.production or not catalog_source:
|
||||||
return
|
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")):
|
||||||
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.")
|
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")):
|
||||||
collector.add("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",
|
||||||
|
"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:
|
||||||
@@ -329,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
|
||||||
@@ -338,8 +729,11 @@ 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,notifications,calendar,default
|
CELERY_QUEUES=send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default
|
||||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
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
|
# Deployment-wide connector egress policy. Enable private networks only when
|
||||||
# this installation intentionally integrates with internal services.
|
# this installation intentionally integrates with internal services.
|
||||||
@@ -370,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
|
||||||
@@ -387,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
|
||||||
@@ -403,8 +810,11 @@ DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
|||||||
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
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,notifications,calendar,default
|
CELERY_QUEUES=send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default
|
||||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
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_ALLOW_PRIVATE_NETWORKS=true
|
||||||
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
|
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
|
||||||
@@ -427,6 +837,12 @@ 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
|
||||||
"""
|
"""
|
||||||
@@ -437,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:
|
||||||
|
|||||||
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
|
||||||
@@ -335,6 +335,18 @@ def module_install_preflight(
|
|||||||
issues.append(ModuleInstallerIssue("warning", "empty_plan", "No planned package changes are present."))
|
issues.append(ModuleInstallerIssue("warning", "empty_plan", "No planned package changes are present."))
|
||||||
if not maintenance_mode:
|
if not maintenance_mode:
|
||||||
issues.append(ModuleInstallerIssue("blocker", "maintenance_required", "Package changes require maintenance mode."))
|
issues.append(ModuleInstallerIssue("blocker", "maintenance_required", "Package changes require maintenance mode."))
|
||||||
|
if os.getenv("GOVOPLAN_STATE_PROFILE", "local").strip().lower() == "shared":
|
||||||
|
issues.append(
|
||||||
|
ModuleInstallerIssue(
|
||||||
|
"blocker",
|
||||||
|
"immutable_cluster_release_required",
|
||||||
|
(
|
||||||
|
"Shared-state deployments cannot mutate packages on one runtime node. "
|
||||||
|
"Build and roll out one immutable release image across every API, worker, "
|
||||||
|
"and scheduler node."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
activation_candidates = desired_modules_after_package_plan(desired_sequence, plan)
|
activation_candidates = desired_modules_after_package_plan(desired_sequence, plan)
|
||||||
issues.extend(module_manifest_compatibility_issues(available, module_ids=activation_candidates))
|
issues.extend(module_manifest_compatibility_issues(available, module_ids=activation_candidates))
|
||||||
@@ -3328,12 +3340,6 @@ def _wait_for_health_urls(urls: Iterable[str], *, timeout_seconds: float, interv
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _run_restart_command_legacy(command: str | None) -> dict[str, object] | None:
|
|
||||||
if not command:
|
|
||||||
return None
|
|
||||||
return _run_restart_command(command)
|
|
||||||
|
|
||||||
|
|
||||||
def _planned_python_install_packages(record: Mapping[str, object]) -> tuple[str, ...]:
|
def _planned_python_install_packages(record: Mapping[str, object]) -> tuple[str, ...]:
|
||||||
raw_plan = record.get("plan")
|
raw_plan = record.get("plan")
|
||||||
if not isinstance(raw_plan, list):
|
if not isinstance(raw_plan, list):
|
||||||
@@ -3685,10 +3691,21 @@ def _snapshot_sqlite_database(run_dir: Path, database_url: str | None) -> dict[s
|
|||||||
source.backup(target)
|
source.backup(target)
|
||||||
else:
|
else:
|
||||||
backup_path.touch()
|
backup_path.touch()
|
||||||
|
with closing(sqlite3.connect(str(backup_path))) as candidate:
|
||||||
|
row = candidate.execute("PRAGMA integrity_check").fetchone()
|
||||||
|
integrity = str(row[0] if row else "missing result")
|
||||||
|
if integrity.lower() != "ok":
|
||||||
|
raise ModuleInstallerError(
|
||||||
|
f"SQLite backup failed its restore-readiness integrity check: {integrity}"
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"type": "sqlite",
|
"type": "sqlite",
|
||||||
"source": str(db_path),
|
"source": str(db_path),
|
||||||
"path": backup_path.name,
|
"path": backup_path.name,
|
||||||
|
"restore_check": {
|
||||||
|
"type": "sqlite_integrity_check",
|
||||||
|
"result": integrity,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -109,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))
|
||||||
@@ -404,19 +406,118 @@ def plan_desired_enabled_modules(
|
|||||||
*,
|
*,
|
||||||
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:
|
||||||
@@ -427,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,6 +3,7 @@ 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 dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -16,6 +17,11 @@ 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
|
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_]*)+$")
|
||||||
@@ -613,6 +619,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,
|
||||||
|
|||||||
@@ -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,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,13 +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"]
|
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_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:
|
||||||
@@ -30,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:
|
||||||
@@ -40,7 +86,9 @@ def policy_source_path(scope_type: str, scope_id: str | None = None) -> str:
|
|||||||
raise ValueError("System policy sources do not carry a scope_id")
|
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='')}"
|
||||||
|
|
||||||
|
|
||||||
@@ -50,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")
|
||||||
@@ -87,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 {},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -128,6 +182,202 @@ class PolicyDecision:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FunctionAssignmentGovernanceRequest:
|
||||||
|
tenant_id: str
|
||||||
|
kind: FunctionAssignmentChangeKind
|
||||||
|
action: FunctionAssignmentGovernanceAction
|
||||||
|
function_id: str
|
||||||
|
actor: PrincipalRef
|
||||||
|
candidate_identity_id: str
|
||||||
|
candidate_account_id: str | None = None
|
||||||
|
current_state: str = "draft"
|
||||||
|
function_settings: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
context: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FunctionAssignmentGovernanceDecision:
|
||||||
|
allowed: bool
|
||||||
|
reason: str | None = None
|
||||||
|
profile: str = "unavailable"
|
||||||
|
required_steps: tuple[str, ...] = ()
|
||||||
|
authority_function_id: str | None = None
|
||||||
|
evidence_required: bool = False
|
||||||
|
recipient_acceptance_required: bool = False
|
||||||
|
separation_of_duties: bool = True
|
||||||
|
quorum: int = 1
|
||||||
|
maximum_validity_days: int | None = None
|
||||||
|
request_expiry_hours: int = 336
|
||||||
|
source_path: tuple[PolicySourceStep, ...] = ()
|
||||||
|
requirements: tuple[str, ...] = ()
|
||||||
|
details: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"allowed": self.allowed,
|
||||||
|
"reason": self.reason,
|
||||||
|
"profile": self.profile,
|
||||||
|
"required_steps": list(self.required_steps),
|
||||||
|
"authority_function_id": self.authority_function_id,
|
||||||
|
"evidence_required": self.evidence_required,
|
||||||
|
"recipient_acceptance_required": (self.recipient_acceptance_required),
|
||||||
|
"separation_of_duties": self.separation_of_duties,
|
||||||
|
"quorum": self.quorum,
|
||||||
|
"maximum_validity_days": self.maximum_validity_days,
|
||||||
|
"request_expiry_hours": self.request_expiry_hours,
|
||||||
|
"source_path": [step.to_dict() for step in self.source_path],
|
||||||
|
"requirements": list(self.requirements),
|
||||||
|
"details": dict(self.details),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class FunctionAssignmentGovernancePolicy(Protocol):
|
||||||
|
def resolve_function_assignment_action(
|
||||||
|
self,
|
||||||
|
session: object | None = None,
|
||||||
|
*,
|
||||||
|
request: FunctionAssignmentGovernanceRequest,
|
||||||
|
) -> FunctionAssignmentGovernanceDecision: ...
|
||||||
|
|
||||||
|
|
||||||
|
def function_assignment_governance_policy(
|
||||||
|
registry: object | None,
|
||||||
|
) -> FunctionAssignmentGovernancePolicy | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE)
|
||||||
|
return (
|
||||||
|
capability
|
||||||
|
if isinstance(capability, FunctionAssignmentGovernancePolicy)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionScopeRef:
|
||||||
|
scope_type: DefinitionScopeType
|
||||||
|
scope_id: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def path(self) -> str:
|
||||||
|
return policy_source_path(self.scope_type, self.scope_id)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionGovernanceRequest:
|
||||||
|
module_id: str
|
||||||
|
definition_ref: str
|
||||||
|
tenant_id: str
|
||||||
|
definition_scope: DefinitionScopeRef
|
||||||
|
target_scope: DefinitionScopeRef
|
||||||
|
definition_kind: DefinitionKind
|
||||||
|
action: DefinitionGovernanceAction
|
||||||
|
actor: PrincipalRef
|
||||||
|
status: str = "draft"
|
||||||
|
inherit_to_lower_scopes: bool = False
|
||||||
|
allow_run: bool = True
|
||||||
|
allow_reuse: bool = False
|
||||||
|
allow_automation: bool = False
|
||||||
|
context: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DefinitionGovernancePolicy(Protocol):
|
||||||
|
def resolve_definition_action(
|
||||||
|
self,
|
||||||
|
session: object | None = None,
|
||||||
|
*,
|
||||||
|
request: DefinitionGovernanceRequest,
|
||||||
|
) -> PolicyDecision: ...
|
||||||
|
|
||||||
|
|
||||||
|
def definition_governance_policy(
|
||||||
|
registry: object | None,
|
||||||
|
) -> DefinitionGovernancePolicy | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_POLICY_DEFINITION_GOVERNANCE)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_POLICY_DEFINITION_GOVERNANCE)
|
||||||
|
return capability if isinstance(capability, DefinitionGovernancePolicy) else None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ViewGovernanceRequest:
|
||||||
|
tenant_id: str
|
||||||
|
action: ViewGovernanceAction
|
||||||
|
actor: PrincipalRef
|
||||||
|
target_scope: DefinitionScopeRef
|
||||||
|
view_id: str | None = None
|
||||||
|
candidate_view_ids: tuple[str, ...] = ()
|
||||||
|
candidate_surface_ids: tuple[str, ...] = ()
|
||||||
|
requested_surface_ids: tuple[str, ...] = ()
|
||||||
|
context: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ViewGovernanceDecision:
|
||||||
|
allowed: bool
|
||||||
|
reason: str | None = None
|
||||||
|
allowed_view_ids: frozenset[str] | None = None
|
||||||
|
visible_surface_ids: frozenset[str] | None = None
|
||||||
|
source_path: tuple[PolicySourceStep, ...] = ()
|
||||||
|
requirements: tuple[str, ...] = ()
|
||||||
|
diagnostics: tuple[Mapping[str, Any], ...] = ()
|
||||||
|
details: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"allowed": self.allowed,
|
||||||
|
"reason": self.reason,
|
||||||
|
"allowed_view_ids": (
|
||||||
|
sorted(self.allowed_view_ids)
|
||||||
|
if self.allowed_view_ids is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"visible_surface_ids": (
|
||||||
|
sorted(self.visible_surface_ids)
|
||||||
|
if self.visible_surface_ids is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"source_path": [step.to_dict() for step in self.source_path],
|
||||||
|
"requirements": list(self.requirements),
|
||||||
|
"diagnostics": [dict(item) for item in self.diagnostics],
|
||||||
|
"details": dict(self.details),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ViewGovernancePolicy(Protocol):
|
||||||
|
def resolve_view_action(
|
||||||
|
self,
|
||||||
|
session: object | None = None,
|
||||||
|
*,
|
||||||
|
request: ViewGovernanceRequest,
|
||||||
|
) -> ViewGovernanceDecision: ...
|
||||||
|
|
||||||
|
|
||||||
|
def view_governance_policy(
|
||||||
|
registry: object | None,
|
||||||
|
) -> ViewGovernancePolicy | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_POLICY_VIEW_GOVERNANCE)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_POLICY_VIEW_GOVERNANCE)
|
||||||
|
return capability if isinstance(capability, ViewGovernancePolicy) else None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class SchedulingParticipantPrivacyRequest:
|
class SchedulingParticipantPrivacyRequest:
|
||||||
"""Context for resolving what one Scheduling participant may see.
|
"""Context for resolving what one Scheduling participant may see.
|
||||||
@@ -173,44 +423,32 @@ class SchedulingParticipantPrivacyPolicy(Protocol):
|
|||||||
session: object,
|
session: object,
|
||||||
*,
|
*,
|
||||||
request: SchedulingParticipantPrivacyRequest,
|
request: SchedulingParticipantPrivacyRequest,
|
||||||
) -> SchedulingParticipantPrivacyDecision:
|
) -> 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(
|
def scheduling_participant_privacy_policy(
|
||||||
@@ -223,4 +461,8 @@ def scheduling_participant_privacy_policy(
|
|||||||
if not registry.has_capability(CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY):
|
if not registry.has_capability(CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY):
|
||||||
return None
|
return None
|
||||||
capability = registry.capability(CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY)
|
capability = registry.capability(CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY)
|
||||||
return capability if isinstance(capability, SchedulingParticipantPrivacyPolicy) else None
|
return (
|
||||||
|
capability
|
||||||
|
if isinstance(capability, SchedulingParticipantPrivacyPolicy)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|||||||
@@ -73,6 +73,18 @@ class PollOptionUpdateCommand:
|
|||||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class PollInvitationCommand:
|
class PollInvitationCommand:
|
||||||
respondent_id: str | None = None
|
respondent_id: str | None = None
|
||||||
@@ -98,6 +110,22 @@ class PollSubmitResponseCommand:
|
|||||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class PollOptionRef:
|
class PollOptionRef:
|
||||||
id: str
|
id: str
|
||||||
@@ -138,6 +166,16 @@ class PollResponseRef:
|
|||||||
answers: tuple[PollAnswerRef, ...] = ()
|
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
|
@runtime_checkable
|
||||||
class PollSchedulingProvider(Protocol):
|
class PollSchedulingProvider(Protocol):
|
||||||
def create_poll(
|
def create_poll(
|
||||||
@@ -183,6 +221,18 @@ class PollSchedulingProvider(Protocol):
|
|||||||
|
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def reorder_options(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollOptionOrderCommand,
|
||||||
|
) -> PollRef:
|
||||||
|
"""Atomically replace the complete active option order."""
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
||||||
...
|
...
|
||||||
|
|
||||||
@@ -251,6 +301,21 @@ class PollResponseSubmissionProvider(Protocol):
|
|||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PollResponseRetirementProvider(Protocol):
|
||||||
|
"""Optional extension for owning modules that remove participants."""
|
||||||
|
|
||||||
|
def retire_responses(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollResponseRetirementCommand,
|
||||||
|
) -> PollResponseRetirementRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
def poll_scheduling_provider(registry: object | None) -> PollSchedulingProvider | None:
|
def poll_scheduling_provider(registry: object | None) -> PollSchedulingProvider | None:
|
||||||
if registry is None or not hasattr(registry, "has_capability"):
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
return None
|
return None
|
||||||
@@ -265,3 +330,20 @@ def poll_response_submission_provider(
|
|||||||
) -> PollResponseSubmissionProvider | None:
|
) -> PollResponseSubmissionProvider | None:
|
||||||
provider = poll_scheduling_provider(registry)
|
provider = poll_scheduling_provider(registry)
|
||||||
return provider if isinstance(provider, PollResponseSubmissionProvider) else None
|
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",
|
||||||
|
]
|
||||||
@@ -16,13 +16,42 @@ from govoplan_core.core.modules import (
|
|||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
|
PublicFrontendRoute,
|
||||||
ResourceAclProvider,
|
ResourceAclProvider,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
|
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
|
||||||
SUPPORTED_MANIFEST_CONTRACT_VERSION,
|
SUPPORTED_MANIFEST_CONTRACT_VERSION,
|
||||||
|
TenantSummaryBatchProvider,
|
||||||
TenantSummaryProvider,
|
TenantSummaryProvider,
|
||||||
|
user_workflow_scope_condition_issues,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.ownership import (
|
||||||
|
OwnershipProviderRegistration,
|
||||||
|
ResourceOwnershipProvider,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.provider_governance import (
|
||||||
|
ExternalProviderDeclaration,
|
||||||
|
ExternalProviderStateProviderRegistration,
|
||||||
|
ModuleArchitectureDeclaration,
|
||||||
|
module_architecture_issues,
|
||||||
)
|
)
|
||||||
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.search import (
|
||||||
|
RegisteredSearchProvider,
|
||||||
|
RegisteredSearchSourceProvider,
|
||||||
|
SearchProvider,
|
||||||
|
SearchSourceProvider,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.views import (
|
||||||
|
ViewSurface,
|
||||||
|
module_view_surface_id,
|
||||||
|
navigation_view_surface_id,
|
||||||
|
route_view_surface_id,
|
||||||
|
validate_view_surface_id,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.workflows import (
|
||||||
|
workflow_definition_contribution_hash,
|
||||||
|
)
|
||||||
|
|
||||||
_MODULE_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
_MODULE_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
||||||
_NPM_PACKAGE_RE = re.compile(r"^(?:@[a-z0-9][a-z0-9_.-]*/)?[a-z0-9][a-z0-9_.-]*$")
|
_NPM_PACKAGE_RE = re.compile(r"^(?:@[a-z0-9][a-z0-9_.-]*/)?[a-z0-9][a-z0-9_.-]*$")
|
||||||
@@ -47,10 +76,18 @@ class PlatformRegistry:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._manifests: dict[str, ModuleManifest] = {}
|
self._manifests: dict[str, ModuleManifest] = {}
|
||||||
self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {}
|
self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {}
|
||||||
|
self._tenant_summary_batch_providers: dict[str, TenantSummaryBatchProvider] = {}
|
||||||
self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = defaultdict(list)
|
self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = defaultdict(list)
|
||||||
|
self._ownership_providers: dict[str, OwnershipProviderRegistration] = {}
|
||||||
self._capability_factories: dict[str, CapabilityFactory] = {}
|
self._capability_factories: dict[str, CapabilityFactory] = {}
|
||||||
self._capabilities: dict[str, object] = {}
|
self._capabilities: dict[str, object] = {}
|
||||||
self._capability_context: ModuleContext | None = None
|
self._capability_context: ModuleContext | None = None
|
||||||
|
self._search_provider_registrations: list[RegisteredSearchProvider] = []
|
||||||
|
self._search_providers: dict[str, SearchProvider] = {}
|
||||||
|
self._search_source_registrations: list[
|
||||||
|
RegisteredSearchSourceProvider
|
||||||
|
] = []
|
||||||
|
self._search_sources: dict[str, SearchSourceProvider] = {}
|
||||||
|
|
||||||
def register(self, manifest: ModuleManifest) -> ModuleManifest:
|
def register(self, manifest: ModuleManifest) -> ModuleManifest:
|
||||||
if manifest.id in self._manifests:
|
if manifest.id in self._manifests:
|
||||||
@@ -58,11 +95,29 @@ class PlatformRegistry:
|
|||||||
self._manifests[manifest.id] = manifest
|
self._manifests[manifest.id] = manifest
|
||||||
for provider in manifest.tenant_summary_providers:
|
for provider in manifest.tenant_summary_providers:
|
||||||
self.register_tenant_summary_provider(manifest.id, provider)
|
self.register_tenant_summary_provider(manifest.id, provider)
|
||||||
|
for provider in manifest.tenant_summary_batch_providers:
|
||||||
|
self.register_tenant_summary_batch_provider(manifest.id, provider)
|
||||||
for resource_type, providers in manifest.delete_veto_providers.items():
|
for resource_type, providers in manifest.delete_veto_providers.items():
|
||||||
for provider in providers:
|
for provider in providers:
|
||||||
self.register_delete_veto(manifest.id, resource_type, provider)
|
self.register_delete_veto(manifest.id, resource_type, provider)
|
||||||
|
for registration in manifest.ownership_providers:
|
||||||
|
self.register_ownership_provider(manifest.id, registration)
|
||||||
for name, factory in manifest.capability_factories.items():
|
for name, factory in manifest.capability_factories.items():
|
||||||
self.register_capability_factory(manifest.id, name, factory)
|
self.register_capability_factory(manifest.id, name, factory)
|
||||||
|
for registration in manifest.search_providers:
|
||||||
|
self._search_provider_registrations.append(
|
||||||
|
RegisteredSearchProvider(
|
||||||
|
module_id=manifest.id,
|
||||||
|
registration=registration,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for registration in manifest.search_sources:
|
||||||
|
self._search_source_registrations.append(
|
||||||
|
RegisteredSearchSourceProvider(
|
||||||
|
module_id=manifest.id,
|
||||||
|
registration=registration,
|
||||||
|
)
|
||||||
|
)
|
||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
def replace(self, manifests: Iterable[ModuleManifest]) -> RegistrySnapshot:
|
def replace(self, manifests: Iterable[ModuleManifest]) -> RegistrySnapshot:
|
||||||
@@ -75,12 +130,24 @@ class PlatformRegistry:
|
|||||||
|
|
||||||
self._manifests = dict(replacement._manifests)
|
self._manifests = dict(replacement._manifests)
|
||||||
self._tenant_summary_providers = dict(replacement._tenant_summary_providers)
|
self._tenant_summary_providers = dict(replacement._tenant_summary_providers)
|
||||||
|
self._tenant_summary_batch_providers = dict(
|
||||||
|
replacement._tenant_summary_batch_providers
|
||||||
|
)
|
||||||
self._delete_veto_providers = defaultdict(list, {
|
self._delete_veto_providers = defaultdict(list, {
|
||||||
resource_type: list(providers)
|
resource_type: list(providers)
|
||||||
for resource_type, providers in replacement._delete_veto_providers.items()
|
for resource_type, providers in replacement._delete_veto_providers.items()
|
||||||
})
|
})
|
||||||
|
self._ownership_providers = dict(replacement._ownership_providers)
|
||||||
self._capability_factories = dict(replacement._capability_factories)
|
self._capability_factories = dict(replacement._capability_factories)
|
||||||
|
self._search_provider_registrations = list(
|
||||||
|
replacement._search_provider_registrations
|
||||||
|
)
|
||||||
|
self._search_source_registrations = list(
|
||||||
|
replacement._search_source_registrations
|
||||||
|
)
|
||||||
self._capabilities.clear()
|
self._capabilities.clear()
|
||||||
|
self._search_providers.clear()
|
||||||
|
self._search_sources.clear()
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
def get(self, module_id: str) -> ModuleManifest | None:
|
def get(self, module_id: str) -> ModuleManifest | None:
|
||||||
@@ -102,6 +169,33 @@ class PlatformRegistry:
|
|||||||
def manifests(self) -> tuple[ModuleManifest, ...]:
|
def manifests(self) -> tuple[ModuleManifest, ...]:
|
||||||
return tuple(self._topologically_sorted())
|
return tuple(self._topologically_sorted())
|
||||||
|
|
||||||
|
def module_architectures(
|
||||||
|
self,
|
||||||
|
) -> tuple[tuple[str, ModuleArchitectureDeclaration], ...]:
|
||||||
|
return tuple(
|
||||||
|
(manifest.id, manifest.architecture)
|
||||||
|
for manifest in self.manifests()
|
||||||
|
if manifest.architecture is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
def external_provider_declarations(
|
||||||
|
self,
|
||||||
|
) -> tuple[ExternalProviderDeclaration, ...]:
|
||||||
|
return tuple(
|
||||||
|
declaration
|
||||||
|
for manifest in self.manifests()
|
||||||
|
for declaration in manifest.external_providers
|
||||||
|
)
|
||||||
|
|
||||||
|
def external_provider_state_providers(
|
||||||
|
self,
|
||||||
|
) -> tuple[ExternalProviderStateProviderRegistration, ...]:
|
||||||
|
return tuple(
|
||||||
|
registration
|
||||||
|
for manifest in self.manifests()
|
||||||
|
for registration in manifest.external_provider_state_providers
|
||||||
|
)
|
||||||
|
|
||||||
def permissions(self) -> tuple[PermissionDefinition, ...]:
|
def permissions(self) -> tuple[PermissionDefinition, ...]:
|
||||||
return tuple(permission for manifest in self.manifests() for permission in manifest.permissions)
|
return tuple(permission for manifest in self.manifests() for permission in manifest.permissions)
|
||||||
|
|
||||||
@@ -111,12 +205,59 @@ class PlatformRegistry:
|
|||||||
def nav_items(self) -> tuple[NavItem, ...]:
|
def nav_items(self) -> tuple[NavItem, ...]:
|
||||||
return tuple(sorted((item for manifest in self.manifests() for item in manifest.nav_items), key=lambda item: item.order))
|
return tuple(sorted((item for manifest in self.manifests() for item in manifest.nav_items), key=lambda item: item.order))
|
||||||
|
|
||||||
|
def view_surfaces(self) -> tuple[ViewSurface, ...]:
|
||||||
|
return tuple(
|
||||||
|
surface
|
||||||
|
for manifest in self.manifests()
|
||||||
|
for surface in manifest_view_surfaces(manifest)
|
||||||
|
)
|
||||||
|
|
||||||
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
|
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
|
||||||
return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers)
|
return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers)
|
||||||
|
|
||||||
|
def register_ownership_provider(
|
||||||
|
self,
|
||||||
|
module_id: str,
|
||||||
|
registration: OwnershipProviderRegistration,
|
||||||
|
) -> None:
|
||||||
|
resource_type = registration.resource_type.strip().lower()
|
||||||
|
if not resource_type:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Ownership provider in {module_id} has no resource type"
|
||||||
|
)
|
||||||
|
if resource_type in self._ownership_providers:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Duplicate ownership provider for resource type: {resource_type}"
|
||||||
|
)
|
||||||
|
if not isinstance(registration.provider, ResourceOwnershipProvider):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Ownership provider for {resource_type} does not implement "
|
||||||
|
"ResourceOwnershipProvider"
|
||||||
|
)
|
||||||
|
self._ownership_providers[resource_type] = OwnershipProviderRegistration(
|
||||||
|
resource_type=resource_type,
|
||||||
|
provider=registration.provider,
|
||||||
|
module_id=module_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def ownership_provider(
|
||||||
|
self,
|
||||||
|
resource_type: str,
|
||||||
|
) -> ResourceOwnershipProvider | None:
|
||||||
|
registration = self.ownership_provider_registration(resource_type)
|
||||||
|
return registration.provider if registration else None
|
||||||
|
|
||||||
|
def ownership_provider_registration(
|
||||||
|
self,
|
||||||
|
resource_type: str,
|
||||||
|
) -> OwnershipProviderRegistration | None:
|
||||||
|
return self._ownership_providers.get(resource_type.strip().lower())
|
||||||
|
|
||||||
def configure_capability_context(self, context: ModuleContext) -> None:
|
def configure_capability_context(self, context: ModuleContext) -> None:
|
||||||
self._capability_context = context
|
self._capability_context = context
|
||||||
self._capabilities.clear()
|
self._capabilities.clear()
|
||||||
|
self._search_providers.clear()
|
||||||
|
self._search_sources.clear()
|
||||||
|
|
||||||
def register_capability_factory(self, module_id: str, name: str, factory: CapabilityFactory) -> None:
|
def register_capability_factory(self, module_id: str, name: str, factory: CapabilityFactory) -> None:
|
||||||
if name in self._capability_factories:
|
if name in self._capability_factories:
|
||||||
@@ -126,6 +267,9 @@ class PlatformRegistry:
|
|||||||
def has_capability(self, name: str) -> bool:
|
def has_capability(self, name: str) -> bool:
|
||||||
return name in self._capability_factories
|
return name in self._capability_factories
|
||||||
|
|
||||||
|
def capability_names(self) -> tuple[str, ...]:
|
||||||
|
return tuple(sorted(self._capability_factories))
|
||||||
|
|
||||||
def capability(self, name: str) -> object | None:
|
def capability(self, name: str) -> object | None:
|
||||||
if name in self._capabilities:
|
if name in self._capabilities:
|
||||||
return self._capabilities[name]
|
return self._capabilities[name]
|
||||||
@@ -144,12 +288,95 @@ class PlatformRegistry:
|
|||||||
raise RegistryError(f"Required capability is not available: {name}")
|
raise RegistryError(f"Required capability is not available: {name}")
|
||||||
return capability
|
return capability
|
||||||
|
|
||||||
|
def search_provider_registrations(
|
||||||
|
self,
|
||||||
|
) -> tuple[RegisteredSearchProvider, ...]:
|
||||||
|
return tuple(
|
||||||
|
sorted(
|
||||||
|
self._search_provider_registrations,
|
||||||
|
key=lambda item: (
|
||||||
|
item.registration.order,
|
||||||
|
item.module_id,
|
||||||
|
item.registration.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def search_providers(
|
||||||
|
self,
|
||||||
|
) -> tuple[tuple[RegisteredSearchProvider, SearchProvider], ...]:
|
||||||
|
if self._capability_context is None:
|
||||||
|
if self._search_provider_registrations:
|
||||||
|
raise RegistryError("Search provider context is not configured.")
|
||||||
|
return ()
|
||||||
|
providers: list[tuple[RegisteredSearchProvider, SearchProvider]] = []
|
||||||
|
for registered in self.search_provider_registrations():
|
||||||
|
key = f"{registered.module_id}:{registered.registration.id}"
|
||||||
|
provider = self._search_providers.get(key)
|
||||||
|
if provider is None:
|
||||||
|
provider = registered.registration.create(self._capability_context)
|
||||||
|
self._search_providers[key] = provider
|
||||||
|
providers.append((registered, provider))
|
||||||
|
return tuple(providers)
|
||||||
|
|
||||||
|
def search_source_registrations(
|
||||||
|
self,
|
||||||
|
) -> tuple[RegisteredSearchSourceProvider, ...]:
|
||||||
|
return tuple(
|
||||||
|
sorted(
|
||||||
|
self._search_source_registrations,
|
||||||
|
key=lambda item: (
|
||||||
|
item.registration.order,
|
||||||
|
item.module_id,
|
||||||
|
item.registration.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def search_sources(
|
||||||
|
self,
|
||||||
|
) -> tuple[
|
||||||
|
tuple[RegisteredSearchSourceProvider, SearchSourceProvider],
|
||||||
|
...,
|
||||||
|
]:
|
||||||
|
if self._capability_context is None:
|
||||||
|
if self._search_source_registrations:
|
||||||
|
raise RegistryError(
|
||||||
|
"Search source context is not configured."
|
||||||
|
)
|
||||||
|
return ()
|
||||||
|
providers: list[
|
||||||
|
tuple[RegisteredSearchSourceProvider, SearchSourceProvider]
|
||||||
|
] = []
|
||||||
|
for registered in self.search_source_registrations():
|
||||||
|
key = f"{registered.module_id}:{registered.registration.id}"
|
||||||
|
provider = self._search_sources.get(key)
|
||||||
|
if provider is None:
|
||||||
|
provider = registered.registration.create(
|
||||||
|
self._capability_context
|
||||||
|
)
|
||||||
|
self._search_sources[key] = provider
|
||||||
|
providers.append((registered, provider))
|
||||||
|
return tuple(providers)
|
||||||
|
|
||||||
def register_tenant_summary_provider(self, module_id: str, provider: TenantSummaryProvider) -> None:
|
def register_tenant_summary_provider(self, module_id: str, provider: TenantSummaryProvider) -> None:
|
||||||
self._tenant_summary_providers[module_id] = provider
|
self._tenant_summary_providers[module_id] = provider
|
||||||
|
|
||||||
def tenant_summary_providers(self) -> Mapping[str, TenantSummaryProvider]:
|
def tenant_summary_providers(self) -> Mapping[str, TenantSummaryProvider]:
|
||||||
return dict(self._tenant_summary_providers)
|
return dict(self._tenant_summary_providers)
|
||||||
|
|
||||||
|
def register_tenant_summary_batch_provider(
|
||||||
|
self,
|
||||||
|
module_id: str,
|
||||||
|
provider: TenantSummaryBatchProvider,
|
||||||
|
) -> None:
|
||||||
|
self._tenant_summary_batch_providers[module_id] = provider
|
||||||
|
|
||||||
|
def tenant_summary_batch_providers(
|
||||||
|
self,
|
||||||
|
) -> Mapping[str, TenantSummaryBatchProvider]:
|
||||||
|
return dict(self._tenant_summary_batch_providers)
|
||||||
|
|
||||||
def register_delete_veto(self, module_id: str, resource_type: str, provider: DeleteVetoProvider) -> None:
|
def register_delete_veto(self, module_id: str, resource_type: str, provider: DeleteVetoProvider) -> None:
|
||||||
self._delete_veto_providers[resource_type].append(DeleteVetoProviderRegistration(
|
self._delete_veto_providers[resource_type].append(DeleteVetoProviderRegistration(
|
||||||
module_id=module_id,
|
module_id=module_id,
|
||||||
@@ -194,6 +421,7 @@ class PlatformRegistry:
|
|||||||
available_capabilities=available_capabilities,
|
available_capabilities=available_capabilities,
|
||||||
)
|
)
|
||||||
permissions = _collect_manifest_permissions(ordered)
|
permissions = _collect_manifest_permissions(ordered)
|
||||||
|
_validate_public_frontend_route_uniqueness(ordered)
|
||||||
_validate_interface_closure(ordered)
|
_validate_interface_closure(ordered)
|
||||||
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
|
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
|
||||||
|
|
||||||
@@ -227,6 +455,53 @@ class PlatformRegistry:
|
|||||||
return (self._manifests[module_id] for module_id in ordered)
|
return (self._manifests[module_id] for module_id in ordered)
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_view_surfaces(manifest: ModuleManifest) -> tuple[ViewSurface, ...]:
|
||||||
|
frontend = manifest.frontend
|
||||||
|
if frontend is None:
|
||||||
|
return ()
|
||||||
|
root_id = module_view_surface_id(manifest.id)
|
||||||
|
surfaces = [
|
||||||
|
ViewSurface(
|
||||||
|
id=root_id,
|
||||||
|
module_id=manifest.id,
|
||||||
|
kind="module",
|
||||||
|
label=manifest.name,
|
||||||
|
order=min((item.order for item in frontend.nav_items), default=100),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
surfaces.extend(
|
||||||
|
ViewSurface(
|
||||||
|
id=item.surface_id or navigation_view_surface_id(manifest.id, item.path),
|
||||||
|
module_id=manifest.id,
|
||||||
|
kind="navigation",
|
||||||
|
label=item.label,
|
||||||
|
parent_id=root_id,
|
||||||
|
order=item.order,
|
||||||
|
)
|
||||||
|
for item in frontend.nav_items
|
||||||
|
)
|
||||||
|
surfaces.extend(
|
||||||
|
ViewSurface(
|
||||||
|
id=route.surface_id or route_view_surface_id(manifest.id, route.path),
|
||||||
|
module_id=manifest.id,
|
||||||
|
kind="route",
|
||||||
|
label=route.component,
|
||||||
|
parent_id=root_id,
|
||||||
|
description=route.path,
|
||||||
|
order=route.order,
|
||||||
|
)
|
||||||
|
for route in (*frontend.routes, *frontend.settings_routes)
|
||||||
|
)
|
||||||
|
surfaces.extend(
|
||||||
|
replace(
|
||||||
|
surface,
|
||||||
|
parent_id=surface.parent_id or root_id,
|
||||||
|
)
|
||||||
|
for surface in frontend.view_surfaces
|
||||||
|
)
|
||||||
|
return tuple(surfaces)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_delete_veto_result(
|
def _normalize_delete_veto_result(
|
||||||
result: object,
|
result: object,
|
||||||
*,
|
*,
|
||||||
@@ -304,8 +579,12 @@ def _validate_manifest_permission(
|
|||||||
permission: PermissionDefinition,
|
permission: PermissionDefinition,
|
||||||
seen_permissions: Mapping[str, PermissionDefinition],
|
seen_permissions: Mapping[str, PermissionDefinition],
|
||||||
) -> None:
|
) -> None:
|
||||||
if permission.module_id != manifest.id:
|
permission_namespace = manifest.permission_namespace or manifest.id
|
||||||
raise RegistryError(f"Permission {permission.scope!r} has mismatched module id {permission.module_id!r}")
|
if permission.module_id != permission_namespace:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Permission {permission.scope!r} has mismatched module id "
|
||||||
|
f"{permission.module_id!r}; expected {permission_namespace!r}"
|
||||||
|
)
|
||||||
if not _SCOPE_RE.match(permission.scope):
|
if not _SCOPE_RE.match(permission.scope):
|
||||||
raise RegistryError(f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}")
|
raise RegistryError(f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}")
|
||||||
expected_prefix = f"{permission.module_id}:{permission.resource}:"
|
expected_prefix = f"{permission.module_id}:{permission.resource}:"
|
||||||
@@ -320,9 +599,34 @@ def _validate_role_template_scopes(
|
|||||||
*,
|
*,
|
||||||
known_scopes: set[str],
|
known_scopes: set[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
seen_templates: dict[tuple[str, str], str] = {}
|
||||||
for manifest in manifests:
|
for manifest in manifests:
|
||||||
for template in manifest.role_templates:
|
for template in manifest.role_templates:
|
||||||
|
template_key = (template.level, template.slug)
|
||||||
|
previous_module = seen_templates.get(template_key)
|
||||||
|
if previous_module is not None:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Duplicate {template.level} role template slug {template.slug!r} "
|
||||||
|
f"in modules {previous_module!r} and {manifest.id!r}"
|
||||||
|
)
|
||||||
|
seen_templates[template_key] = manifest.id
|
||||||
|
if template.default_authenticated and template.level != "tenant":
|
||||||
|
raise RegistryError(
|
||||||
|
f"Default authenticated role template {template.slug!r} must be tenant-level"
|
||||||
|
)
|
||||||
|
if template.default_authenticated and not template.managed:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Default authenticated role template {template.slug!r} must be managed"
|
||||||
|
)
|
||||||
for scope in template.permissions:
|
for scope in template.permissions:
|
||||||
|
if template.default_authenticated and (
|
||||||
|
scope in {"*", "tenant:*", "system:*"}
|
||||||
|
or _WILDCARD_RE.match(scope)
|
||||||
|
):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Default authenticated role template {template.slug!r} "
|
||||||
|
"must use explicit permissions, not wildcard scopes"
|
||||||
|
)
|
||||||
if _role_template_scope_known(scope, known_scopes):
|
if _role_template_scope_known(scope, known_scopes):
|
||||||
continue
|
continue
|
||||||
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
|
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
|
||||||
@@ -344,6 +648,278 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
|||||||
_validate_manifest_frontend(manifest)
|
_validate_manifest_frontend(manifest)
|
||||||
for item in manifest.nav_items:
|
for item in manifest.nav_items:
|
||||||
_validate_nav_item(manifest.id, item)
|
_validate_nav_item(manifest.id, item)
|
||||||
|
for topic in manifest.documentation:
|
||||||
|
if not version_range_is_valid(
|
||||||
|
version_min=topic.version_min,
|
||||||
|
version_max_exclusive=topic.version_max_exclusive,
|
||||||
|
):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} documentation topic {topic.id!r} "
|
||||||
|
"declares an empty version range"
|
||||||
|
)
|
||||||
|
for issue in user_workflow_scope_condition_issues(topic):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
|
||||||
|
)
|
||||||
|
_validate_documentation_extensions(manifest)
|
||||||
|
_validate_architecture_declarations(manifest)
|
||||||
|
_validate_workflow_definition_contributions(manifest)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_architecture_declarations(manifest: ModuleManifest) -> None:
|
||||||
|
architecture = manifest.architecture
|
||||||
|
if architecture is not None:
|
||||||
|
for issue in module_architecture_issues(
|
||||||
|
architecture,
|
||||||
|
has_migrations=manifest.migration_spec is not None,
|
||||||
|
):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} architecture declaration: {issue}"
|
||||||
|
)
|
||||||
|
|
||||||
|
provider_ids: set[str] = set()
|
||||||
|
declared_capabilities = {
|
||||||
|
*manifest.required_capabilities,
|
||||||
|
*manifest.optional_capabilities,
|
||||||
|
*manifest.capability_factories,
|
||||||
|
}
|
||||||
|
declared_interfaces = {
|
||||||
|
*(item.name for item in manifest.provides_interfaces),
|
||||||
|
*(item.name for item in manifest.requires_interfaces),
|
||||||
|
}
|
||||||
|
operational_check_ids = {
|
||||||
|
item.check_id for item in manifest.operational_check_providers
|
||||||
|
}
|
||||||
|
documentation_topic_ids = {item.id for item in manifest.documentation}
|
||||||
|
ownership_resource_types = {
|
||||||
|
item.resource_type for item in manifest.ownership_providers
|
||||||
|
}
|
||||||
|
provider_authority_modes: set[str] = set()
|
||||||
|
|
||||||
|
for declaration in manifest.external_providers:
|
||||||
|
if declaration.module_id != manifest.id:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Provider declaration {declaration.id!r} belongs to "
|
||||||
|
f"{declaration.module_id!r}, not module {manifest.id!r}"
|
||||||
|
)
|
||||||
|
if declaration.id in provider_ids:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} declares duplicate external provider "
|
||||||
|
f"{declaration.id!r}"
|
||||||
|
)
|
||||||
|
provider_ids.add(declaration.id)
|
||||||
|
provider_authority_modes.update(declaration.authority_modes)
|
||||||
|
_validate_provider_references(
|
||||||
|
manifest.id,
|
||||||
|
declaration,
|
||||||
|
declared_capabilities=declared_capabilities,
|
||||||
|
declared_interfaces=declared_interfaces,
|
||||||
|
operational_check_ids=operational_check_ids,
|
||||||
|
documentation_topic_ids=documentation_topic_ids,
|
||||||
|
ownership_resource_types=ownership_resource_types,
|
||||||
|
)
|
||||||
|
|
||||||
|
state_provider_ids: set[str] = set()
|
||||||
|
for registration in manifest.external_provider_state_providers:
|
||||||
|
if registration.module_id != manifest.id:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Provider state registration {registration.provider_id!r} belongs "
|
||||||
|
f"to {registration.module_id!r}, not module {manifest.id!r}"
|
||||||
|
)
|
||||||
|
if registration.provider_id not in provider_ids:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} registers runtime state for undeclared "
|
||||||
|
f"provider {registration.provider_id!r}"
|
||||||
|
)
|
||||||
|
if registration.provider_id in state_provider_ids:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} registers duplicate runtime state for "
|
||||||
|
f"provider {registration.provider_id!r}"
|
||||||
|
)
|
||||||
|
state_provider_ids.add(registration.provider_id)
|
||||||
|
|
||||||
|
missing_state_provider_ids = provider_ids - state_provider_ids
|
||||||
|
if missing_state_provider_ids:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} external providers require sanitized runtime "
|
||||||
|
"state providers: " + ", ".join(sorted(missing_state_provider_ids))
|
||||||
|
)
|
||||||
|
|
||||||
|
if architecture is None:
|
||||||
|
if manifest.external_providers or manifest.external_provider_state_providers:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} declares external providers without a "
|
||||||
|
"module architecture declaration"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
missing_modes = provider_authority_modes - set(
|
||||||
|
architecture.supported_authority_modes
|
||||||
|
)
|
||||||
|
if missing_modes:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} provider authority modes are not declared "
|
||||||
|
"by its architecture metadata: " + ", ".join(sorted(missing_modes))
|
||||||
|
)
|
||||||
|
unknown_target_providers = set(
|
||||||
|
architecture.target_tested_providers
|
||||||
|
) - provider_ids
|
||||||
|
if unknown_target_providers:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} target-tested providers are not declared: "
|
||||||
|
+ ", ".join(sorted(unknown_target_providers))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_provider_references(
|
||||||
|
module_id: str,
|
||||||
|
declaration: ExternalProviderDeclaration,
|
||||||
|
*,
|
||||||
|
declared_capabilities: set[str],
|
||||||
|
declared_interfaces: set[str],
|
||||||
|
operational_check_ids: set[str],
|
||||||
|
documentation_topic_ids: set[str],
|
||||||
|
ownership_resource_types: set[str],
|
||||||
|
) -> None:
|
||||||
|
references = (
|
||||||
|
(
|
||||||
|
"capabilities",
|
||||||
|
set(declaration.capability_names),
|
||||||
|
declared_capabilities,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"interfaces",
|
||||||
|
set(declaration.interface_names),
|
||||||
|
declared_interfaces,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"operational checks",
|
||||||
|
set(declaration.operational_check_ids),
|
||||||
|
operational_check_ids,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"documentation topics",
|
||||||
|
set(declaration.documentation_topic_ids),
|
||||||
|
documentation_topic_ids,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ownership resource types",
|
||||||
|
set(declaration.ownership_resource_types),
|
||||||
|
ownership_resource_types,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for label, requested, available in references:
|
||||||
|
missing = requested - available
|
||||||
|
if missing:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {module_id!r} provider {declaration.id!r} references "
|
||||||
|
f"undeclared {label}: " + ", ".join(sorted(missing))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_workflow_definition_contributions(
|
||||||
|
manifest: ModuleManifest,
|
||||||
|
) -> None:
|
||||||
|
seen_keys: set[str] = set()
|
||||||
|
for contribution in manifest.workflow_definitions:
|
||||||
|
if contribution.origin_module_id != manifest.id:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Workflow contribution {contribution.definition_key!r} in "
|
||||||
|
f"module {manifest.id!r} declares origin module "
|
||||||
|
f"{contribution.origin_module_id!r}"
|
||||||
|
)
|
||||||
|
if contribution.origin_module_version != manifest.version:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Workflow contribution {contribution.definition_key!r} in "
|
||||||
|
f"module {manifest.id!r} declares origin version "
|
||||||
|
f"{contribution.origin_module_version!r}, expected "
|
||||||
|
f"{manifest.version!r}"
|
||||||
|
)
|
||||||
|
if not re.match(
|
||||||
|
r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,119}$",
|
||||||
|
contribution.definition_key,
|
||||||
|
):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Workflow contribution key is invalid: "
|
||||||
|
f"{contribution.definition_key!r}"
|
||||||
|
)
|
||||||
|
if contribution.definition_key in seen_keys:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} declares duplicate Workflow "
|
||||||
|
f"contribution {contribution.definition_key!r}"
|
||||||
|
)
|
||||||
|
seen_keys.add(contribution.definition_key)
|
||||||
|
if contribution.scope_type not in {"system", "tenant"}:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Workflow contribution {contribution.definition_key!r} has "
|
||||||
|
f"unsupported scope {contribution.scope_type!r}"
|
||||||
|
)
|
||||||
|
expected_hash = workflow_definition_contribution_hash(contribution)
|
||||||
|
if (
|
||||||
|
contribution.content_hash is not None
|
||||||
|
and contribution.content_hash != expected_hash
|
||||||
|
):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Workflow contribution {contribution.definition_key!r} has "
|
||||||
|
"a content hash that does not match its canonical content"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_documentation_extensions(manifest: ModuleManifest) -> None:
|
||||||
|
for capability, metadata in manifest.capability_documentation.items():
|
||||||
|
if capability not in manifest.capability_factories:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} documents capability {capability!r} "
|
||||||
|
"but does not provide it"
|
||||||
|
)
|
||||||
|
if not metadata.label.strip():
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} capability {capability!r} "
|
||||||
|
"documentation must have a label"
|
||||||
|
)
|
||||||
|
if not metadata.summary.strip():
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} capability {capability!r} "
|
||||||
|
"documentation must have a summary"
|
||||||
|
)
|
||||||
|
if metadata.contract_version is not None and not metadata.contract_version.strip():
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} capability {capability!r} "
|
||||||
|
"documentation contract version must not be empty"
|
||||||
|
)
|
||||||
|
|
||||||
|
provider_keys: set[str] = set()
|
||||||
|
for registration in manifest.documentation_configuration_providers:
|
||||||
|
if not registration.keys:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} documentation configuration provider must declare keys"
|
||||||
|
)
|
||||||
|
for raw_key in registration.keys:
|
||||||
|
key = raw_key.strip()
|
||||||
|
if not key:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} documentation configuration provider declares an empty key"
|
||||||
|
)
|
||||||
|
if key in provider_keys:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} declares duplicate documentation configuration key {key!r}"
|
||||||
|
)
|
||||||
|
provider_keys.add(key)
|
||||||
|
|
||||||
|
source_ids: set[str] = set()
|
||||||
|
for source in manifest.documentation_sources:
|
||||||
|
if not _INTERFACE_NAME_RE.match(source.id):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} documentation source id must be namespaced: {source.id!r}"
|
||||||
|
)
|
||||||
|
if source.id in source_ids:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} declares duplicate documentation source {source.id!r}"
|
||||||
|
)
|
||||||
|
source_ids.add(source.id)
|
||||||
|
if not source.label.strip():
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} documentation source {source.id!r} must have a label"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_manifest_identity(manifest: ModuleManifest) -> None:
|
def _validate_manifest_identity(manifest: ModuleManifest) -> None:
|
||||||
@@ -353,6 +929,14 @@ def _validate_manifest_identity(manifest: ModuleManifest) -> None:
|
|||||||
raise RegistryError(f"Module {manifest.id!r} must declare a non-empty name")
|
raise RegistryError(f"Module {manifest.id!r} must declare a non-empty name")
|
||||||
if not manifest.version.strip():
|
if not manifest.version.strip():
|
||||||
raise RegistryError(f"Module {manifest.id!r} must declare a non-empty version")
|
raise RegistryError(f"Module {manifest.id!r} must declare a non-empty version")
|
||||||
|
if (
|
||||||
|
manifest.permission_namespace is not None
|
||||||
|
and not _MODULE_ID_RE.match(manifest.permission_namespace)
|
||||||
|
):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} has invalid permission namespace "
|
||||||
|
f"{manifest.permission_namespace!r}"
|
||||||
|
)
|
||||||
if manifest.compatibility.manifest_contract_version != SUPPORTED_MANIFEST_CONTRACT_VERSION:
|
if manifest.compatibility.manifest_contract_version != SUPPORTED_MANIFEST_CONTRACT_VERSION:
|
||||||
raise RegistryError(
|
raise RegistryError(
|
||||||
f"Module {manifest.id!r} uses unsupported manifest contract version "
|
f"Module {manifest.id!r} uses unsupported manifest contract version "
|
||||||
@@ -368,6 +952,32 @@ def _validate_manifest_contract_lists(manifest: ModuleManifest) -> None:
|
|||||||
_validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities)
|
_validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities)
|
||||||
_validate_interface_providers(manifest.id, manifest.provides_interfaces)
|
_validate_interface_providers(manifest.id, manifest.provides_interfaces)
|
||||||
_validate_interface_requirements(manifest.id, manifest.requires_interfaces)
|
_validate_interface_requirements(manifest.id, manifest.requires_interfaces)
|
||||||
|
provider_ids: set[str] = set()
|
||||||
|
for registration in manifest.search_providers:
|
||||||
|
if not _INTERFACE_NAME_RE.match(registration.id):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} search provider id must be namespaced: "
|
||||||
|
f"{registration.id!r}"
|
||||||
|
)
|
||||||
|
if registration.id in provider_ids:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} declares duplicate search provider "
|
||||||
|
f"{registration.id!r}"
|
||||||
|
)
|
||||||
|
provider_ids.add(registration.id)
|
||||||
|
source_ids: set[str] = set()
|
||||||
|
for registration in manifest.search_sources:
|
||||||
|
if not _INTERFACE_NAME_RE.match(registration.id):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} search source id must be "
|
||||||
|
f"namespaced: {registration.id!r}"
|
||||||
|
)
|
||||||
|
if registration.id in source_ids:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} declares duplicate search source "
|
||||||
|
f"{registration.id!r}"
|
||||||
|
)
|
||||||
|
source_ids.add(registration.id)
|
||||||
|
|
||||||
|
|
||||||
def _validate_manifest_overlaps(manifest: ModuleManifest) -> None:
|
def _validate_manifest_overlaps(manifest: ModuleManifest) -> None:
|
||||||
@@ -403,10 +1013,119 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
|
|||||||
)
|
)
|
||||||
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
|
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
|
||||||
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
|
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
|
||||||
for route in (*frontend.routes, *frontend.settings_routes):
|
for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes):
|
||||||
_validate_frontend_route(manifest.id, route.path, route.component)
|
_validate_frontend_route(manifest.id, route.path, route.component)
|
||||||
|
for route in (*frontend.routes, *frontend.settings_routes):
|
||||||
|
if route.surface_id is not None:
|
||||||
|
_validate_view_surface_id(manifest.id, route.surface_id)
|
||||||
for item in frontend.nav_items:
|
for item in frontend.nav_items:
|
||||||
_validate_nav_item(manifest.id, item)
|
_validate_nav_item(manifest.id, item)
|
||||||
|
if item.surface_id is not None:
|
||||||
|
_validate_view_surface_id(manifest.id, item.surface_id)
|
||||||
|
_validate_view_surfaces(manifest)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_view_surfaces(manifest: ModuleManifest) -> None:
|
||||||
|
frontend = manifest.frontend
|
||||||
|
if frontend is None:
|
||||||
|
return
|
||||||
|
root_id = module_view_surface_id(manifest.id)
|
||||||
|
known_ids = {root_id}
|
||||||
|
_validate_view_surface_id(manifest.id, root_id)
|
||||||
|
for item in frontend.nav_items:
|
||||||
|
surface_id = item.surface_id or navigation_view_surface_id(manifest.id, item.path)
|
||||||
|
_register_view_surface_id(manifest.id, surface_id, known_ids)
|
||||||
|
for route in (*frontend.routes, *frontend.settings_routes):
|
||||||
|
surface_id = route.surface_id or route_view_surface_id(manifest.id, route.path)
|
||||||
|
_register_view_surface_id(manifest.id, surface_id, known_ids)
|
||||||
|
for surface in frontend.view_surfaces:
|
||||||
|
_validate_declared_view_surface(manifest.id, surface)
|
||||||
|
_register_view_surface_id(manifest.id, surface.id, known_ids)
|
||||||
|
for surface in frontend.view_surfaces:
|
||||||
|
_validate_view_surface_definition(
|
||||||
|
manifest.id,
|
||||||
|
surface,
|
||||||
|
root_id=root_id,
|
||||||
|
known_ids=known_ids,
|
||||||
|
)
|
||||||
|
parent_by_id = {
|
||||||
|
surface.id: surface.parent_id or root_id
|
||||||
|
for surface in frontend.view_surfaces
|
||||||
|
}
|
||||||
|
_validate_view_surface_hierarchy(manifest.id, parent_by_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _register_view_surface_id(
|
||||||
|
module_id: str,
|
||||||
|
surface_id: str,
|
||||||
|
known_ids: set[str],
|
||||||
|
) -> None:
|
||||||
|
_validate_view_surface_id(module_id, surface_id)
|
||||||
|
if surface_id in known_ids:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Duplicate view surface id {surface_id!r} in module {module_id!r}"
|
||||||
|
)
|
||||||
|
known_ids.add(surface_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_declared_view_surface(
|
||||||
|
module_id: str,
|
||||||
|
surface: ViewSurface,
|
||||||
|
) -> None:
|
||||||
|
if surface.module_id != module_id:
|
||||||
|
raise RegistryError(
|
||||||
|
f"View surface {surface.id!r} belongs to {surface.module_id!r}, "
|
||||||
|
f"not module {module_id!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_view_surface_definition(
|
||||||
|
module_id: str,
|
||||||
|
surface: ViewSurface,
|
||||||
|
*,
|
||||||
|
root_id: str,
|
||||||
|
known_ids: set[str],
|
||||||
|
) -> None:
|
||||||
|
parent_id = surface.parent_id or root_id
|
||||||
|
if parent_id not in known_ids:
|
||||||
|
raise RegistryError(
|
||||||
|
f"View surface {surface.id!r} in module {module_id!r} "
|
||||||
|
f"references unknown parent {parent_id!r}"
|
||||||
|
)
|
||||||
|
if surface.required and not surface.default_visible:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Required view surface {surface.id!r} in module {module_id!r} "
|
||||||
|
"must be visible by default"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_view_surface_hierarchy(
|
||||||
|
module_id: str,
|
||||||
|
parent_by_id: Mapping[str, str],
|
||||||
|
) -> None:
|
||||||
|
for surface_id in parent_by_id:
|
||||||
|
path: set[str] = set()
|
||||||
|
current_id: str | None = surface_id
|
||||||
|
while current_id in parent_by_id:
|
||||||
|
if current_id in path:
|
||||||
|
raise RegistryError(
|
||||||
|
f"View surface hierarchy in module {module_id!r} "
|
||||||
|
f"contains a cycle at {current_id!r}"
|
||||||
|
)
|
||||||
|
path.add(current_id)
|
||||||
|
current_id = parent_by_id[current_id]
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_view_surface_id(module_id: str, surface_id: str) -> None:
|
||||||
|
if not validate_view_surface_id(surface_id):
|
||||||
|
raise RegistryError(
|
||||||
|
f"View surface id for module {module_id!r} is invalid: {surface_id!r}"
|
||||||
|
)
|
||||||
|
if not surface_id.startswith(f"{module_id}."):
|
||||||
|
raise RegistryError(
|
||||||
|
f"View surface id for module {module_id!r} must start with "
|
||||||
|
f"{module_id!r} followed by '.': {surface_id!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
|
def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
|
||||||
@@ -416,6 +1135,24 @@ def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
|
|||||||
raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component")
|
raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_public_frontend_route_uniqueness(
|
||||||
|
manifests: tuple[ModuleManifest, ...],
|
||||||
|
) -> None:
|
||||||
|
owners: dict[str, tuple[str, PublicFrontendRoute]] = {}
|
||||||
|
for manifest in manifests:
|
||||||
|
if manifest.frontend is None:
|
||||||
|
continue
|
||||||
|
for route in manifest.frontend.public_routes:
|
||||||
|
previous = owners.get(route.path)
|
||||||
|
if previous is not None:
|
||||||
|
previous_module, _previous_route = previous
|
||||||
|
raise RegistryError(
|
||||||
|
f"Duplicate public frontend route {route.path!r} in modules "
|
||||||
|
f"{previous_module!r} and {manifest.id!r}"
|
||||||
|
)
|
||||||
|
owners[route.path] = (manifest.id, route)
|
||||||
|
|
||||||
|
|
||||||
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
|
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
|
||||||
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list)
|
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list)
|
||||||
for manifest in manifests:
|
for manifest in manifests:
|
||||||
|
|||||||
@@ -0,0 +1,605 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from enum import StrEnum
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, DateTime, Index, Integer, JSON, String, UniqueConstraint, select
|
||||||
|
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||||
|
|
||||||
|
from govoplan_core.db.base import Base, TimestampMixin, utcnow
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeNodeState(StrEnum):
|
||||||
|
ACTIVE = "active"
|
||||||
|
DRAINING = "draining"
|
||||||
|
STOPPED = "stopped"
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeCoordinationError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class LeaseUnavailable(RuntimeCoordinationError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class StaleFence(RuntimeCoordinationError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeNode(Base, TimestampMixin):
|
||||||
|
__tablename__ = "core_runtime_nodes"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"installation_id",
|
||||||
|
"node_id",
|
||||||
|
name="uq_core_runtime_node_installation_node",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_core_runtime_nodes_installation_state_heartbeat",
|
||||||
|
"installation_id",
|
||||||
|
"state",
|
||||||
|
"last_heartbeat_at",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
node_id: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
|
||||||
|
incarnation: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
role: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||||
|
software_version: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||||
|
composition_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
queues: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
state: Mapped[str] = mapped_column(
|
||||||
|
String(30),
|
||||||
|
default=RuntimeNodeState.ACTIVE.value,
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||||
|
last_heartbeat_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||||
|
drain_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
drain_reason: Mapped[str | None] = mapped_column(String(500))
|
||||||
|
stopped_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, default=dict, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class DistributedLease(Base, TimestampMixin):
|
||||||
|
__tablename__ = "core_distributed_leases"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"installation_id",
|
||||||
|
"resource_key",
|
||||||
|
name="uq_core_distributed_lease_resource",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_core_distributed_leases_expiry",
|
||||||
|
"installation_id",
|
||||||
|
"expires_at",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
resource_key: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
holder_node_id: Mapped[str | None] = mapped_column(String(200), index=True)
|
||||||
|
holder_incarnation: Mapped[str | None] = mapped_column(String(36), index=True)
|
||||||
|
fencing_token: Mapped[int] = mapped_column(
|
||||||
|
BigInteger().with_variant(Integer, "sqlite"),
|
||||||
|
default=0,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
acquired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
renewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||||
|
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSON, default=dict, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RuntimeIdentity:
|
||||||
|
installation_id: str
|
||||||
|
node_id: str
|
||||||
|
incarnation: str
|
||||||
|
role: str
|
||||||
|
software_version: str
|
||||||
|
composition_hash: str
|
||||||
|
queues: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class LeaseClaim:
|
||||||
|
installation_id: str
|
||||||
|
resource_key: str
|
||||||
|
holder_node_id: str
|
||||||
|
holder_incarnation: str
|
||||||
|
fencing_token: int
|
||||||
|
expires_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_identity(
|
||||||
|
settings: object,
|
||||||
|
*,
|
||||||
|
software_version: str,
|
||||||
|
module_ids: tuple[str, ...] = (),
|
||||||
|
role: str | None = None,
|
||||||
|
node_id: str | None = None,
|
||||||
|
incarnation: str | None = None,
|
||||||
|
queues: tuple[str, ...] | None = None,
|
||||||
|
) -> RuntimeIdentity:
|
||||||
|
effective_role = str(
|
||||||
|
role or getattr(settings, "runtime_role", "api") or "api"
|
||||||
|
).strip().lower()
|
||||||
|
effective_node_id = str(
|
||||||
|
node_id
|
||||||
|
or getattr(settings, "runtime_node_id", None)
|
||||||
|
or os.getenv("HOSTNAME")
|
||||||
|
or socket.gethostname()
|
||||||
|
).strip()
|
||||||
|
installation_id = str(
|
||||||
|
getattr(settings, "installation_id", "govoplan-local")
|
||||||
|
or "govoplan-local"
|
||||||
|
).strip()
|
||||||
|
if not installation_id or not effective_node_id or not effective_role:
|
||||||
|
raise RuntimeCoordinationError(
|
||||||
|
"Runtime identity requires installation, node, and role identifiers"
|
||||||
|
)
|
||||||
|
effective_queues = queues
|
||||||
|
if effective_queues is None:
|
||||||
|
effective_queues = tuple(
|
||||||
|
item.strip()
|
||||||
|
for item in str(getattr(settings, "celery_queues", "") or "").split(",")
|
||||||
|
if item.strip()
|
||||||
|
)
|
||||||
|
composition_hash = hashlib.sha256(
|
||||||
|
json.dumps(sorted(module_ids), separators=(",", ":")).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
return RuntimeIdentity(
|
||||||
|
installation_id=installation_id,
|
||||||
|
node_id=effective_node_id,
|
||||||
|
incarnation=incarnation or str(uuid4()),
|
||||||
|
role=effective_role,
|
||||||
|
software_version=str(software_version),
|
||||||
|
composition_hash=composition_hash,
|
||||||
|
queues=tuple(sorted(set(effective_queues))),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_runtime_node(
|
||||||
|
session: Session,
|
||||||
|
identity: RuntimeIdentity,
|
||||||
|
*,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> RuntimeNode:
|
||||||
|
observed_at = _as_utc(now or utcnow())
|
||||||
|
_insert_node_placeholder(session, identity, observed_at)
|
||||||
|
node = _locked_node(session, identity.installation_id, identity.node_id)
|
||||||
|
if node is None: # pragma: no cover - guarded by insert/upsert
|
||||||
|
raise RuntimeCoordinationError("Runtime node registration was not persisted")
|
||||||
|
node.incarnation = identity.incarnation
|
||||||
|
node.role = identity.role
|
||||||
|
node.software_version = identity.software_version
|
||||||
|
node.composition_hash = identity.composition_hash
|
||||||
|
node.queues = list(identity.queues)
|
||||||
|
node.state = RuntimeNodeState.ACTIVE.value
|
||||||
|
node.started_at = observed_at
|
||||||
|
node.last_heartbeat_at = observed_at
|
||||||
|
node.drain_requested_at = None
|
||||||
|
node.drain_reason = None
|
||||||
|
node.stopped_at = None
|
||||||
|
node.metadata_ = dict(metadata or {})
|
||||||
|
session.add(node)
|
||||||
|
session.flush()
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
def heartbeat_runtime_node(
|
||||||
|
session: Session,
|
||||||
|
identity: RuntimeIdentity,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> RuntimeNode:
|
||||||
|
node = _locked_node(session, identity.installation_id, identity.node_id)
|
||||||
|
if node is None or node.incarnation != identity.incarnation:
|
||||||
|
raise RuntimeCoordinationError(
|
||||||
|
"Runtime heartbeat was rejected for a stale node incarnation"
|
||||||
|
)
|
||||||
|
if node.state == RuntimeNodeState.STOPPED.value:
|
||||||
|
raise RuntimeCoordinationError("Stopped runtime node cannot heartbeat")
|
||||||
|
node.last_heartbeat_at = _as_utc(now or utcnow())
|
||||||
|
if metadata is not None:
|
||||||
|
node.metadata_ = dict(metadata)
|
||||||
|
session.add(node)
|
||||||
|
session.flush()
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
def request_runtime_node_drain(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
installation_id: str,
|
||||||
|
node_id: str,
|
||||||
|
reason: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> RuntimeNode:
|
||||||
|
node = _locked_node(session, installation_id, node_id)
|
||||||
|
if node is None:
|
||||||
|
raise RuntimeCoordinationError("Runtime node was not found")
|
||||||
|
if node.state == RuntimeNodeState.STOPPED.value:
|
||||||
|
raise RuntimeCoordinationError("Stopped runtime node cannot be drained")
|
||||||
|
node.state = RuntimeNodeState.DRAINING.value
|
||||||
|
node.drain_requested_at = _as_utc(now or utcnow())
|
||||||
|
node.drain_reason = str(reason or "operator request")[:500]
|
||||||
|
session.add(node)
|
||||||
|
session.flush()
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_runtime_node_drain(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
installation_id: str,
|
||||||
|
node_id: str,
|
||||||
|
) -> RuntimeNode:
|
||||||
|
node = _locked_node(session, installation_id, node_id)
|
||||||
|
if node is None:
|
||||||
|
raise RuntimeCoordinationError("Runtime node was not found")
|
||||||
|
if node.state != RuntimeNodeState.DRAINING.value:
|
||||||
|
return node
|
||||||
|
node.state = RuntimeNodeState.ACTIVE.value
|
||||||
|
node.drain_requested_at = None
|
||||||
|
node.drain_reason = None
|
||||||
|
session.add(node)
|
||||||
|
session.flush()
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
def stop_runtime_node(
|
||||||
|
session: Session,
|
||||||
|
identity: RuntimeIdentity,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> bool:
|
||||||
|
node = _locked_node(session, identity.installation_id, identity.node_id)
|
||||||
|
if node is None or node.incarnation != identity.incarnation:
|
||||||
|
return False
|
||||||
|
observed_at = _as_utc(now or utcnow())
|
||||||
|
node.state = RuntimeNodeState.STOPPED.value
|
||||||
|
node.stopped_at = observed_at
|
||||||
|
node.last_heartbeat_at = observed_at
|
||||||
|
session.add(node)
|
||||||
|
session.flush()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_node_is_draining(
|
||||||
|
session: Session,
|
||||||
|
identity: RuntimeIdentity,
|
||||||
|
) -> bool:
|
||||||
|
node = session.execute(
|
||||||
|
select(RuntimeNode).where(
|
||||||
|
RuntimeNode.installation_id == identity.installation_id,
|
||||||
|
RuntimeNode.node_id == identity.node_id,
|
||||||
|
RuntimeNode.incarnation == identity.incarnation,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
return node is not None and node.state == RuntimeNodeState.DRAINING.value
|
||||||
|
|
||||||
|
|
||||||
|
def list_runtime_nodes(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
installation_id: str,
|
||||||
|
stale_after_seconds: int,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
observed_at = _as_utc(now or utcnow())
|
||||||
|
stale_before = observed_at - timedelta(seconds=max(1, stale_after_seconds))
|
||||||
|
nodes = session.execute(
|
||||||
|
select(RuntimeNode)
|
||||||
|
.where(RuntimeNode.installation_id == installation_id)
|
||||||
|
.order_by(RuntimeNode.role, RuntimeNode.node_id)
|
||||||
|
).scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"node_id": node.node_id,
|
||||||
|
"incarnation": node.incarnation,
|
||||||
|
"role": node.role,
|
||||||
|
"software_version": node.software_version,
|
||||||
|
"composition_hash": node.composition_hash,
|
||||||
|
"queues": list(node.queues or []),
|
||||||
|
"state": node.state,
|
||||||
|
"started_at": _iso(node.started_at),
|
||||||
|
"last_heartbeat_at": _iso(node.last_heartbeat_at),
|
||||||
|
"drain_requested_at": _iso(node.drain_requested_at),
|
||||||
|
"drain_reason": node.drain_reason,
|
||||||
|
"stopped_at": _iso(node.stopped_at),
|
||||||
|
"stale": (
|
||||||
|
node.state != RuntimeNodeState.STOPPED.value
|
||||||
|
and _as_utc(node.last_heartbeat_at) < stale_before
|
||||||
|
),
|
||||||
|
"metadata": dict(node.metadata_ or {}),
|
||||||
|
}
|
||||||
|
for node in nodes
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def acquire_lease(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
installation_id: str,
|
||||||
|
resource_key: str,
|
||||||
|
holder_node_id: str,
|
||||||
|
holder_incarnation: str,
|
||||||
|
ttl_seconds: int,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> LeaseClaim | None:
|
||||||
|
if ttl_seconds < 1:
|
||||||
|
raise ValueError("Lease TTL must be at least one second")
|
||||||
|
observed_at = _as_utc(now or utcnow())
|
||||||
|
_insert_lease_placeholder(session, installation_id, resource_key, observed_at)
|
||||||
|
lease = _locked_lease(session, installation_id, resource_key)
|
||||||
|
if lease is None: # pragma: no cover - guarded by insert/upsert
|
||||||
|
raise RuntimeCoordinationError("Distributed lease was not persisted")
|
||||||
|
same_holder = (
|
||||||
|
lease.holder_node_id == holder_node_id
|
||||||
|
and lease.holder_incarnation == holder_incarnation
|
||||||
|
)
|
||||||
|
expired = _as_utc(lease.expires_at) <= observed_at
|
||||||
|
if lease.holder_node_id is not None and not same_holder and not expired:
|
||||||
|
return None
|
||||||
|
if not same_holder or expired:
|
||||||
|
lease.fencing_token = int(lease.fencing_token or 0) + 1
|
||||||
|
lease.acquired_at = observed_at
|
||||||
|
lease.holder_node_id = holder_node_id
|
||||||
|
lease.holder_incarnation = holder_incarnation
|
||||||
|
lease.renewed_at = observed_at
|
||||||
|
lease.expires_at = observed_at + timedelta(seconds=ttl_seconds)
|
||||||
|
lease.metadata_ = dict(metadata or {})
|
||||||
|
session.add(lease)
|
||||||
|
session.flush()
|
||||||
|
return _lease_claim(lease)
|
||||||
|
|
||||||
|
|
||||||
|
def renew_lease(
|
||||||
|
session: Session,
|
||||||
|
claim: LeaseClaim,
|
||||||
|
*,
|
||||||
|
ttl_seconds: int,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> LeaseClaim:
|
||||||
|
if ttl_seconds < 1:
|
||||||
|
raise ValueError("Lease TTL must be at least one second")
|
||||||
|
observed_at = _as_utc(now or utcnow())
|
||||||
|
lease = _locked_lease(session, claim.installation_id, claim.resource_key)
|
||||||
|
_assert_matching_fence(lease, claim, observed_at=observed_at)
|
||||||
|
lease.renewed_at = observed_at
|
||||||
|
lease.expires_at = observed_at + timedelta(seconds=ttl_seconds)
|
||||||
|
session.add(lease)
|
||||||
|
session.flush()
|
||||||
|
return _lease_claim(lease)
|
||||||
|
|
||||||
|
|
||||||
|
def release_lease(
|
||||||
|
session: Session,
|
||||||
|
claim: LeaseClaim,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> None:
|
||||||
|
observed_at = _as_utc(now or utcnow())
|
||||||
|
lease = _locked_lease(session, claim.installation_id, claim.resource_key)
|
||||||
|
_assert_matching_fence(lease, claim, observed_at=observed_at)
|
||||||
|
lease.holder_node_id = None
|
||||||
|
lease.holder_incarnation = None
|
||||||
|
lease.renewed_at = observed_at
|
||||||
|
lease.expires_at = observed_at
|
||||||
|
session.add(lease)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def assert_lease_fence(
|
||||||
|
session: Session,
|
||||||
|
claim: LeaseClaim,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> None:
|
||||||
|
lease = _locked_lease(session, claim.installation_id, claim.resource_key)
|
||||||
|
_assert_matching_fence(
|
||||||
|
lease,
|
||||||
|
claim,
|
||||||
|
observed_at=_as_utc(now or utcnow()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_node_placeholder(
|
||||||
|
session: Session,
|
||||||
|
identity: RuntimeIdentity,
|
||||||
|
observed_at: datetime,
|
||||||
|
) -> None:
|
||||||
|
values = {
|
||||||
|
"id": str(uuid4()),
|
||||||
|
"installation_id": identity.installation_id,
|
||||||
|
"node_id": identity.node_id,
|
||||||
|
"incarnation": identity.incarnation,
|
||||||
|
"role": identity.role,
|
||||||
|
"software_version": identity.software_version,
|
||||||
|
"composition_hash": identity.composition_hash,
|
||||||
|
"queues": list(identity.queues),
|
||||||
|
"state": RuntimeNodeState.ACTIVE.value,
|
||||||
|
"started_at": observed_at,
|
||||||
|
"last_heartbeat_at": observed_at,
|
||||||
|
"metadata": {},
|
||||||
|
"created_at": observed_at,
|
||||||
|
"updated_at": observed_at,
|
||||||
|
}
|
||||||
|
_insert_do_nothing(
|
||||||
|
session,
|
||||||
|
RuntimeNode.__table__,
|
||||||
|
values,
|
||||||
|
conflict_columns=("installation_id", "node_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_lease_placeholder(
|
||||||
|
session: Session,
|
||||||
|
installation_id: str,
|
||||||
|
resource_key: str,
|
||||||
|
observed_at: datetime,
|
||||||
|
) -> None:
|
||||||
|
values = {
|
||||||
|
"id": str(uuid4()),
|
||||||
|
"installation_id": installation_id,
|
||||||
|
"resource_key": resource_key,
|
||||||
|
"fencing_token": 0,
|
||||||
|
"expires_at": observed_at,
|
||||||
|
"metadata": {},
|
||||||
|
"created_at": observed_at,
|
||||||
|
"updated_at": observed_at,
|
||||||
|
}
|
||||||
|
_insert_do_nothing(
|
||||||
|
session,
|
||||||
|
DistributedLease.__table__,
|
||||||
|
values,
|
||||||
|
conflict_columns=("installation_id", "resource_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_do_nothing(
|
||||||
|
session: Session,
|
||||||
|
table: Any,
|
||||||
|
values: dict[str, Any],
|
||||||
|
*,
|
||||||
|
conflict_columns: tuple[str, ...],
|
||||||
|
) -> None:
|
||||||
|
dialect = session.get_bind().dialect.name
|
||||||
|
if dialect == "postgresql":
|
||||||
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
|
|
||||||
|
statement = insert(table).values(**values).on_conflict_do_nothing(
|
||||||
|
index_elements=list(conflict_columns)
|
||||||
|
)
|
||||||
|
elif dialect == "sqlite":
|
||||||
|
from sqlalchemy.dialects.sqlite import insert
|
||||||
|
|
||||||
|
statement = insert(table).values(**values).on_conflict_do_nothing(
|
||||||
|
index_elements=list(conflict_columns)
|
||||||
|
)
|
||||||
|
else: # pragma: no cover - GovOPlaN supports PostgreSQL and dev SQLite
|
||||||
|
raise RuntimeCoordinationError(
|
||||||
|
f"Distributed coordination is unsupported on {dialect!r}"
|
||||||
|
)
|
||||||
|
session.execute(statement)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _locked_node(
|
||||||
|
session: Session,
|
||||||
|
installation_id: str,
|
||||||
|
node_id: str,
|
||||||
|
) -> RuntimeNode | None:
|
||||||
|
return session.execute(
|
||||||
|
select(RuntimeNode)
|
||||||
|
.where(
|
||||||
|
RuntimeNode.installation_id == installation_id,
|
||||||
|
RuntimeNode.node_id == node_id,
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def _locked_lease(
|
||||||
|
session: Session,
|
||||||
|
installation_id: str,
|
||||||
|
resource_key: str,
|
||||||
|
) -> DistributedLease | None:
|
||||||
|
return session.execute(
|
||||||
|
select(DistributedLease)
|
||||||
|
.where(
|
||||||
|
DistributedLease.installation_id == installation_id,
|
||||||
|
DistributedLease.resource_key == resource_key,
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_matching_fence(
|
||||||
|
lease: DistributedLease | None,
|
||||||
|
claim: LeaseClaim,
|
||||||
|
*,
|
||||||
|
observed_at: datetime,
|
||||||
|
) -> None:
|
||||||
|
if (
|
||||||
|
lease is None
|
||||||
|
or lease.holder_node_id != claim.holder_node_id
|
||||||
|
or lease.holder_incarnation != claim.holder_incarnation
|
||||||
|
or int(lease.fencing_token) != claim.fencing_token
|
||||||
|
or _as_utc(lease.expires_at) <= observed_at
|
||||||
|
):
|
||||||
|
raise StaleFence(
|
||||||
|
f"Lease fence is stale for resource {claim.resource_key!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _lease_claim(lease: DistributedLease) -> LeaseClaim:
|
||||||
|
if lease.holder_node_id is None or lease.holder_incarnation is None:
|
||||||
|
raise LeaseUnavailable("Distributed lease has no active holder")
|
||||||
|
return LeaseClaim(
|
||||||
|
installation_id=lease.installation_id,
|
||||||
|
resource_key=lease.resource_key,
|
||||||
|
holder_node_id=lease.holder_node_id,
|
||||||
|
holder_incarnation=lease.holder_incarnation,
|
||||||
|
fencing_token=int(lease.fencing_token),
|
||||||
|
expires_at=_as_utc(lease.expires_at),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_utc(value: datetime) -> datetime:
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value: datetime | None) -> str | None:
|
||||||
|
return _as_utc(value).isoformat() if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DistributedLease",
|
||||||
|
"LeaseClaim",
|
||||||
|
"LeaseUnavailable",
|
||||||
|
"RuntimeCoordinationError",
|
||||||
|
"RuntimeIdentity",
|
||||||
|
"RuntimeNode",
|
||||||
|
"RuntimeNodeState",
|
||||||
|
"StaleFence",
|
||||||
|
"acquire_lease",
|
||||||
|
"assert_lease_fence",
|
||||||
|
"cancel_runtime_node_drain",
|
||||||
|
"heartbeat_runtime_node",
|
||||||
|
"list_runtime_nodes",
|
||||||
|
"register_runtime_node",
|
||||||
|
"release_lease",
|
||||||
|
"renew_lease",
|
||||||
|
"request_runtime_node_drain",
|
||||||
|
"runtime_identity",
|
||||||
|
"runtime_node_is_draining",
|
||||||
|
"stop_runtime_node",
|
||||||
|
]
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
SANCTIONS_SNAPSHOT_CONTRACT_VERSION = "1"
|
||||||
|
SANCTIONS_SCREENING_CONTRACT_VERSION = "1"
|
||||||
|
CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS = (
|
||||||
|
"connectors.sanctionsSnapshotProvider"
|
||||||
|
)
|
||||||
|
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING = (
|
||||||
|
"riskCompliance.sanctionsScreeningProvider"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsSnapshotReference:
|
||||||
|
ref: str
|
||||||
|
tenant_id: str
|
||||||
|
provider_id: str
|
||||||
|
publisher: str
|
||||||
|
jurisdiction: str
|
||||||
|
list_type: str
|
||||||
|
source_id: str
|
||||||
|
source_version: str
|
||||||
|
publication_at: datetime | None
|
||||||
|
effective_at: datetime | None
|
||||||
|
acquired_at: datetime
|
||||||
|
content_type: str
|
||||||
|
byte_count: int
|
||||||
|
sha256: str
|
||||||
|
parser_version: str
|
||||||
|
raw_evidence_ref: str
|
||||||
|
connector_run_id: str
|
||||||
|
signature_evidence: Mapping[str, object] = field(
|
||||||
|
default_factory=dict
|
||||||
|
)
|
||||||
|
licence_notes: str | None = None
|
||||||
|
trust_notes: str | None = None
|
||||||
|
transport_evidence: Mapping[str, object] = field(
|
||||||
|
default_factory=dict
|
||||||
|
)
|
||||||
|
contract_version: str = SANCTIONS_SNAPSHOT_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.contract_version != SANCTIONS_SNAPSHOT_CONTRACT_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
"Unsupported sanctions snapshot contract version."
|
||||||
|
)
|
||||||
|
required = (
|
||||||
|
self.ref,
|
||||||
|
self.tenant_id,
|
||||||
|
self.provider_id,
|
||||||
|
self.publisher,
|
||||||
|
self.jurisdiction,
|
||||||
|
self.list_type,
|
||||||
|
self.source_id,
|
||||||
|
self.source_version,
|
||||||
|
self.content_type,
|
||||||
|
self.sha256,
|
||||||
|
self.parser_version,
|
||||||
|
self.raw_evidence_ref,
|
||||||
|
self.connector_run_id,
|
||||||
|
)
|
||||||
|
if not all(value.strip() for value in required):
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions snapshot references require complete provenance."
|
||||||
|
)
|
||||||
|
if self.byte_count < 1:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions snapshot evidence must not be empty."
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
len(self.sha256) != 64
|
||||||
|
or any(character not in "0123456789abcdef" for character in self.sha256)
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions snapshot SHA-256 evidence is invalid."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsSnapshotPayload:
|
||||||
|
snapshot: SanctionsSnapshotReference
|
||||||
|
content: bytes
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if len(self.content) != self.snapshot.byte_count:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions snapshot content length does not match metadata."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsScreeningSubject:
|
||||||
|
subject_type: Literal["person", "entity"]
|
||||||
|
primary_name: str | None = None
|
||||||
|
subject_ref: str | None = None
|
||||||
|
aliases: tuple[str, ...] = ()
|
||||||
|
identifiers: tuple[Mapping[str, str], ...] = ()
|
||||||
|
dates: tuple[str, ...] = ()
|
||||||
|
addresses: tuple[Mapping[str, str], ...] = ()
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.subject_type not in {"person", "entity"}:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening subjects must be a person or entity."
|
||||||
|
)
|
||||||
|
if not str(self.primary_name or "").strip() and not any(
|
||||||
|
str(value.get("value") or "").strip()
|
||||||
|
for value in self.identifiers
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"A sanctions screening subject needs a name or identifier."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsScreeningPolicy:
|
||||||
|
fuzzy_threshold: float = 0.88
|
||||||
|
max_snapshot_age_days: int = 7
|
||||||
|
max_candidates: int = 100
|
||||||
|
failure_policy: Literal["block", "review", "degraded"] = "block"
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not 0.8 <= self.fuzzy_threshold <= 1:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening fuzzy threshold must be between 0.8 and 1."
|
||||||
|
)
|
||||||
|
if not 1 <= self.max_snapshot_age_days <= 365:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions snapshot age must be between 1 and 365 days."
|
||||||
|
)
|
||||||
|
if not 1 <= self.max_candidates <= 100:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening candidate limit must be between 1 and 100."
|
||||||
|
)
|
||||||
|
if self.failure_policy not in {"block", "review", "degraded"}:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening failure policy is not supported."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsScreeningRequest:
|
||||||
|
list_snapshot_id: str
|
||||||
|
idempotency_key: str
|
||||||
|
subject: SanctionsScreeningSubject
|
||||||
|
policy: SanctionsScreeningPolicy = field(
|
||||||
|
default_factory=SanctionsScreeningPolicy
|
||||||
|
)
|
||||||
|
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
"Unsupported sanctions screening contract version."
|
||||||
|
)
|
||||||
|
if not self.list_snapshot_id.strip():
|
||||||
|
raise ValueError(
|
||||||
|
"A sanctions list snapshot is required for screening."
|
||||||
|
)
|
||||||
|
if not self.idempotency_key.strip():
|
||||||
|
raise ValueError(
|
||||||
|
"A sanctions screening idempotency key is required."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsScreeningEvidence:
|
||||||
|
ref: str
|
||||||
|
run_id: str
|
||||||
|
outcome: str
|
||||||
|
candidate_count: int
|
||||||
|
list_snapshot_id: str
|
||||||
|
source_version: str
|
||||||
|
subject_fingerprint: str
|
||||||
|
matcher_version: str
|
||||||
|
normalization_version: str
|
||||||
|
policy_version: str
|
||||||
|
completed_at: datetime | None
|
||||||
|
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
"Unsupported sanctions screening evidence version."
|
||||||
|
)
|
||||||
|
if self.ref != f"risk-screening:{self.run_id}":
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening evidence reference is invalid."
|
||||||
|
)
|
||||||
|
if self.candidate_count < 0:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening candidate count cannot be negative."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsScreeningFreshnessRequest:
|
||||||
|
evidence_ref: str
|
||||||
|
current_subject: SanctionsScreeningSubject | None = None
|
||||||
|
expected_list_snapshot_id: str | None = None
|
||||||
|
policy: SanctionsScreeningPolicy = field(
|
||||||
|
default_factory=SanctionsScreeningPolicy
|
||||||
|
)
|
||||||
|
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
"Unsupported sanctions screening freshness contract version."
|
||||||
|
)
|
||||||
|
if not self.evidence_ref.startswith("risk-screening:"):
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening evidence reference is invalid."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsScreeningFreshness:
|
||||||
|
evidence: SanctionsScreeningEvidence
|
||||||
|
fresh: bool
|
||||||
|
reasons: tuple[str, ...]
|
||||||
|
checked_at: datetime
|
||||||
|
current_list_snapshot_id: str | None
|
||||||
|
gate_decision: Literal["allow", "block", "review", "degraded"]
|
||||||
|
gate_reasons: tuple[str, ...]
|
||||||
|
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
"Unsupported sanctions screening freshness version."
|
||||||
|
)
|
||||||
|
if self.fresh != (not self.reasons):
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening freshness reasons are inconsistent."
|
||||||
|
)
|
||||||
|
if self.gate_decision not in {
|
||||||
|
"allow",
|
||||||
|
"block",
|
||||||
|
"review",
|
||||||
|
"degraded",
|
||||||
|
}:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening gate decision is invalid."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsScreeningResult:
|
||||||
|
evidence: SanctionsScreeningEvidence
|
||||||
|
freshness: SanctionsScreeningFreshness
|
||||||
|
created: bool
|
||||||
|
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
"Unsupported sanctions screening result version."
|
||||||
|
)
|
||||||
|
if self.evidence != self.freshness.evidence:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening result evidence is inconsistent."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class SanctionsSnapshotProvider(Protocol):
|
||||||
|
def list_snapshots(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[SanctionsSnapshotReference]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_snapshot(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
snapshot_ref: str,
|
||||||
|
) -> SanctionsSnapshotReference | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def read_snapshot(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
snapshot_ref: str,
|
||||||
|
) -> SanctionsSnapshotPayload:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class SanctionsScreeningProvider(Protocol):
|
||||||
|
def request_screening(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
request: SanctionsScreeningRequest,
|
||||||
|
) -> SanctionsScreeningResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
def check_freshness(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
request: SanctionsScreeningFreshnessRequest,
|
||||||
|
) -> SanctionsScreeningFreshness:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def sanctions_snapshot_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> SanctionsSnapshotProvider | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(
|
||||||
|
CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
provider = registry.capability(
|
||||||
|
CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
provider
|
||||||
|
if isinstance(provider, SanctionsSnapshotProvider)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sanctions_screening_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> SanctionsScreeningProvider | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(
|
||||||
|
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
provider = registry.capability(
|
||||||
|
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
provider
|
||||||
|
if isinstance(provider, SanctionsScreeningProvider)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS",
|
||||||
|
"CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING",
|
||||||
|
"SANCTIONS_SCREENING_CONTRACT_VERSION",
|
||||||
|
"SANCTIONS_SNAPSHOT_CONTRACT_VERSION",
|
||||||
|
"SanctionsScreeningEvidence",
|
||||||
|
"SanctionsScreeningFreshness",
|
||||||
|
"SanctionsScreeningFreshnessRequest",
|
||||||
|
"SanctionsScreeningPolicy",
|
||||||
|
"SanctionsScreeningProvider",
|
||||||
|
"SanctionsScreeningRequest",
|
||||||
|
"SanctionsScreeningResult",
|
||||||
|
"SanctionsScreeningSubject",
|
||||||
|
"SanctionsSnapshotPayload",
|
||||||
|
"SanctionsSnapshotProvider",
|
||||||
|
"SanctionsSnapshotReference",
|
||||||
|
"sanctions_screening_provider",
|
||||||
|
"sanctions_snapshot_provider",
|
||||||
|
]
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from govoplan_core.core.external_references import ExternalObjectReference
|
||||||
|
from govoplan_core.core.modules import ModuleContext
|
||||||
|
|
||||||
|
|
||||||
|
SearchContextKind = Literal["global", "module", "resource"]
|
||||||
|
SearchVisibility = Literal["tenant", "restricted"]
|
||||||
|
SearchIndexChangeKind = Literal["upsert", "delete"]
|
||||||
|
SEARCH_SOURCE_CONTRACT_VERSION = "1"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchQuery:
|
||||||
|
text: str
|
||||||
|
tenant_id: str
|
||||||
|
module_ids: tuple[str, ...] = ()
|
||||||
|
resource_types: tuple[str, ...] = ()
|
||||||
|
context_kind: SearchContextKind = "global"
|
||||||
|
context_id: str | None = None
|
||||||
|
limit: int = 25
|
||||||
|
offset: int = 0
|
||||||
|
cursor: str | None = None
|
||||||
|
language: str = "simple"
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
normalized_text = self.text.strip()
|
||||||
|
if len(normalized_text) > 500:
|
||||||
|
raise ValueError("Search text is limited to 500 characters.")
|
||||||
|
if not self.tenant_id.strip():
|
||||||
|
raise ValueError("Search queries require a tenant.")
|
||||||
|
if not 1 <= self.limit <= 200:
|
||||||
|
raise ValueError("Search result limits must be between 1 and 200.")
|
||||||
|
if self.offset < 0:
|
||||||
|
raise ValueError("Search offsets cannot be negative.")
|
||||||
|
if self.cursor and self.offset:
|
||||||
|
raise ValueError(
|
||||||
|
"Search cursor and offset pagination cannot be combined."
|
||||||
|
)
|
||||||
|
if not self.language.replace("_", "").isalnum():
|
||||||
|
raise ValueError("Search language configuration is invalid.")
|
||||||
|
if len(self.module_ids) > 50 or len(self.resource_types) > 50:
|
||||||
|
raise ValueError(
|
||||||
|
"Search queries support at most 50 module and type filters."
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "text", normalized_text)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchResult:
|
||||||
|
provider_id: str
|
||||||
|
module_id: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
title: str
|
||||||
|
url: str
|
||||||
|
summary: str | None = None
|
||||||
|
score: float = 0.0
|
||||||
|
highlights: tuple[str, ...] = ()
|
||||||
|
breadcrumbs: tuple[str, ...] = ()
|
||||||
|
external_reference: ExternalObjectReference | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
source_revision: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchResourceReference:
|
||||||
|
tenant_id: str
|
||||||
|
module_id: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for field_name in (
|
||||||
|
"tenant_id",
|
||||||
|
"module_id",
|
||||||
|
"resource_type",
|
||||||
|
"resource_id",
|
||||||
|
):
|
||||||
|
if not str(getattr(self, field_name) or "").strip():
|
||||||
|
raise ValueError(
|
||||||
|
f"Search resource reference {field_name} is required."
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key(self) -> str:
|
||||||
|
values = (
|
||||||
|
self.tenant_id,
|
||||||
|
self.module_id,
|
||||||
|
self.resource_type,
|
||||||
|
self.resource_id,
|
||||||
|
)
|
||||||
|
return "|".join(
|
||||||
|
f"{len(value)}:{value}"
|
||||||
|
for value in values
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchResourceType:
|
||||||
|
provider_id: str
|
||||||
|
module_id: str
|
||||||
|
resource_type: str
|
||||||
|
label: str
|
||||||
|
index_version: int = 1
|
||||||
|
language: str = "simple"
|
||||||
|
requires_authorization_recheck: bool = True
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not all(
|
||||||
|
value.strip()
|
||||||
|
for value in (
|
||||||
|
self.provider_id,
|
||||||
|
self.module_id,
|
||||||
|
self.resource_type,
|
||||||
|
self.label,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Search resource type identifiers and label are required."
|
||||||
|
)
|
||||||
|
if self.index_version < 1:
|
||||||
|
raise ValueError("Search index versions start at one.")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchDocument:
|
||||||
|
tenant_id: str
|
||||||
|
module_id: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
title: str
|
||||||
|
url: str
|
||||||
|
summary: str | None = None
|
||||||
|
body: str | None = None
|
||||||
|
keywords: tuple[str, ...] = ()
|
||||||
|
visibility: SearchVisibility = "restricted"
|
||||||
|
acl_tokens: tuple[str, ...] = ()
|
||||||
|
external_reference: ExternalObjectReference | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
provider_id: str | None = None
|
||||||
|
source_revision: str = "1"
|
||||||
|
change_cursor: str | None = None
|
||||||
|
source_updated_at: datetime | None = None
|
||||||
|
language: str = "simple"
|
||||||
|
index_version: int = 1
|
||||||
|
requires_authorization_recheck: bool = False
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
required = {
|
||||||
|
"tenant_id": self.tenant_id,
|
||||||
|
"module_id": self.module_id,
|
||||||
|
"resource_type": self.resource_type,
|
||||||
|
"resource_id": self.resource_id,
|
||||||
|
"title": self.title,
|
||||||
|
"url": self.url,
|
||||||
|
}
|
||||||
|
for field_name, value in required.items():
|
||||||
|
if not str(value or "").strip():
|
||||||
|
raise ValueError(f"Search document {field_name} is required.")
|
||||||
|
limits = {
|
||||||
|
"module_id": 100,
|
||||||
|
"resource_type": 100,
|
||||||
|
"resource_id": 255,
|
||||||
|
"title": 500,
|
||||||
|
"url": 1500,
|
||||||
|
"summary": 4_000,
|
||||||
|
"body": 200_000,
|
||||||
|
"source_revision": 255,
|
||||||
|
"change_cursor": 500,
|
||||||
|
"language": 32,
|
||||||
|
}
|
||||||
|
for field_name, limit in limits.items():
|
||||||
|
value = getattr(self, field_name)
|
||||||
|
if value is not None and len(str(value)) > limit:
|
||||||
|
raise ValueError(
|
||||||
|
f"Search document {field_name} is limited to "
|
||||||
|
f"{limit} characters."
|
||||||
|
)
|
||||||
|
if len(self.keywords) > 100 or any(
|
||||||
|
len(keyword) > 200
|
||||||
|
for keyword in self.keywords
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Search documents support at most 100 bounded keywords."
|
||||||
|
)
|
||||||
|
if len(self.acl_tokens) > 500 or any(
|
||||||
|
len(token) > 500
|
||||||
|
for token in self.acl_tokens
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Search documents support at most 500 bounded ACL tokens."
|
||||||
|
)
|
||||||
|
if self.visibility == "restricted" and not self.acl_tokens:
|
||||||
|
raise ValueError(
|
||||||
|
"Restricted search documents require at least one ACL token."
|
||||||
|
)
|
||||||
|
if not self.source_revision.strip():
|
||||||
|
raise ValueError(
|
||||||
|
"Search documents require a stable source revision."
|
||||||
|
)
|
||||||
|
if self.index_version < 1:
|
||||||
|
raise ValueError("Search document index versions start at one.")
|
||||||
|
if (
|
||||||
|
self.requires_authorization_recheck
|
||||||
|
and not str(self.provider_id or "").strip()
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Search documents requiring authorization rechecks must "
|
||||||
|
"name their source provider."
|
||||||
|
)
|
||||||
|
if self.provider_id is not None and len(self.provider_id) > 200:
|
||||||
|
raise ValueError(
|
||||||
|
"Search document provider_id is limited to 200 characters."
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reference(self) -> SearchResourceReference:
|
||||||
|
return SearchResourceReference(
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
module_id=self.module_id,
|
||||||
|
resource_type=self.resource_type,
|
||||||
|
resource_id=self.resource_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchAuthorizationRequest:
|
||||||
|
reference: SearchResourceReference
|
||||||
|
source_revision: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchBackfillRequest:
|
||||||
|
tenant_id: str
|
||||||
|
provider_id: str
|
||||||
|
resource_type: str
|
||||||
|
rebuild_id: str
|
||||||
|
cursor: str | None = None
|
||||||
|
limit: int = 100
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not 1 <= self.limit <= 500:
|
||||||
|
raise ValueError(
|
||||||
|
"Search backfill page size must be between 1 and 500."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchBackfillPage:
|
||||||
|
documents: tuple[SearchDocument, ...]
|
||||||
|
next_cursor: str | None
|
||||||
|
complete: bool
|
||||||
|
high_watermark: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if len(self.documents) > 500:
|
||||||
|
raise ValueError(
|
||||||
|
"Search backfill providers returned more than 500 documents."
|
||||||
|
)
|
||||||
|
if not self.complete and not self.next_cursor:
|
||||||
|
raise ValueError(
|
||||||
|
"Incomplete search backfill pages require a next cursor."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchIndexChange:
|
||||||
|
change_id: str
|
||||||
|
provider_id: str
|
||||||
|
kind: SearchIndexChangeKind
|
||||||
|
reference: SearchResourceReference
|
||||||
|
source_revision: str
|
||||||
|
cursor: str
|
||||||
|
document: SearchDocument | None = None
|
||||||
|
occurred_at: datetime | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not all(
|
||||||
|
value.strip()
|
||||||
|
for value in (
|
||||||
|
self.change_id,
|
||||||
|
self.provider_id,
|
||||||
|
self.source_revision,
|
||||||
|
self.cursor,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Search index changes require stable identity and revision."
|
||||||
|
)
|
||||||
|
if self.kind == "upsert":
|
||||||
|
if (
|
||||||
|
self.document is None
|
||||||
|
or self.document.reference != self.reference
|
||||||
|
or self.document.provider_id != self.provider_id
|
||||||
|
or self.document.source_revision
|
||||||
|
!= self.source_revision
|
||||||
|
or self.document.change_cursor != self.cursor
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Search upserts require a matching document."
|
||||||
|
)
|
||||||
|
elif self.document is not None:
|
||||||
|
raise ValueError(
|
||||||
|
"Search deletes must not contain a document."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class SearchProvider(Protocol):
|
||||||
|
def search(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: SearchQuery,
|
||||||
|
) -> Sequence[SearchResult]:
|
||||||
|
"""Return only results the principal may currently read."""
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class SearchIndexWriter(Protocol):
|
||||||
|
def upsert_document(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
document: SearchDocument,
|
||||||
|
) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def delete_document(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
module_id: str,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
) -> bool:
|
||||||
|
...
|
||||||
|
|
||||||
|
def enqueue_change(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
change: SearchIndexChange,
|
||||||
|
) -> bool:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class SearchSourceProvider(Protocol):
|
||||||
|
def resource_types(self) -> Sequence[SearchResourceType]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def backfill(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
request: SearchBackfillRequest,
|
||||||
|
) -> SearchBackfillPage:
|
||||||
|
...
|
||||||
|
|
||||||
|
def authorize(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
requests: Sequence[SearchAuthorizationRequest],
|
||||||
|
) -> Mapping[str, bool]:
|
||||||
|
"""Return an explicit decision for every requested reference key."""
|
||||||
|
|
||||||
|
|
||||||
|
SearchProviderFactory = Callable[[ModuleContext], SearchProvider]
|
||||||
|
SearchSourceProviderFactory = Callable[
|
||||||
|
[ModuleContext],
|
||||||
|
SearchSourceProvider,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchProviderRegistration:
|
||||||
|
id: str
|
||||||
|
factory: SearchProviderFactory
|
||||||
|
resource_types: tuple[str, ...] = ()
|
||||||
|
order: int = 100
|
||||||
|
|
||||||
|
def create(self, context: ModuleContext) -> SearchProvider:
|
||||||
|
provider = self.factory(context)
|
||||||
|
if not isinstance(provider, SearchProvider):
|
||||||
|
raise TypeError(
|
||||||
|
f"Search provider {self.id!r} does not implement SearchProvider."
|
||||||
|
)
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RegisteredSearchProvider:
|
||||||
|
module_id: str
|
||||||
|
registration: SearchProviderRegistration
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchSourceProviderRegistration:
|
||||||
|
id: str
|
||||||
|
factory: SearchSourceProviderFactory
|
||||||
|
order: int = 100
|
||||||
|
|
||||||
|
def create(self, context: ModuleContext) -> SearchSourceProvider:
|
||||||
|
provider = self.factory(context)
|
||||||
|
if not isinstance(provider, SearchSourceProvider):
|
||||||
|
raise TypeError(
|
||||||
|
f"Search source {self.id!r} does not implement "
|
||||||
|
"SearchSourceProvider."
|
||||||
|
)
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RegisteredSearchSourceProvider:
|
||||||
|
module_id: str
|
||||||
|
registration: SearchSourceProviderRegistration
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_SEARCH_INDEX_WRITER = "search.index_writer"
|
||||||
|
|
||||||
|
|
||||||
|
def search_index_writer(registry: object | None) -> SearchIndexWriter | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_SEARCH_INDEX_WRITER)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
writer = registry.capability(CAPABILITY_SEARCH_INDEX_WRITER)
|
||||||
|
return writer if isinstance(writer, SearchIndexWriter) else None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_SEARCH_INDEX_WRITER",
|
||||||
|
"SEARCH_SOURCE_CONTRACT_VERSION",
|
||||||
|
"RegisteredSearchProvider",
|
||||||
|
"RegisteredSearchSourceProvider",
|
||||||
|
"SearchAuthorizationRequest",
|
||||||
|
"SearchBackfillPage",
|
||||||
|
"SearchBackfillRequest",
|
||||||
|
"SearchContextKind",
|
||||||
|
"SearchDocument",
|
||||||
|
"SearchIndexChange",
|
||||||
|
"SearchIndexChangeKind",
|
||||||
|
"SearchIndexWriter",
|
||||||
|
"SearchProvider",
|
||||||
|
"SearchProviderFactory",
|
||||||
|
"SearchProviderRegistration",
|
||||||
|
"SearchQuery",
|
||||||
|
"SearchResourceReference",
|
||||||
|
"SearchResourceType",
|
||||||
|
"SearchResult",
|
||||||
|
"SearchSourceProvider",
|
||||||
|
"SearchSourceProviderFactory",
|
||||||
|
"SearchSourceProviderRegistration",
|
||||||
|
"SearchVisibility",
|
||||||
|
"search_index_writer",
|
||||||
|
]
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_CONNECTORS_TABULAR_SOURCES = "connectors.tabularSources"
|
||||||
|
CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER = "connectors.tabularSnapshotWriter"
|
||||||
|
|
||||||
|
|
||||||
|
class TabularSourceError(ValueError):
|
||||||
|
"""Stable base error for provider-neutral tabular source operations."""
|
||||||
|
|
||||||
|
|
||||||
|
class TabularSourceNotFoundError(TabularSourceError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TabularSourceAccessError(TabularSourceError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TabularSourceValidationError(TabularSourceError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def parse_tabular_csv(
|
||||||
|
csv_text: str,
|
||||||
|
*,
|
||||||
|
delimiter: str = ",",
|
||||||
|
max_rows: int = 10_000,
|
||||||
|
) -> tuple[Mapping[str, object], ...]:
|
||||||
|
"""Parse a bounded CSV document into JSON-compatible tabular rows."""
|
||||||
|
|
||||||
|
if len(delimiter) != 1:
|
||||||
|
raise TabularSourceValidationError("CSV delimiter must be one character.")
|
||||||
|
try:
|
||||||
|
reader = csv.DictReader(io.StringIO(csv_text), delimiter=delimiter)
|
||||||
|
original_headers, normalized_headers = _csv_headers(reader.fieldnames)
|
||||||
|
rows: list[dict[str, object]] = []
|
||||||
|
for row in reader:
|
||||||
|
_validate_csv_row_shape(row)
|
||||||
|
if _csv_row_is_empty(row, original_headers):
|
||||||
|
continue
|
||||||
|
if len(rows) >= max_rows:
|
||||||
|
raise TabularSourceValidationError(
|
||||||
|
f"CSV snapshots are limited to {max_rows:,} rows."
|
||||||
|
)
|
||||||
|
rows.append(_csv_row(row, original_headers, normalized_headers))
|
||||||
|
return tuple(rows)
|
||||||
|
except csv.Error as exc:
|
||||||
|
raise TabularSourceValidationError(f"CSV input could not be parsed: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_headers(
|
||||||
|
fieldnames: Sequence[str | None] | None,
|
||||||
|
) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||||
|
if not fieldnames:
|
||||||
|
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
|
||||||
|
original = tuple(str(name or "") for name in fieldnames)
|
||||||
|
normalized = tuple(
|
||||||
|
name.strip().lstrip("\ufeff") if position == 0 else name.strip()
|
||||||
|
for position, name in enumerate(original)
|
||||||
|
)
|
||||||
|
if any(not name for name in normalized):
|
||||||
|
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
|
||||||
|
if len(set(normalized)) != len(normalized):
|
||||||
|
raise TabularSourceValidationError("CSV column names must be unique.")
|
||||||
|
return original, normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_csv_row_shape(row: Mapping[str | None, object]) -> None:
|
||||||
|
extras = row.get(None)
|
||||||
|
if isinstance(extras, list) and any(str(value or "").strip() for value in extras):
|
||||||
|
raise TabularSourceValidationError(
|
||||||
|
"A CSV row contains more values than the header defines."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_row_is_empty(
|
||||||
|
row: Mapping[str | None, str | list[str] | None],
|
||||||
|
headers: Sequence[str],
|
||||||
|
) -> bool:
|
||||||
|
return all(
|
||||||
|
value is None or isinstance(value, list) or not value.strip()
|
||||||
|
for value in (row.get(header) for header in headers)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_row(
|
||||||
|
row: Mapping[str | None, str | list[str] | None],
|
||||||
|
original_headers: Sequence[str],
|
||||||
|
normalized_headers: Sequence[str],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
normalized: _csv_scalar(value if isinstance(value, str) else None)
|
||||||
|
for original, normalized in zip(
|
||||||
|
original_headers,
|
||||||
|
normalized_headers,
|
||||||
|
strict=True,
|
||||||
|
)
|
||||||
|
for value in (row.get(original),)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_scalar(value: str | None) -> object:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = value.strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
lowered = text.casefold()
|
||||||
|
if lowered in {"true", "false"}:
|
||||||
|
return lowered == "true"
|
||||||
|
if re.fullmatch(r"-?(?:0|[1-9][0-9]*)", text):
|
||||||
|
return int(text)
|
||||||
|
if re.fullmatch(r"-?(?:0|[1-9][0-9]*)\.[0-9]+", text):
|
||||||
|
return float(text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TabularColumn:
|
||||||
|
name: str
|
||||||
|
data_type: str
|
||||||
|
nullable: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TabularSource:
|
||||||
|
"""Opaque, policy-filtered source reference exposed to consuming modules."""
|
||||||
|
|
||||||
|
ref: str
|
||||||
|
provider: str
|
||||||
|
source_name: str
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
schema: tuple[TabularColumn, ...] = ()
|
||||||
|
schema_version: str = "1"
|
||||||
|
fingerprint: str = ""
|
||||||
|
row_count: int | None = None
|
||||||
|
byte_count: int | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
capabilities: tuple[str, ...] = ("read",)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TabularReadRequest:
|
||||||
|
source_ref: str
|
||||||
|
limit: int = 250
|
||||||
|
offset: int = 0
|
||||||
|
columns: tuple[str, ...] = ()
|
||||||
|
expected_fingerprint: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TabularReadResult:
|
||||||
|
source: TabularSource
|
||||||
|
rows: tuple[Mapping[str, object], ...]
|
||||||
|
total_rows: int
|
||||||
|
truncated: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TabularSnapshotInput:
|
||||||
|
name: str
|
||||||
|
source_name: str
|
||||||
|
rows: tuple[Mapping[str, object], ...]
|
||||||
|
description: str | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class TabularSourceProvider(Protocol):
|
||||||
|
"""Principal-aware catalogue and bounded reader for tabular sources."""
|
||||||
|
|
||||||
|
def list_sources(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: str = "",
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[TabularSource]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_source(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
source_ref: str,
|
||||||
|
) -> TabularSource | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def read_source(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: TabularReadRequest,
|
||||||
|
) -> TabularReadResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class TabularSnapshotWriter(Protocol):
|
||||||
|
"""Optional capability for importing bounded static tabular snapshots."""
|
||||||
|
|
||||||
|
def create_snapshot(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
snapshot: TabularSnapshotInput,
|
||||||
|
) -> TabularSource:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def tabular_source_provider(registry: object | None) -> TabularSourceProvider | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_CONNECTORS_TABULAR_SOURCES)
|
||||||
|
return capability if isinstance(capability, TabularSourceProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def tabular_snapshot_writer(registry: object | None) -> TabularSnapshotWriter | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER)
|
||||||
|
return capability if isinstance(capability, TabularSnapshotWriter) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _capability(registry: object | None, name: str) -> object | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(name)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return registry.capability(name)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER",
|
||||||
|
"CAPABILITY_CONNECTORS_TABULAR_SOURCES",
|
||||||
|
"TabularColumn",
|
||||||
|
"TabularReadRequest",
|
||||||
|
"TabularReadResult",
|
||||||
|
"TabularSnapshotInput",
|
||||||
|
"TabularSnapshotWriter",
|
||||||
|
"TabularSource",
|
||||||
|
"TabularSourceAccessError",
|
||||||
|
"TabularSourceError",
|
||||||
|
"TabularSourceNotFoundError",
|
||||||
|
"TabularSourceProvider",
|
||||||
|
"TabularSourceValidationError",
|
||||||
|
"parse_tabular_csv",
|
||||||
|
"tabular_snapshot_writer",
|
||||||
|
"tabular_source_provider",
|
||||||
|
]
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable, Iterable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from redis import Redis
|
||||||
|
from redis.exceptions import RedisError
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_NAMESPACE_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]{0,99}$")
|
||||||
|
_REDIS_INCREMENT_SCRIPT = """
|
||||||
|
local count = redis.call('INCR', KEYS[1])
|
||||||
|
if count == 1 then
|
||||||
|
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
||||||
|
end
|
||||||
|
local ttl = redis.call('TTL', KEYS[1])
|
||||||
|
return {count, ttl}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FixedWindowBucket:
|
||||||
|
count: int = 0
|
||||||
|
retry_after_seconds: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ThrottleDimension:
|
||||||
|
"""One independently limited dimension without putting its value in keys."""
|
||||||
|
|
||||||
|
namespace: str
|
||||||
|
subject: str
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ThrottleDecision:
|
||||||
|
allowed: bool
|
||||||
|
retry_after_seconds: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class FixedWindowStore(Protocol):
|
||||||
|
def read(self, key: str) -> FixedWindowBucket: ...
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket: ...
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryFixedWindowStore:
|
||||||
|
"""Bounded process-local store for development and distributed-store loss."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
max_entries: int = 10_000,
|
||||||
|
clock: Callable[[], float] = time.monotonic,
|
||||||
|
) -> None:
|
||||||
|
self._entries: dict[str, tuple[int, float]] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._max_entries = max(2, max_entries)
|
||||||
|
self._clock = clock
|
||||||
|
|
||||||
|
def read(self, key: str) -> FixedWindowBucket:
|
||||||
|
now = self._clock()
|
||||||
|
with self._lock:
|
||||||
|
entry = self._active_entry(key, now=now)
|
||||||
|
if entry is None:
|
||||||
|
return FixedWindowBucket()
|
||||||
|
count, expires_at = entry
|
||||||
|
return FixedWindowBucket(
|
||||||
|
count=count,
|
||||||
|
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
|
||||||
|
now = self._clock()
|
||||||
|
with self._lock:
|
||||||
|
entry = self._active_entry(key, now=now)
|
||||||
|
if entry is None:
|
||||||
|
self._make_room(now=now, incoming_key=key)
|
||||||
|
count = 1
|
||||||
|
expires_at = now + window_seconds
|
||||||
|
else:
|
||||||
|
count = entry[0] + 1
|
||||||
|
expires_at = entry[1]
|
||||||
|
self._entries[key] = (count, expires_at)
|
||||||
|
return FixedWindowBucket(
|
||||||
|
count=count,
|
||||||
|
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._entries.pop(key, None)
|
||||||
|
|
||||||
|
def _active_entry(self, key: str, *, now: float) -> tuple[int, float] | None:
|
||||||
|
entry = self._entries.get(key)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
if entry[1] <= now:
|
||||||
|
self._entries.pop(key, None)
|
||||||
|
return None
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def _make_room(self, *, now: float, incoming_key: str) -> None:
|
||||||
|
if incoming_key in self._entries or len(self._entries) < self._max_entries:
|
||||||
|
return
|
||||||
|
for key, (_count, expires_at) in tuple(self._entries.items()):
|
||||||
|
if expires_at <= now:
|
||||||
|
self._entries.pop(key, None)
|
||||||
|
while len(self._entries) >= self._max_entries:
|
||||||
|
self._entries.pop(next(iter(self._entries)))
|
||||||
|
|
||||||
|
|
||||||
|
class RedisFixedWindowStore:
|
||||||
|
"""Atomic Redis fixed-window counters shared across API workers."""
|
||||||
|
|
||||||
|
def __init__(self, redis_url: str) -> None:
|
||||||
|
self._client = Redis.from_url(
|
||||||
|
redis_url,
|
||||||
|
decode_responses=True,
|
||||||
|
socket_connect_timeout=0.25,
|
||||||
|
socket_timeout=0.25,
|
||||||
|
health_check_interval=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
def read(self, key: str) -> FixedWindowBucket:
|
||||||
|
pipeline = self._client.pipeline(transaction=False)
|
||||||
|
pipeline.get(key)
|
||||||
|
pipeline.ttl(key)
|
||||||
|
raw_count, raw_ttl = pipeline.execute()
|
||||||
|
return FixedWindowBucket(
|
||||||
|
count=int(raw_count or 0),
|
||||||
|
retry_after_seconds=max(0, int(raw_ttl or 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
|
||||||
|
result = self._client.eval(
|
||||||
|
_REDIS_INCREMENT_SCRIPT,
|
||||||
|
1,
|
||||||
|
key,
|
||||||
|
window_seconds,
|
||||||
|
)
|
||||||
|
if not isinstance(result, (list, tuple)) or len(result) != 2:
|
||||||
|
raise RedisError("Unexpected fixed-window throttle response from Redis")
|
||||||
|
return FixedWindowBucket(
|
||||||
|
count=int(result[0]),
|
||||||
|
retry_after_seconds=max(1, int(result[1])),
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
self._client.delete(key)
|
||||||
|
|
||||||
|
|
||||||
|
class ResilientFixedWindowStore:
|
||||||
|
"""Mirror locally, prefer Redis, and fall back safely during an outage."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
primary: FixedWindowStore | None,
|
||||||
|
fallback: FixedWindowStore,
|
||||||
|
*,
|
||||||
|
retry_seconds: int = 30,
|
||||||
|
clock: Callable[[], float] = time.monotonic,
|
||||||
|
) -> None:
|
||||||
|
self._primary = primary
|
||||||
|
self._fallback = fallback
|
||||||
|
self._retry_seconds = max(1, retry_seconds)
|
||||||
|
self._clock = clock
|
||||||
|
self._primary_unavailable_until = 0.0
|
||||||
|
self._state_lock = threading.Lock()
|
||||||
|
|
||||||
|
def read(self, key: str) -> FixedWindowBucket:
|
||||||
|
fallback_result = self._fallback.read(key)
|
||||||
|
primary = self._available_primary()
|
||||||
|
if primary is None:
|
||||||
|
return fallback_result
|
||||||
|
try:
|
||||||
|
return _stricter_bucket(primary.read(key), fallback_result)
|
||||||
|
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||||
|
self._mark_primary_unavailable(exc)
|
||||||
|
return fallback_result
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
|
||||||
|
fallback_result = self._fallback.increment(
|
||||||
|
key,
|
||||||
|
window_seconds=window_seconds,
|
||||||
|
)
|
||||||
|
primary = self._available_primary()
|
||||||
|
if primary is None:
|
||||||
|
return fallback_result
|
||||||
|
try:
|
||||||
|
primary_result = primary.increment(
|
||||||
|
key,
|
||||||
|
window_seconds=window_seconds,
|
||||||
|
)
|
||||||
|
return _stricter_bucket(primary_result, fallback_result)
|
||||||
|
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||||
|
self._mark_primary_unavailable(exc)
|
||||||
|
return fallback_result
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
self._fallback.delete(key)
|
||||||
|
primary = self._available_primary()
|
||||||
|
if primary is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
primary.delete(key)
|
||||||
|
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||||
|
self._mark_primary_unavailable(exc)
|
||||||
|
|
||||||
|
def _available_primary(self) -> FixedWindowStore | None:
|
||||||
|
if self._primary is None:
|
||||||
|
return None
|
||||||
|
with self._state_lock:
|
||||||
|
if self._clock() < self._primary_unavailable_until:
|
||||||
|
return None
|
||||||
|
return self._primary
|
||||||
|
|
||||||
|
def _mark_primary_unavailable(self, exc: Exception) -> None:
|
||||||
|
should_log = False
|
||||||
|
with self._state_lock:
|
||||||
|
now = self._clock()
|
||||||
|
if now >= self._primary_unavailable_until:
|
||||||
|
should_log = True
|
||||||
|
self._primary_unavailable_until = now + self._retry_seconds
|
||||||
|
if should_log:
|
||||||
|
logger.warning(
|
||||||
|
"Distributed throttling is unavailable; using the process-local fallback (%s)",
|
||||||
|
type(exc).__name__,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stricter_bucket(
|
||||||
|
first: FixedWindowBucket,
|
||||||
|
second: FixedWindowBucket,
|
||||||
|
) -> FixedWindowBucket:
|
||||||
|
return FixedWindowBucket(
|
||||||
|
count=max(first.count, second.count),
|
||||||
|
retry_after_seconds=max(
|
||||||
|
first.retry_after_seconds,
|
||||||
|
second.retry_after_seconds,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FixedWindowThrottle:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: FixedWindowStore,
|
||||||
|
*,
|
||||||
|
window_seconds: int,
|
||||||
|
key_prefix: str = "govoplan:throttle:v1",
|
||||||
|
) -> None:
|
||||||
|
self._store = store
|
||||||
|
self._window_seconds = max(1, window_seconds)
|
||||||
|
self._key_prefix = key_prefix.rstrip(":")
|
||||||
|
|
||||||
|
def check(self, dimensions: Iterable[ThrottleDimension]) -> ThrottleDecision:
|
||||||
|
return self._decision(tuple(dimensions), increment=False)
|
||||||
|
|
||||||
|
def record(self, dimensions: Iterable[ThrottleDimension]) -> ThrottleDecision:
|
||||||
|
return self._decision(tuple(dimensions), increment=True)
|
||||||
|
|
||||||
|
def reset(self, dimensions: Iterable[ThrottleDimension]) -> None:
|
||||||
|
for dimension in tuple(dimensions):
|
||||||
|
self._store.delete(self._key(dimension))
|
||||||
|
|
||||||
|
def _decision(
|
||||||
|
self,
|
||||||
|
dimensions: tuple[ThrottleDimension, ...],
|
||||||
|
*,
|
||||||
|
increment: bool,
|
||||||
|
) -> ThrottleDecision:
|
||||||
|
if not dimensions:
|
||||||
|
raise ValueError("At least one throttle dimension is required")
|
||||||
|
blocked_retry_after = 0
|
||||||
|
for dimension in dimensions:
|
||||||
|
if dimension.limit < 1:
|
||||||
|
raise ValueError("Throttle limits must be positive")
|
||||||
|
key = self._key(dimension)
|
||||||
|
state = (
|
||||||
|
self._store.increment(key, window_seconds=self._window_seconds)
|
||||||
|
if increment
|
||||||
|
else self._store.read(key)
|
||||||
|
)
|
||||||
|
if state.count >= dimension.limit:
|
||||||
|
blocked_retry_after = max(
|
||||||
|
blocked_retry_after,
|
||||||
|
max(1, state.retry_after_seconds),
|
||||||
|
)
|
||||||
|
return ThrottleDecision(
|
||||||
|
allowed=blocked_retry_after == 0,
|
||||||
|
retry_after_seconds=blocked_retry_after,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _key(self, dimension: ThrottleDimension) -> str:
|
||||||
|
namespace = dimension.namespace.strip().casefold()
|
||||||
|
if not _NAMESPACE_PATTERN.fullmatch(namespace):
|
||||||
|
raise ValueError("Throttle namespaces must use lowercase letters, digits, '.', '_' or '-'")
|
||||||
|
subject_digest = hashlib.sha256(dimension.subject.encode("utf-8")).hexdigest()
|
||||||
|
return f"{self._key_prefix}:{namespace}:{subject_digest}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_fixed_window_throttle(
|
||||||
|
*,
|
||||||
|
redis_url: str | None,
|
||||||
|
window_seconds: int,
|
||||||
|
redis_retry_seconds: int = 30,
|
||||||
|
max_local_entries: int = 10_000,
|
||||||
|
key_prefix: str = "govoplan:throttle:v1",
|
||||||
|
) -> FixedWindowThrottle:
|
||||||
|
primary = (
|
||||||
|
RedisFixedWindowStore(redis_url)
|
||||||
|
if redis_url is not None and redis_url.strip()
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
store = ResilientFixedWindowStore(
|
||||||
|
primary,
|
||||||
|
InMemoryFixedWindowStore(max_entries=max_local_entries),
|
||||||
|
retry_seconds=redis_retry_seconds,
|
||||||
|
)
|
||||||
|
return FixedWindowThrottle(
|
||||||
|
store,
|
||||||
|
window_seconds=window_seconds,
|
||||||
|
key_prefix=key_prefix,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FixedWindowBucket",
|
||||||
|
"FixedWindowStore",
|
||||||
|
"FixedWindowThrottle",
|
||||||
|
"InMemoryFixedWindowStore",
|
||||||
|
"RedisFixedWindowStore",
|
||||||
|
"ResilientFixedWindowStore",
|
||||||
|
"ThrottleDecision",
|
||||||
|
"ThrottleDimension",
|
||||||
|
"build_fixed_window_throttle",
|
||||||
|
]
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
VIEWS_MODULE_ID = "views"
|
||||||
|
CAPABILITY_VIEWS_RESOLVER = "views.resolver"
|
||||||
|
VIEW_SURFACE_CONTRACT_VERSION = "1"
|
||||||
|
|
||||||
|
ViewSurfaceKind = Literal[
|
||||||
|
"module",
|
||||||
|
"navigation",
|
||||||
|
"route",
|
||||||
|
"section",
|
||||||
|
"action",
|
||||||
|
"selector",
|
||||||
|
]
|
||||||
|
|
||||||
|
_SURFACE_ID_RE = re.compile(r"^[a-z][a-z0-9_.-]{2,159}$")
|
||||||
|
_SURFACE_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ViewSurface:
|
||||||
|
id: str
|
||||||
|
module_id: str
|
||||||
|
kind: ViewSurfaceKind
|
||||||
|
label: str
|
||||||
|
parent_id: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
order: int = 100
|
||||||
|
default_visible: bool = True
|
||||||
|
required: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class EffectiveView:
|
||||||
|
view_id: str | None
|
||||||
|
revision_id: str | None
|
||||||
|
name: str | None
|
||||||
|
visible_surface_ids: frozenset[str]
|
||||||
|
locked: bool = False
|
||||||
|
projection_active: bool = False
|
||||||
|
provenance: tuple[dict[str, object], ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ViewResolver(Protocol):
|
||||||
|
def resolve_effective_view(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
account_id: str,
|
||||||
|
group_ids: Iterable[str] = (),
|
||||||
|
workflow_view_id: str | None = None,
|
||||||
|
workflow_revision_id: str | None = None,
|
||||||
|
workflow_surface_ids: Iterable[str] = (),
|
||||||
|
) -> EffectiveView: ...
|
||||||
|
|
||||||
|
|
||||||
|
def views_resolver(registry: object | None) -> ViewResolver | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_VIEWS_RESOLVER)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_VIEWS_RESOLVER)
|
||||||
|
return capability if isinstance(capability, ViewResolver) else None
|
||||||
|
|
||||||
|
|
||||||
|
def module_view_surface_id(module_id: str) -> str:
|
||||||
|
return f"{module_id}.module"
|
||||||
|
|
||||||
|
|
||||||
|
def navigation_view_surface_id(module_id: str, path: str) -> str:
|
||||||
|
return f"{module_id}.nav.{_surface_slug(path)}"
|
||||||
|
|
||||||
|
|
||||||
|
def route_view_surface_id(module_id: str, path: str) -> str:
|
||||||
|
return f"{module_id}.route.{_surface_slug(path)}"
|
||||||
|
|
||||||
|
|
||||||
|
def validate_view_surface_id(value: str) -> bool:
|
||||||
|
return bool(_SURFACE_ID_RE.fullmatch(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _surface_slug(value: str) -> str:
|
||||||
|
normalized = _SURFACE_SLUG_RE.sub(".", value.strip().lower()).strip(".")
|
||||||
|
return normalized or "root"
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_VIEWS_RESOLVER",
|
||||||
|
"EffectiveView",
|
||||||
|
"VIEWS_MODULE_ID",
|
||||||
|
"VIEW_SURFACE_CONTRACT_VERSION",
|
||||||
|
"ViewResolver",
|
||||||
|
"ViewSurface",
|
||||||
|
"ViewSurfaceKind",
|
||||||
|
"module_view_surface_id",
|
||||||
|
"navigation_view_surface_id",
|
||||||
|
"route_view_surface_id",
|
||||||
|
"validate_view_surface_id",
|
||||||
|
"views_resolver",
|
||||||
|
]
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
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_VOTING_BALLOTS = "voting.ballots"
|
||||||
|
CAPABILITY_VOTING_PROVIDER_PREFIX = "voting.provider."
|
||||||
|
|
||||||
|
VOTING_ASSURANCE_RECORDED = "recorded"
|
||||||
|
VOTING_ASSURANCE_CONFIDENTIAL = "confidential"
|
||||||
|
VOTING_ASSURANCE_SECRET = "secret"
|
||||||
|
VOTING_ASSURANCE_EXTERNAL_CERTIFIED = "external_certified"
|
||||||
|
|
||||||
|
|
||||||
|
class VotingCapabilityError(ValueError):
|
||||||
|
"""Stable error raised by Voting capability implementations."""
|
||||||
|
|
||||||
|
|
||||||
|
def voting_provider_capability(provider_id: str) -> str:
|
||||||
|
normalized = str(provider_id or "").strip().lower()
|
||||||
|
if not normalized or any(
|
||||||
|
character not in "abcdefghijklmnopqrstuvwxyz0123456789_.-"
|
||||||
|
for character in normalized
|
||||||
|
):
|
||||||
|
raise VotingCapabilityError("Voting provider id is invalid.")
|
||||||
|
return f"{CAPABILITY_VOTING_PROVIDER_PREFIX}{normalized}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class VotingOption:
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class VotingElector:
|
||||||
|
subject_id: str
|
||||||
|
label: str | None = None
|
||||||
|
weight: int = 1
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class VotingBallotCreateCommand:
|
||||||
|
title: str
|
||||||
|
method: str
|
||||||
|
assurance_profile: str
|
||||||
|
options: tuple[VotingOption, ...]
|
||||||
|
electorate: tuple[VotingElector, ...]
|
||||||
|
description: str | None = None
|
||||||
|
context_module: str | None = None
|
||||||
|
context_resource_type: str | None = None
|
||||||
|
context_resource_id: str | None = None
|
||||||
|
quorum_weight: int = 0
|
||||||
|
threshold_numerator: int = 1
|
||||||
|
threshold_denominator: int = 2
|
||||||
|
allow_replacement: bool = True
|
||||||
|
opens_at: datetime | None = None
|
||||||
|
closes_at: datetime | None = None
|
||||||
|
provider_id: str | None = None
|
||||||
|
provider_ballot_ref: str | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class VotingCastCommand:
|
||||||
|
selections: tuple[str, ...]
|
||||||
|
idempotency_key: str
|
||||||
|
elector_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class VotingBallotRef:
|
||||||
|
id: str
|
||||||
|
revision: int
|
||||||
|
state: str
|
||||||
|
assurance_profile: str
|
||||||
|
definition_sha256: str | None = None
|
||||||
|
electorate_sha256: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class VotingReceipt:
|
||||||
|
ballot_id: str
|
||||||
|
revision: int
|
||||||
|
receipt_sha256: str
|
||||||
|
cast_at: datetime
|
||||||
|
replaced_previous: bool = False
|
||||||
|
replayed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class VotingResult:
|
||||||
|
ballot_id: str
|
||||||
|
revision: int
|
||||||
|
counts: Mapping[str, int]
|
||||||
|
weighted_counts: Mapping[str, int]
|
||||||
|
cast_count: int
|
||||||
|
cast_weight: int
|
||||||
|
eligible_count: int
|
||||||
|
eligible_weight: int
|
||||||
|
quorum_met: bool
|
||||||
|
threshold_met: bool
|
||||||
|
winning_options: tuple[str, ...]
|
||||||
|
result_sha256: str
|
||||||
|
evidence: tuple[Mapping[str, object], ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ExternalVotingFinalizationRequest:
|
||||||
|
tenant_id: str
|
||||||
|
ballot_id: str
|
||||||
|
provider_ballot_ref: str
|
||||||
|
definition_sha256: str
|
||||||
|
electorate_sha256: str
|
||||||
|
options: tuple[VotingOption, ...]
|
||||||
|
eligible_count: int
|
||||||
|
eligible_weight: int
|
||||||
|
requested_at: datetime
|
||||||
|
idempotency_key: str
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ExternalVotingProvider(Protocol):
|
||||||
|
"""Provider boundary for confidential, secret, or certified voting.
|
||||||
|
|
||||||
|
Providers return aggregate results and evidence only. Raw ballots and
|
||||||
|
provider credentials must not cross this boundary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def finalize_ballot(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ExternalVotingFinalizationRequest,
|
||||||
|
) -> VotingResult: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class VotingBallotProvider(Protocol):
|
||||||
|
def create_ballot(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
command: VotingBallotCreateCommand,
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> VotingBallotRef: ...
|
||||||
|
|
||||||
|
def get_ballot(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
ballot_id: str,
|
||||||
|
) -> Mapping[str, object] | None: ...
|
||||||
|
|
||||||
|
def open_ballot(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
ballot_id: str,
|
||||||
|
expected_revision: int,
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> VotingBallotRef: ...
|
||||||
|
|
||||||
|
def cast_ballot(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
ballot_id: str,
|
||||||
|
command: VotingCastCommand,
|
||||||
|
) -> VotingReceipt: ...
|
||||||
|
|
||||||
|
def close_ballot(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
ballot_id: str,
|
||||||
|
expected_revision: int,
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> VotingResult: ...
|
||||||
|
|
||||||
|
def certify_ballot(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
ballot_id: str,
|
||||||
|
expected_revision: int,
|
||||||
|
evidence: Sequence[Mapping[str, object]],
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> VotingBallotRef: ...
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_VOTING_BALLOTS",
|
||||||
|
"CAPABILITY_VOTING_PROVIDER_PREFIX",
|
||||||
|
"ExternalVotingFinalizationRequest",
|
||||||
|
"ExternalVotingProvider",
|
||||||
|
"VOTING_ASSURANCE_CONFIDENTIAL",
|
||||||
|
"VOTING_ASSURANCE_EXTERNAL_CERTIFIED",
|
||||||
|
"VOTING_ASSURANCE_RECORDED",
|
||||||
|
"VOTING_ASSURANCE_SECRET",
|
||||||
|
"VotingBallotCreateCommand",
|
||||||
|
"VotingBallotProvider",
|
||||||
|
"VotingBallotRef",
|
||||||
|
"VotingCapabilityError",
|
||||||
|
"VotingCastCommand",
|
||||||
|
"VotingElector",
|
||||||
|
"VotingOption",
|
||||||
|
"VotingReceipt",
|
||||||
|
"VotingResult",
|
||||||
|
"voting_provider_capability",
|
||||||
|
]
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from govoplan_core.core.events import PlatformEvent
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_WORKFLOW_RUNTIME_WORKER = "workflow.runtimeWorker"
|
||||||
|
CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS = "workflow.definitionContributions"
|
||||||
|
CAPABILITY_WORKFLOW_ORCHESTRATION = "workflow.orchestration"
|
||||||
|
CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER = "workflow.triggerDispatcher"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class WorkflowDefinitionContribution:
|
||||||
|
"""Versioned, module-owned Workflow Engine baseline definition."""
|
||||||
|
|
||||||
|
origin_module_id: str
|
||||||
|
origin_module_version: str
|
||||||
|
definition_key: str
|
||||||
|
name: str
|
||||||
|
graph: Mapping[str, object]
|
||||||
|
contribution_schema_version: str = "1"
|
||||||
|
description: str | None = None
|
||||||
|
definition_kind: str = "flow"
|
||||||
|
scope_type: str = "system"
|
||||||
|
inherit_to_lower_scopes: bool = True
|
||||||
|
allow_start: bool = True
|
||||||
|
allow_reuse: bool = True
|
||||||
|
allow_automation: bool = False
|
||||||
|
execution_mode: str = "hybrid"
|
||||||
|
view_id: str | None = None
|
||||||
|
view_revision_id: str | None = None
|
||||||
|
bpmn_xml: str | None = None
|
||||||
|
bpmn_adapter_id: str = "govoplan.native.bpmn"
|
||||||
|
bpmn_adapter_version: str | None = None
|
||||||
|
activate_on_install: bool = True
|
||||||
|
required_capabilities: tuple[str, ...] = ()
|
||||||
|
required_interfaces: tuple[str, ...] = ()
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
policy_metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
content_hash: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class WorkflowStandardStartRequest:
|
||||||
|
tenant_id: str
|
||||||
|
origin_module_id: str
|
||||||
|
definition_key: str
|
||||||
|
idempotency_key: str
|
||||||
|
input: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
actor_id: str | None = None
|
||||||
|
correlation_id: str | None = None
|
||||||
|
start_origin: str = "user"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class WorkflowCurrentStepResolution:
|
||||||
|
action: str
|
||||||
|
expected_step_id: str | None = None
|
||||||
|
actor_id: str | None = None
|
||||||
|
output: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
evidence: tuple[str, ...] = ()
|
||||||
|
comment: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class WorkflowInstanceRef:
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
definition_id: str
|
||||||
|
definition_revision_id: str
|
||||||
|
definition_revision: int
|
||||||
|
definition_hash: str
|
||||||
|
status: str
|
||||||
|
current_step_id: str | None = None
|
||||||
|
current_node_id: str | None = None
|
||||||
|
replayed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def workflow_definition_contribution_hash(
|
||||||
|
contribution: WorkflowDefinitionContribution,
|
||||||
|
) -> str:
|
||||||
|
"""Return the stable content hash, excluding package release provenance."""
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"schema_version": contribution.contribution_schema_version,
|
||||||
|
"definition_key": contribution.definition_key,
|
||||||
|
"name": contribution.name,
|
||||||
|
"description": contribution.description,
|
||||||
|
"graph": contribution.graph,
|
||||||
|
"definition_kind": contribution.definition_kind,
|
||||||
|
"scope_type": contribution.scope_type,
|
||||||
|
"inherit_to_lower_scopes": contribution.inherit_to_lower_scopes,
|
||||||
|
"allow_start": contribution.allow_start,
|
||||||
|
"allow_reuse": contribution.allow_reuse,
|
||||||
|
"allow_automation": contribution.allow_automation,
|
||||||
|
"execution_mode": contribution.execution_mode,
|
||||||
|
"view_id": contribution.view_id,
|
||||||
|
"view_revision_id": contribution.view_revision_id,
|
||||||
|
"bpmn_xml": contribution.bpmn_xml,
|
||||||
|
"bpmn_adapter_id": contribution.bpmn_adapter_id,
|
||||||
|
"bpmn_adapter_version": contribution.bpmn_adapter_version,
|
||||||
|
"activate_on_install": contribution.activate_on_install,
|
||||||
|
"required_capabilities": contribution.required_capabilities,
|
||||||
|
"required_interfaces": contribution.required_interfaces,
|
||||||
|
"metadata": contribution.metadata,
|
||||||
|
"policy_metadata": contribution.policy_metadata,
|
||||||
|
}
|
||||||
|
encoded = json.dumps(
|
||||||
|
payload,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=True,
|
||||||
|
)
|
||||||
|
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class WorkflowRuntimeWorker(Protocol):
|
||||||
|
def reconcile_pending(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> Mapping[str, object]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class WorkflowTriggerDispatcher(Protocol):
|
||||||
|
"""Durable schedule/event start and wait-resumption boundary."""
|
||||||
|
|
||||||
|
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 WorkflowDefinitionContributionProvider(Protocol):
|
||||||
|
def reconcile(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_ids: Sequence[str] = (),
|
||||||
|
) -> Mapping[str, object]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class WorkflowOrchestrationProvider(Protocol):
|
||||||
|
def start_standard(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: WorkflowStandardStartRequest,
|
||||||
|
) -> WorkflowInstanceRef: ...
|
||||||
|
|
||||||
|
def resolve_current_step(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
instance_id: str,
|
||||||
|
resolution: WorkflowCurrentStepResolution,
|
||||||
|
) -> WorkflowInstanceRef: ...
|
||||||
|
|
||||||
|
def get_instance(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
instance_id: str,
|
||||||
|
) -> WorkflowInstanceRef: ...
|
||||||
|
|
||||||
|
|
||||||
|
def workflow_runtime_worker(
|
||||||
|
registry: object | None,
|
||||||
|
) -> WorkflowRuntimeWorker | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_WORKFLOW_RUNTIME_WORKER)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_WORKFLOW_RUNTIME_WORKER)
|
||||||
|
return capability if isinstance(capability, WorkflowRuntimeWorker) else None
|
||||||
|
|
||||||
|
|
||||||
|
def workflow_trigger_dispatcher(
|
||||||
|
registry: object | None,
|
||||||
|
) -> WorkflowTriggerDispatcher | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER)
|
||||||
|
return capability if isinstance(capability, WorkflowTriggerDispatcher) else None
|
||||||
|
|
||||||
|
|
||||||
|
def workflow_definition_contribution_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> WorkflowDefinitionContributionProvider | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS)
|
||||||
|
return (
|
||||||
|
capability
|
||||||
|
if isinstance(capability, WorkflowDefinitionContributionProvider)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def workflow_orchestration_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> WorkflowOrchestrationProvider | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_WORKFLOW_ORCHESTRATION)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_WORKFLOW_ORCHESTRATION)
|
||||||
|
return capability if isinstance(capability, WorkflowOrchestrationProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS",
|
||||||
|
"CAPABILITY_WORKFLOW_ORCHESTRATION",
|
||||||
|
"CAPABILITY_WORKFLOW_RUNTIME_WORKER",
|
||||||
|
"CAPABILITY_WORKFLOW_TRIGGER_DISPATCHER",
|
||||||
|
"WorkflowCurrentStepResolution",
|
||||||
|
"WorkflowDefinitionContribution",
|
||||||
|
"WorkflowDefinitionContributionProvider",
|
||||||
|
"WorkflowInstanceRef",
|
||||||
|
"WorkflowOrchestrationProvider",
|
||||||
|
"WorkflowRuntimeWorker",
|
||||||
|
"WorkflowTriggerDispatcher",
|
||||||
|
"WorkflowStandardStartRequest",
|
||||||
|
"workflow_definition_contribution_hash",
|
||||||
|
"workflow_definition_contribution_provider",
|
||||||
|
"workflow_orchestration_provider",
|
||||||
|
"workflow_runtime_worker",
|
||||||
|
"workflow_trigger_dispatcher",
|
||||||
|
]
|
||||||
@@ -32,6 +32,9 @@ def create_all_tables() -> None:
|
|||||||
# model metadata with the shared SQLAlchemy base before create_all runs.
|
# model metadata with the shared SQLAlchemy base before create_all runs.
|
||||||
from govoplan_core.admin import models as core_admin_models # noqa: F401
|
from govoplan_core.admin import models as core_admin_models # noqa: F401
|
||||||
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401
|
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401
|
||||||
|
from govoplan_core.core import recovery as core_recovery_models # noqa: F401
|
||||||
|
from govoplan_core.core import runtime_coordination as core_runtime_models # noqa: F401
|
||||||
|
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401
|
||||||
|
|
||||||
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
|
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(settings.enabled_modules, raw_enabled_modules)
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import hashlib
|
||||||
|
import time
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from sqlalchemy.engine import make_url
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
|
||||||
|
class MigrationLockTimeout(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def migration_lock_key(installation_id: str, migration_track: str) -> int:
|
||||||
|
payload = f"govoplan\0{installation_id}\0{migration_track}".encode("utf-8")
|
||||||
|
return int.from_bytes(hashlib.sha256(payload).digest()[:8], "big", signed=True)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def deployment_migration_lock(
|
||||||
|
database_url: str,
|
||||||
|
*,
|
||||||
|
installation_id: str,
|
||||||
|
migration_track: str,
|
||||||
|
timeout_seconds: float = 900.0,
|
||||||
|
poll_seconds: float = 2.0,
|
||||||
|
) -> Iterator[None]:
|
||||||
|
"""Serialize all release migrations before coordination tables are available."""
|
||||||
|
|
||||||
|
if timeout_seconds < 0 or poll_seconds <= 0:
|
||||||
|
raise ValueError("Migration lock timeout must be non-negative and polling positive")
|
||||||
|
if make_url(database_url).get_backend_name() != "postgresql":
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
key = migration_lock_key(installation_id, migration_track)
|
||||||
|
engine = create_engine(database_url)
|
||||||
|
connection = engine.connect()
|
||||||
|
acquired = False
|
||||||
|
try:
|
||||||
|
deadline = time.monotonic() + timeout_seconds
|
||||||
|
while True:
|
||||||
|
acquired = bool(
|
||||||
|
connection.execute(
|
||||||
|
text("SELECT pg_try_advisory_lock(:key)"),
|
||||||
|
{"key": key},
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
if acquired:
|
||||||
|
break
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise MigrationLockTimeout(
|
||||||
|
"Timed out waiting for the deployment-wide database migration lock"
|
||||||
|
)
|
||||||
|
time.sleep(min(poll_seconds, max(0.05, deadline - time.monotonic())))
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
if acquired:
|
||||||
|
try:
|
||||||
|
connection.execute(
|
||||||
|
text("SELECT pg_advisory_unlock(:key)"),
|
||||||
|
{"key": key},
|
||||||
|
)
|
||||||
|
except SQLAlchemyError:
|
||||||
|
# Closing the physical connection releases session locks.
|
||||||
|
pass
|
||||||
|
connection.close()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MigrationLockTimeout",
|
||||||
|
"deployment_migration_lock",
|
||||||
|
"migration_lock_key",
|
||||||
|
]
|
||||||
@@ -7,6 +7,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
|
import sysconfig
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from alembic import command
|
from alembic import command
|
||||||
@@ -17,6 +18,9 @@ from sqlalchemy import create_engine, inspect, text
|
|||||||
|
|
||||||
from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan
|
from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan
|
||||||
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
|
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
|
||||||
|
from govoplan_core.core import recovery as core_recovery_models # noqa: F401 - populate core metadata
|
||||||
|
from govoplan_core.core import runtime_coordination as core_runtime_models # noqa: F401 - populate core metadata
|
||||||
|
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
|
||||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
|
||||||
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
||||||
from govoplan_core.core.modules import ModuleMigrationTaskContext, ModuleMigrationTaskResult
|
from govoplan_core.core.modules import ModuleMigrationTaskContext, ModuleMigrationTaskResult
|
||||||
@@ -462,11 +466,13 @@ def _jsonable_migration_task_details(value: Mapping[str, Any]) -> Mapping[str, A
|
|||||||
|
|
||||||
def _repo_root() -> Path:
|
def _repo_root() -> Path:
|
||||||
packaged_root = Path(__file__).resolve().parents[3]
|
packaged_root = Path(__file__).resolve().parents[3]
|
||||||
|
installed_runtime_root = Path(sysconfig.get_path("data")) / "govoplan_core_runtime"
|
||||||
configured = os.environ.get("GOVOPLAN_CORE_SOURCE_ROOT")
|
configured = os.environ.get("GOVOPLAN_CORE_SOURCE_ROOT")
|
||||||
candidates = [
|
candidates = [
|
||||||
Path(configured).expanduser() if configured else None,
|
Path(configured).expanduser() if configured else None,
|
||||||
Path.cwd(),
|
Path.cwd(),
|
||||||
packaged_root,
|
packaged_root,
|
||||||
|
installed_runtime_root,
|
||||||
]
|
]
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
if candidate is None:
|
if candidate is None:
|
||||||
@@ -586,6 +592,51 @@ def database_revision(database_url: str | None = None) -> str | None:
|
|||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def database_migration_heads(
|
||||||
|
database_url: str | None = None,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
url = database_url or settings.database_url
|
||||||
|
engine = create_engine(url)
|
||||||
|
try:
|
||||||
|
with engine.connect() as connection:
|
||||||
|
return tuple(
|
||||||
|
sorted(MigrationContext.configure(connection).get_current_heads())
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def configured_migration_heads(
|
||||||
|
database_url: str | None = None,
|
||||||
|
*,
|
||||||
|
enabled_modules: tuple[str, ...] | list[str] | None = None,
|
||||||
|
manifest_factories: tuple[ManifestFactory, ...] = (),
|
||||||
|
migration_track: str | None = None,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
config = alembic_config(
|
||||||
|
database_url=database_url,
|
||||||
|
enabled_modules=enabled_modules,
|
||||||
|
manifest_factories=manifest_factories,
|
||||||
|
migration_track=migration_track,
|
||||||
|
)
|
||||||
|
return tuple(sorted(ScriptDirectory.from_config(config).get_heads()))
|
||||||
|
|
||||||
|
|
||||||
|
def database_is_at_configured_heads(
|
||||||
|
database_url: str | None = None,
|
||||||
|
*,
|
||||||
|
enabled_modules: tuple[str, ...] | list[str] | None = None,
|
||||||
|
manifest_factories: tuple[ManifestFactory, ...] = (),
|
||||||
|
migration_track: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
return database_migration_heads(database_url) == configured_migration_heads(
|
||||||
|
database_url,
|
||||||
|
enabled_modules=enabled_modules,
|
||||||
|
manifest_factories=manifest_factories,
|
||||||
|
migration_track=migration_track,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _has_columns(inspector, table_name: str, required: set[str]) -> bool:
|
def _has_columns(inspector, table_name: str, required: set[str]) -> bool:
|
||||||
try:
|
try:
|
||||||
actual = {column["name"] for column in inspector.get_columns(table_name)}
|
actual = {column["name"] for column in inspector.get_columns(table_name)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Generator, Mapping
|
from collections.abc import Generator, Mapping
|
||||||
|
import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
@@ -10,6 +11,14 @@ from sqlalchemy.orm import Session, sessionmaker
|
|||||||
from govoplan_core.db.query_metrics import instrument_engine
|
from govoplan_core.db.query_metrics import instrument_engine
|
||||||
|
|
||||||
|
|
||||||
|
_POOL_SETTING_BOUNDS: dict[str, tuple[int, int]] = {
|
||||||
|
"GOVOPLAN_DB_POOL_SIZE": (1, 100),
|
||||||
|
"GOVOPLAN_DB_MAX_OVERFLOW": (0, 200),
|
||||||
|
"GOVOPLAN_DB_POOL_TIMEOUT_SECONDS": (1, 300),
|
||||||
|
"GOVOPLAN_DB_POOL_RECYCLE_SECONDS": (0, 86_400),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def default_connect_args(database_url: str) -> dict[str, Any]:
|
def default_connect_args(database_url: str) -> dict[str, Any]:
|
||||||
return {"check_same_thread": False} if database_url.startswith("sqlite") else {}
|
return {"check_same_thread": False} if database_url.startswith("sqlite") else {}
|
||||||
|
|
||||||
@@ -24,9 +33,42 @@ def create_database_engine(
|
|||||||
merged_connect_args = dict(default_connect_args(database_url))
|
merged_connect_args = dict(default_connect_args(database_url))
|
||||||
if connect_args:
|
if connect_args:
|
||||||
merged_connect_args.update(connect_args)
|
merged_connect_args.update(connect_args)
|
||||||
|
if not database_url.startswith("sqlite"):
|
||||||
|
kwargs.setdefault(
|
||||||
|
"pool_size",
|
||||||
|
_pool_setting("GOVOPLAN_DB_POOL_SIZE", 5),
|
||||||
|
)
|
||||||
|
kwargs.setdefault(
|
||||||
|
"max_overflow",
|
||||||
|
_pool_setting("GOVOPLAN_DB_MAX_OVERFLOW", 10),
|
||||||
|
)
|
||||||
|
kwargs.setdefault(
|
||||||
|
"pool_timeout",
|
||||||
|
_pool_setting("GOVOPLAN_DB_POOL_TIMEOUT_SECONDS", 30),
|
||||||
|
)
|
||||||
|
kwargs.setdefault(
|
||||||
|
"pool_recycle",
|
||||||
|
_pool_setting("GOVOPLAN_DB_POOL_RECYCLE_SECONDS", 1800),
|
||||||
|
)
|
||||||
return instrument_engine(create_engine(database_url, pool_pre_ping=pool_pre_ping, connect_args=merged_connect_args, **kwargs))
|
return instrument_engine(create_engine(database_url, pool_pre_ping=pool_pre_ping, connect_args=merged_connect_args, **kwargs))
|
||||||
|
|
||||||
|
|
||||||
|
def _pool_setting(name: str, default: int) -> int:
|
||||||
|
raw = os.environ.get(name)
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
value = int(raw)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"{name} must be an integer") from exc
|
||||||
|
minimum, maximum = _POOL_SETTING_BOUNDS[name]
|
||||||
|
if not minimum <= value <= maximum:
|
||||||
|
raise ValueError(
|
||||||
|
f"{name} must be between {minimum} and {maximum}",
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class DatabaseHandle:
|
class DatabaseHandle:
|
||||||
def __init__(self, database_url: str, *, engine: Engine | None = None) -> None:
|
def __init__(self, database_url: str, *, engine: Engine | None = None) -> None:
|
||||||
self.database_url = database_url
|
self.database_url = database_url
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class DevserverState:
|
|||||||
config: GovoplanServerConfig
|
config: GovoplanServerConfig
|
||||||
registry: PlatformRegistry
|
registry: PlatformRegistry
|
||||||
reload_dirs: list[str]
|
reload_dirs: list[str]
|
||||||
|
reload_module_ids: tuple[str, ...] | None
|
||||||
|
|
||||||
|
|
||||||
def _config_module_runtime_root(config_path: str | None) -> Path | None:
|
def _config_module_runtime_root(config_path: str | None) -> Path | None:
|
||||||
@@ -190,6 +191,9 @@ def _manifest_source_roots(manifest: ModuleManifest) -> tuple[Path, ...]:
|
|||||||
for provider in manifest.tenant_summary_providers:
|
for provider in manifest.tenant_summary_providers:
|
||||||
roots.extend(_source_roots_for_object(provider))
|
roots.extend(_source_roots_for_object(provider))
|
||||||
|
|
||||||
|
for provider in manifest.tenant_summary_batch_providers:
|
||||||
|
roots.extend(_source_roots_for_object(provider))
|
||||||
|
|
||||||
for providers in manifest.delete_veto_providers.values():
|
for providers in manifest.delete_veto_providers.values():
|
||||||
for provider in providers:
|
for provider in providers:
|
||||||
roots.extend(_source_roots_for_object(provider))
|
roots.extend(_source_roots_for_object(provider))
|
||||||
@@ -237,20 +241,44 @@ def build_reload_dirs(
|
|||||||
config_path: str | None = None,
|
config_path: str | None = None,
|
||||||
registry: PlatformRegistry | None = None,
|
registry: PlatformRegistry | None = None,
|
||||||
extra_dirs: Sequence[str] = (),
|
extra_dirs: Sequence[str] = (),
|
||||||
|
module_ids: Sequence[str] | None = None,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
active_registry = registry or build_platform_registry(config.enabled_modules, manifest_factories=config.manifest_factories)
|
active_registry = registry or build_platform_registry(config.enabled_modules, manifest_factories=config.manifest_factories)
|
||||||
manifests = active_registry.manifests()
|
manifests = active_registry.manifests()
|
||||||
enabled_module_ids = {manifest.id for manifest in manifests}
|
enabled_module_ids = {manifest.id for manifest in manifests}
|
||||||
|
selected_module_ids = (
|
||||||
|
enabled_module_ids
|
||||||
|
if module_ids is None
|
||||||
|
else {module_id.strip() for module_id in module_ids if module_id.strip()}
|
||||||
|
)
|
||||||
|
unknown_module_ids = selected_module_ids - enabled_module_ids
|
||||||
|
if unknown_module_ids:
|
||||||
|
raise SystemExit(
|
||||||
|
"Reload modules are not enabled: "
|
||||||
|
+ ", ".join(sorted(unknown_module_ids))
|
||||||
|
)
|
||||||
|
|
||||||
roots: list[Path | str] = []
|
roots: list[Path | str] = []
|
||||||
roots.extend(_config_source_roots(config_path))
|
roots.extend(_config_source_roots(config_path))
|
||||||
|
|
||||||
for factory in config.manifest_factories:
|
for factory in config.manifest_factories:
|
||||||
|
try:
|
||||||
|
manifest = factory()
|
||||||
|
except TypeError:
|
||||||
|
manifest = None
|
||||||
|
if (
|
||||||
|
module_ids is not None
|
||||||
|
and isinstance(manifest, ModuleManifest)
|
||||||
|
and manifest.id not in selected_module_ids
|
||||||
|
):
|
||||||
|
continue
|
||||||
roots.extend(_source_roots_for_object(factory))
|
roots.extend(_source_roots_for_object(factory))
|
||||||
|
|
||||||
roots.extend(_entry_point_source_roots(enabled_module_ids))
|
roots.extend(_entry_point_source_roots(selected_module_ids))
|
||||||
|
|
||||||
for manifest in manifests:
|
for manifest in manifests:
|
||||||
|
if manifest.id not in selected_module_ids:
|
||||||
|
continue
|
||||||
roots.extend(_manifest_source_roots(manifest))
|
roots.extend(_manifest_source_roots(manifest))
|
||||||
|
|
||||||
roots.extend(extra_dirs)
|
roots.extend(extra_dirs)
|
||||||
@@ -265,11 +293,32 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
|||||||
parser.add_argument("--port", type=int, default=8000, help="Port to bind. Default: 8000.")
|
parser.add_argument("--port", type=int, default=8000, help="Port to bind. Default: 8000.")
|
||||||
parser.add_argument("--no-reload", action="store_true", help="Disable uvicorn reload.")
|
parser.add_argument("--no-reload", action="store_true", help="Disable uvicorn reload.")
|
||||||
parser.add_argument("--reload-dir", action="append", default=[], help="Additional directory to watch. May be passed multiple times.")
|
parser.add_argument("--reload-dir", action="append", default=[], help="Additional directory to watch. May be passed multiple times.")
|
||||||
|
reload_scope = parser.add_mutually_exclusive_group()
|
||||||
|
reload_scope.add_argument(
|
||||||
|
"--reload-module",
|
||||||
|
action="append",
|
||||||
|
default=None,
|
||||||
|
metavar="MODULE_ID",
|
||||||
|
help=(
|
||||||
|
"Watch only this enabled module in addition to core/config sources. "
|
||||||
|
"May be passed multiple times."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
reload_scope.add_argument(
|
||||||
|
"--reload-core-only",
|
||||||
|
action="store_true",
|
||||||
|
help="Watch core/config sources but no optional module source trees.",
|
||||||
|
)
|
||||||
parser.add_argument("--smoke", action="store_true", help="Prepare runtime paths, run app startup, print effective paths, and exit without uvicorn.")
|
parser.add_argument("--smoke", action="store_true", help="Prepare runtime paths, run app startup, print effective paths, and exit without uvicorn.")
|
||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
def prepare_devserver(config_path: str | None, *, extra_reload_dirs: Sequence[str] = ()) -> DevserverState:
|
def prepare_devserver(
|
||||||
|
config_path: str | None,
|
||||||
|
*,
|
||||||
|
extra_reload_dirs: Sequence[str] = (),
|
||||||
|
reload_module_ids: Sequence[str] | None = None,
|
||||||
|
) -> DevserverState:
|
||||||
runtime_root = apply_runtime_defaults(config_path)
|
runtime_root = apply_runtime_defaults(config_path)
|
||||||
database_url = os.getenv("DATABASE_URL", "")
|
database_url = os.getenv("DATABASE_URL", "")
|
||||||
validate_sqlite_database_url(database_url)
|
validate_sqlite_database_url(database_url)
|
||||||
@@ -291,7 +340,13 @@ def prepare_devserver(config_path: str | None, *, extra_reload_dirs: Sequence[st
|
|||||||
)
|
)
|
||||||
enabled_modules = load_startup_enabled_modules(config.enabled_modules, available=available_modules)
|
enabled_modules = load_startup_enabled_modules(config.enabled_modules, available=available_modules)
|
||||||
registry = build_platform_registry(enabled_modules, manifest_factories=config.manifest_factories)
|
registry = build_platform_registry(enabled_modules, manifest_factories=config.manifest_factories)
|
||||||
reload_dirs = build_reload_dirs(config, config_path=config_path, registry=registry, extra_dirs=extra_reload_dirs)
|
reload_dirs = build_reload_dirs(
|
||||||
|
config,
|
||||||
|
config_path=config_path,
|
||||||
|
registry=registry,
|
||||||
|
extra_dirs=extra_reload_dirs,
|
||||||
|
module_ids=reload_module_ids,
|
||||||
|
)
|
||||||
return DevserverState(
|
return DevserverState(
|
||||||
config_path=config_path,
|
config_path=config_path,
|
||||||
runtime_root=runtime_root,
|
runtime_root=runtime_root,
|
||||||
@@ -300,6 +355,11 @@ def prepare_devserver(config_path: str | None, *, extra_reload_dirs: Sequence[st
|
|||||||
config=config,
|
config=config,
|
||||||
registry=registry,
|
registry=registry,
|
||||||
reload_dirs=reload_dirs,
|
reload_dirs=reload_dirs,
|
||||||
|
reload_module_ids=(
|
||||||
|
None
|
||||||
|
if reload_module_ids is None
|
||||||
|
else tuple(sorted(set(reload_module_ids)))
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -320,6 +380,15 @@ def print_devserver_summary(state: DevserverState, *, app: str, no_reload: bool)
|
|||||||
if no_reload:
|
if no_reload:
|
||||||
print("Reload: disabled")
|
print("Reload: disabled")
|
||||||
else:
|
else:
|
||||||
|
if state.reload_module_ids is None:
|
||||||
|
print("Reload scope: core/config plus all enabled modules")
|
||||||
|
elif state.reload_module_ids:
|
||||||
|
print(
|
||||||
|
"Reload scope: core/config plus "
|
||||||
|
+ ", ".join(state.reload_module_ids)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print("Reload scope: core/config only")
|
||||||
print("Reload dirs:")
|
print("Reload dirs:")
|
||||||
for directory in state.reload_dirs:
|
for directory in state.reload_dirs:
|
||||||
print(f" - {directory}")
|
print(f" - {directory}")
|
||||||
@@ -353,7 +422,14 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
os.environ["GOVOPLAN_SERVER_CONFIG"] = args.config
|
os.environ["GOVOPLAN_SERVER_CONFIG"] = args.config
|
||||||
|
|
||||||
config_path = args.config or os.getenv("GOVOPLAN_SERVER_CONFIG")
|
config_path = args.config or os.getenv("GOVOPLAN_SERVER_CONFIG")
|
||||||
state = prepare_devserver(config_path, extra_reload_dirs=args.reload_dir)
|
reload_module_ids: Sequence[str] | None = args.reload_module
|
||||||
|
if args.reload_core_only:
|
||||||
|
reload_module_ids = ()
|
||||||
|
state = prepare_devserver(
|
||||||
|
config_path,
|
||||||
|
extra_reload_dirs=args.reload_dir,
|
||||||
|
reload_module_ids=reload_module_ids,
|
||||||
|
)
|
||||||
print_devserver_summary(state, app=args.app, no_reload=args.no_reload)
|
print_devserver_summary(state, app=args.app, no_reload=args.no_reload)
|
||||||
|
|
||||||
if args.smoke:
|
if args.smoke:
|
||||||
|
|||||||
@@ -0,0 +1,563 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Index, JSON, String, Text, select
|
||||||
|
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||||
|
|
||||||
|
from govoplan_core.audit.logging import audit_event
|
||||||
|
from govoplan_core.core.change_sequence import record_change
|
||||||
|
from govoplan_core.db.base import Base, TimestampMixin, utcnow
|
||||||
|
from govoplan_core.security.redaction import is_sensitive_key, redact_secret_values
|
||||||
|
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
|
||||||
|
|
||||||
|
|
||||||
|
CREDENTIAL_SCOPE_TYPES = frozenset({"system", "tenant", "group", "user", "campaign"})
|
||||||
|
CREDENTIAL_KINDS = frozenset(
|
||||||
|
{
|
||||||
|
"username_password",
|
||||||
|
"token",
|
||||||
|
"oauth2",
|
||||||
|
"api_key",
|
||||||
|
"client_secret",
|
||||||
|
"aws",
|
||||||
|
"custom",
|
||||||
|
"external_secret",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_SECRET_PUBLIC_DATA_KEYS = frozenset(
|
||||||
|
{
|
||||||
|
"access_token",
|
||||||
|
"api_key",
|
||||||
|
"authorization",
|
||||||
|
"bearer_token",
|
||||||
|
"client_secret",
|
||||||
|
"password",
|
||||||
|
"passphrase",
|
||||||
|
"private_key",
|
||||||
|
"refresh_token",
|
||||||
|
"secret",
|
||||||
|
"secret_access_key",
|
||||||
|
"session_token",
|
||||||
|
"token",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialEnvelopeError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialEnvelope(Base, TimestampMixin):
|
||||||
|
__tablename__ = "core_credential_envelopes"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_core_credential_envelopes_scope", "tenant_id", "scope_type", "scope_id"),
|
||||||
|
Index("ix_core_credential_envelopes_active", "tenant_id", "is_active", "deleted_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
scope_type: Mapped[str] = mapped_column(String(20), nullable=False, default="tenant", index=True)
|
||||||
|
scope_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
credential_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||||
|
public_data: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
secret_data_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
secret_keys: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
allowed_modules: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
allowed_server_refs: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
inherit_to_lower_scopes: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||||
|
revision: Mapped[str] = mapped_column(String(36), default=lambda: str(uuid.uuid4()), nullable=False)
|
||||||
|
created_by_user_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
updated_by_user_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CredentialAccessContext:
|
||||||
|
tenant_id: str | None
|
||||||
|
user_id: str | None = None
|
||||||
|
group_ids: frozenset[str] = frozenset()
|
||||||
|
target_scope_type: str = "tenant"
|
||||||
|
target_scope_id: str | None = None
|
||||||
|
module_id: str | None = None
|
||||||
|
server_ref: str | None = None
|
||||||
|
administrative: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ResolvedCredentialEnvelope:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
credential_kind: str
|
||||||
|
public_data: Mapping[str, Any]
|
||||||
|
secret_data: Mapping[str, Any]
|
||||||
|
revision: str
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_credential_scope(
|
||||||
|
*,
|
||||||
|
tenant_id: str | None,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None,
|
||||||
|
) -> tuple[str | None, str, str | None]:
|
||||||
|
normalized_type = str(scope_type or "tenant").strip().casefold()
|
||||||
|
if normalized_type not in CREDENTIAL_SCOPE_TYPES:
|
||||||
|
raise CredentialEnvelopeError(f"Unsupported credential scope: {scope_type!r}")
|
||||||
|
normalized_id = _optional_text(scope_id)
|
||||||
|
if normalized_type == "system":
|
||||||
|
return None, "system", None
|
||||||
|
normalized_tenant = _required_text(tenant_id, "Credential tenant_id is required outside system scope")
|
||||||
|
if normalized_type == "tenant":
|
||||||
|
return normalized_tenant, "tenant", normalized_id or normalized_tenant
|
||||||
|
if not normalized_id:
|
||||||
|
raise CredentialEnvelopeError(f"{normalized_type.capitalize()} credentials require scope_id")
|
||||||
|
return normalized_tenant, normalized_type, normalized_id
|
||||||
|
|
||||||
|
|
||||||
|
def create_credential_envelope(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None,
|
||||||
|
name: str,
|
||||||
|
credential_kind: str,
|
||||||
|
public_data: Mapping[str, Any] | None = None,
|
||||||
|
secret_data: Mapping[str, Any] | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
allowed_modules: list[str] | tuple[str, ...] = (),
|
||||||
|
allowed_server_refs: list[str] | tuple[str, ...] = (),
|
||||||
|
inherit_to_lower_scopes: bool = False,
|
||||||
|
is_active: bool = True,
|
||||||
|
user_id: str | None = None,
|
||||||
|
metadata: Mapping[str, Any] | None = None,
|
||||||
|
) -> CredentialEnvelope:
|
||||||
|
row_tenant_id, row_scope_type, row_scope_id = normalize_credential_scope(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
)
|
||||||
|
clean_kind = _normalize_kind(credential_kind)
|
||||||
|
clean_public = _safe_public_mapping(public_data)
|
||||||
|
clean_secret = _json_mapping(secret_data)
|
||||||
|
row = CredentialEnvelope(
|
||||||
|
tenant_id=row_tenant_id,
|
||||||
|
scope_type=row_scope_type,
|
||||||
|
scope_id=row_scope_id,
|
||||||
|
name=_required_text(name, "Credential name is required"),
|
||||||
|
description=_optional_text(description),
|
||||||
|
credential_kind=clean_kind,
|
||||||
|
public_data=clean_public,
|
||||||
|
secret_data_encrypted=_encrypt_secret_mapping(clean_secret),
|
||||||
|
secret_keys=sorted(clean_secret),
|
||||||
|
allowed_modules=_normalized_values(allowed_modules),
|
||||||
|
allowed_server_refs=_normalized_values(allowed_server_refs),
|
||||||
|
inherit_to_lower_scopes=bool(inherit_to_lower_scopes),
|
||||||
|
is_active=bool(is_active),
|
||||||
|
created_by_user_id=_optional_text(user_id),
|
||||||
|
updated_by_user_id=_optional_text(user_id),
|
||||||
|
metadata_=_json_mapping(metadata) or None,
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
_record_credential_change(session, row=row, operation="created", user_id=user_id)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def update_credential_envelope(
|
||||||
|
session: Session,
|
||||||
|
row: CredentialEnvelope,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
description_supplied: bool = False,
|
||||||
|
credential_kind: str | None = None,
|
||||||
|
public_data: Mapping[str, Any] | None = None,
|
||||||
|
secret_data: Mapping[str, Any] | None = None,
|
||||||
|
clear_secret: bool = False,
|
||||||
|
allowed_modules: list[str] | tuple[str, ...] | None = None,
|
||||||
|
allowed_server_refs: list[str] | tuple[str, ...] | None = None,
|
||||||
|
inherit_to_lower_scopes: bool | None = None,
|
||||||
|
is_active: bool | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
metadata: Mapping[str, Any] | None = None,
|
||||||
|
) -> CredentialEnvelope:
|
||||||
|
if row.deleted_at is not None:
|
||||||
|
raise CredentialEnvelopeError("Deleted credentials cannot be updated")
|
||||||
|
if name is not None:
|
||||||
|
row.name = _required_text(name, "Credential name is required")
|
||||||
|
if description_supplied or description is not None:
|
||||||
|
row.description = _optional_text(description)
|
||||||
|
if credential_kind is not None:
|
||||||
|
clean_kind = _normalize_kind(credential_kind)
|
||||||
|
if clean_kind != row.credential_kind and secret_data is None and not clear_secret:
|
||||||
|
raise CredentialEnvelopeError(
|
||||||
|
"Changing credential kind requires replacing or clearing its secret"
|
||||||
|
)
|
||||||
|
row.credential_kind = clean_kind
|
||||||
|
if public_data is not None:
|
||||||
|
row.public_data = _safe_public_mapping(public_data)
|
||||||
|
if secret_data is not None:
|
||||||
|
clean_secret = _json_mapping(secret_data)
|
||||||
|
row.secret_data_encrypted = _encrypt_secret_mapping(clean_secret)
|
||||||
|
row.secret_keys = sorted(clean_secret)
|
||||||
|
elif clear_secret:
|
||||||
|
row.secret_data_encrypted = None
|
||||||
|
row.secret_keys = []
|
||||||
|
if allowed_modules is not None:
|
||||||
|
row.allowed_modules = _normalized_values(allowed_modules)
|
||||||
|
if allowed_server_refs is not None:
|
||||||
|
row.allowed_server_refs = _normalized_values(allowed_server_refs)
|
||||||
|
if inherit_to_lower_scopes is not None:
|
||||||
|
row.inherit_to_lower_scopes = bool(inherit_to_lower_scopes)
|
||||||
|
if is_active is not None:
|
||||||
|
row.is_active = bool(is_active)
|
||||||
|
if metadata is not None:
|
||||||
|
row.metadata_ = _json_mapping(metadata) or None
|
||||||
|
row.updated_by_user_id = _optional_text(user_id)
|
||||||
|
row.revision = str(uuid.uuid4())
|
||||||
|
session.flush()
|
||||||
|
_record_credential_change(session, row=row, operation="updated", user_id=user_id)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def retire_credential_envelope(
|
||||||
|
session: Session,
|
||||||
|
row: CredentialEnvelope,
|
||||||
|
*,
|
||||||
|
user_id: str | None = None,
|
||||||
|
) -> CredentialEnvelope:
|
||||||
|
if row.deleted_at is not None:
|
||||||
|
return row
|
||||||
|
row.secret_data_encrypted = None
|
||||||
|
row.secret_keys = []
|
||||||
|
row.is_active = False
|
||||||
|
row.deleted_at = utcnow()
|
||||||
|
row.updated_by_user_id = _optional_text(user_id)
|
||||||
|
row.revision = str(uuid.uuid4())
|
||||||
|
session.flush()
|
||||||
|
_record_credential_change(session, row=row, operation="deleted", user_id=user_id)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def get_credential_envelope(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
credential_id: str,
|
||||||
|
context: CredentialAccessContext,
|
||||||
|
require_active: bool = True,
|
||||||
|
for_update: bool = False,
|
||||||
|
) -> CredentialEnvelope:
|
||||||
|
statement = select(CredentialEnvelope).where(CredentialEnvelope.id == _required_text(credential_id, "Credential id is required"))
|
||||||
|
if for_update:
|
||||||
|
statement = statement.with_for_update()
|
||||||
|
row = session.execute(statement).scalar_one_or_none()
|
||||||
|
if row is None or row.deleted_at is not None or not credential_visible_to_context(row, context):
|
||||||
|
raise CredentialEnvelopeError("Credential envelope not found")
|
||||||
|
if require_active and not row.is_active:
|
||||||
|
raise CredentialEnvelopeError("Credential envelope is inactive")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def list_credential_envelopes(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
context: CredentialAccessContext,
|
||||||
|
include_inactive: bool = False,
|
||||||
|
) -> list[CredentialEnvelope]:
|
||||||
|
statement = select(CredentialEnvelope).where(CredentialEnvelope.deleted_at.is_(None))
|
||||||
|
if context.tenant_id is None:
|
||||||
|
statement = statement.where(CredentialEnvelope.tenant_id.is_(None))
|
||||||
|
else:
|
||||||
|
statement = statement.where(
|
||||||
|
(CredentialEnvelope.tenant_id == context.tenant_id)
|
||||||
|
| (CredentialEnvelope.tenant_id.is_(None))
|
||||||
|
)
|
||||||
|
if not include_inactive:
|
||||||
|
statement = statement.where(CredentialEnvelope.is_active.is_(True))
|
||||||
|
rows = session.execute(statement.order_by(CredentialEnvelope.name, CredentialEnvelope.id)).scalars()
|
||||||
|
return [row for row in rows if credential_visible_to_context(row, context)]
|
||||||
|
|
||||||
|
|
||||||
|
def list_managed_credential_envelopes(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None,
|
||||||
|
include_inactive: bool = False,
|
||||||
|
) -> list[CredentialEnvelope]:
|
||||||
|
"""List envelopes for an administrative surface without applying use-site limits."""
|
||||||
|
|
||||||
|
statement = select(CredentialEnvelope).where(CredentialEnvelope.deleted_at.is_(None))
|
||||||
|
if tenant_id is None:
|
||||||
|
statement = statement.where(CredentialEnvelope.tenant_id.is_(None))
|
||||||
|
else:
|
||||||
|
statement = statement.where(CredentialEnvelope.tenant_id == tenant_id)
|
||||||
|
if not include_inactive:
|
||||||
|
statement = statement.where(CredentialEnvelope.is_active.is_(True))
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
statement.order_by(
|
||||||
|
CredentialEnvelope.scope_type,
|
||||||
|
CredentialEnvelope.name,
|
||||||
|
CredentialEnvelope.id,
|
||||||
|
)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_managed_credential_envelope(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
credential_id: str,
|
||||||
|
tenant_id: str | None,
|
||||||
|
for_update: bool = False,
|
||||||
|
) -> CredentialEnvelope:
|
||||||
|
statement = select(CredentialEnvelope).where(
|
||||||
|
CredentialEnvelope.id
|
||||||
|
== _required_text(credential_id, "Credential id is required"),
|
||||||
|
CredentialEnvelope.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
if tenant_id is None:
|
||||||
|
statement = statement.where(CredentialEnvelope.tenant_id.is_(None))
|
||||||
|
else:
|
||||||
|
statement = statement.where(CredentialEnvelope.tenant_id == tenant_id)
|
||||||
|
if for_update:
|
||||||
|
statement = statement.with_for_update()
|
||||||
|
row = session.execute(statement).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
raise CredentialEnvelopeError("Credential envelope not found")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def credential_visible_to_context(row: CredentialEnvelope, context: CredentialAccessContext) -> bool:
|
||||||
|
if row.deleted_at is not None:
|
||||||
|
return False
|
||||||
|
if not _scope_visible(row, context):
|
||||||
|
return False
|
||||||
|
if row.allowed_modules and (not context.module_id or context.module_id not in row.allowed_modules):
|
||||||
|
return False
|
||||||
|
if row.allowed_server_refs and (not context.server_ref or context.server_ref not in row.allowed_server_refs):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_credential_envelope(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
credential_id: str,
|
||||||
|
context: CredentialAccessContext,
|
||||||
|
) -> ResolvedCredentialEnvelope:
|
||||||
|
row = get_credential_envelope(
|
||||||
|
session,
|
||||||
|
credential_id=credential_id,
|
||||||
|
context=context,
|
||||||
|
require_active=True,
|
||||||
|
)
|
||||||
|
return ResolvedCredentialEnvelope(
|
||||||
|
id=row.id,
|
||||||
|
name=row.name,
|
||||||
|
credential_kind=row.credential_kind,
|
||||||
|
public_data=dict(row.public_data or {}),
|
||||||
|
secret_data=_decrypt_secret_mapping(row.secret_data_encrypted),
|
||||||
|
revision=row.revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def credential_envelope_summary(row: CredentialEnvelope) -> dict[str, Any]:
|
||||||
|
public_data = redact_secret_values(dict(row.public_data or {}))
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"tenant_id": row.tenant_id,
|
||||||
|
"scope_type": row.scope_type,
|
||||||
|
"scope_id": row.scope_id,
|
||||||
|
"name": row.name,
|
||||||
|
"description": row.description,
|
||||||
|
"credential_kind": row.credential_kind,
|
||||||
|
"public_data": public_data,
|
||||||
|
"secret_keys": sorted(str(key) for key in (row.secret_keys or [])),
|
||||||
|
"secret_configured": bool(row.secret_data_encrypted),
|
||||||
|
"allowed_modules": sorted(str(item) for item in (row.allowed_modules or [])),
|
||||||
|
"allowed_server_refs": sorted(str(item) for item in (row.allowed_server_refs or [])),
|
||||||
|
"inherit_to_lower_scopes": bool(row.inherit_to_lower_scopes),
|
||||||
|
"is_active": bool(row.is_active),
|
||||||
|
"revision": row.revision,
|
||||||
|
"created_at": row.created_at,
|
||||||
|
"updated_at": row.updated_at,
|
||||||
|
"deleted_at": row.deleted_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_visible(row: CredentialEnvelope, context: CredentialAccessContext) -> bool:
|
||||||
|
target_type = str(context.target_scope_type or "tenant").strip().casefold()
|
||||||
|
target_id = _optional_text(context.target_scope_id)
|
||||||
|
if context.administrative:
|
||||||
|
return row.scope_type == "system" or row.tenant_id == context.tenant_id
|
||||||
|
if row.scope_type == "system":
|
||||||
|
return target_type == "system" or bool(row.inherit_to_lower_scopes)
|
||||||
|
if row.tenant_id != context.tenant_id:
|
||||||
|
return False
|
||||||
|
if row.scope_type == "tenant":
|
||||||
|
if target_type == "tenant":
|
||||||
|
return row.scope_id in {None, context.tenant_id, target_id}
|
||||||
|
return bool(row.inherit_to_lower_scopes)
|
||||||
|
if row.scope_type == "user":
|
||||||
|
return row.scope_id == context.user_id or (target_type == "user" and row.scope_id == target_id)
|
||||||
|
if row.scope_type == "group":
|
||||||
|
exact = row.scope_id in context.group_ids or (target_type == "group" and row.scope_id == target_id)
|
||||||
|
return exact and (target_type == "group" or bool(row.inherit_to_lower_scopes))
|
||||||
|
if row.scope_type == "campaign":
|
||||||
|
return target_type == "campaign" and row.scope_id == target_id
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _record_credential_change(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
row: CredentialEnvelope,
|
||||||
|
operation: str,
|
||||||
|
user_id: str | None,
|
||||||
|
) -> None:
|
||||||
|
scope = "system" if row.scope_type == "system" else "tenant"
|
||||||
|
details = {
|
||||||
|
"scope_type": row.scope_type,
|
||||||
|
"scope_id": row.scope_id,
|
||||||
|
"credential_kind": row.credential_kind,
|
||||||
|
"allowed_modules": list(row.allowed_modules or []),
|
||||||
|
"allowed_server_refs": list(row.allowed_server_refs or []),
|
||||||
|
"secret_configured": bool(row.secret_data_encrypted),
|
||||||
|
}
|
||||||
|
record_change(
|
||||||
|
session,
|
||||||
|
module_id="core",
|
||||||
|
collection="core.security.credentials",
|
||||||
|
resource_type="credential_envelope",
|
||||||
|
resource_id=row.id,
|
||||||
|
operation=operation,
|
||||||
|
tenant_id=row.tenant_id,
|
||||||
|
actor_type="user" if user_id else None,
|
||||||
|
actor_id=user_id,
|
||||||
|
payload={"scope_type": row.scope_type, "credential_kind": row.credential_kind},
|
||||||
|
)
|
||||||
|
audit_event(
|
||||||
|
session,
|
||||||
|
tenant_id=row.tenant_id,
|
||||||
|
action=f"credential.{operation}",
|
||||||
|
scope=scope,
|
||||||
|
user_id=user_id,
|
||||||
|
object_type="credential_envelope",
|
||||||
|
object_id=row.id,
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _encrypt_secret_mapping(value: Mapping[str, Any]) -> str | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
return encrypt_secret(json.dumps(dict(value), separators=(",", ":"), sort_keys=True))
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypt_secret_mapping(value: str | None) -> dict[str, Any]:
|
||||||
|
decrypted = decrypt_secret(value)
|
||||||
|
if not decrypted:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
parsed = json.loads(decrypted)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise CredentialEnvelopeError("Stored credential payload is invalid") from exc
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise CredentialEnvelopeError("Stored credential payload is invalid")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_kind(value: str) -> str:
|
||||||
|
normalized = _required_text(value, "Credential kind is required").casefold()
|
||||||
|
if normalized not in CREDENTIAL_KINDS:
|
||||||
|
raise CredentialEnvelopeError(f"Unsupported credential kind: {value!r}")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_values(values: list[str] | tuple[str, ...]) -> list[str]:
|
||||||
|
return sorted({_required_text(value, "Credential policy values cannot be blank") for value in values})
|
||||||
|
|
||||||
|
|
||||||
|
def _json_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||||
|
return dict(value or {})
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_public_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||||
|
payload = _json_mapping(value)
|
||||||
|
unsafe_path = _secret_public_data_path(payload)
|
||||||
|
if unsafe_path:
|
||||||
|
raise CredentialEnvelopeError(
|
||||||
|
f"Credential public_data must not contain secret field {unsafe_path!r}"
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _secret_public_data_path(value: object, path: str = "public_data") -> str | None:
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
for key, item in value.items():
|
||||||
|
item_path = f"{path}.{key}"
|
||||||
|
if _is_secret_public_key(key):
|
||||||
|
return item_path
|
||||||
|
nested_path = _secret_public_data_path(item, item_path)
|
||||||
|
if nested_path:
|
||||||
|
return nested_path
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for index, item in enumerate(value):
|
||||||
|
nested_path = _secret_public_data_path(item, f"{path}[{index}]")
|
||||||
|
if nested_path:
|
||||||
|
return nested_path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_secret_public_key(key: object) -> bool:
|
||||||
|
clean_key = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", str(key).strip())
|
||||||
|
clean_key = re.sub(r"[^A-Za-z0-9]+", "_", clean_key).strip("_").casefold()
|
||||||
|
if clean_key.endswith(("_id", "_ref", "_reference", "_type")):
|
||||||
|
return False
|
||||||
|
return clean_key in _SECRET_PUBLIC_DATA_KEYS or is_sensitive_key(key)
|
||||||
|
|
||||||
|
|
||||||
|
def _required_text(value: object | None, message: str) -> str:
|
||||||
|
clean = _optional_text(value)
|
||||||
|
if not clean:
|
||||||
|
raise CredentialEnvelopeError(message)
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_text(value: object | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
clean = str(value).strip()
|
||||||
|
return clean or None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CREDENTIAL_KINDS",
|
||||||
|
"CREDENTIAL_SCOPE_TYPES",
|
||||||
|
"CredentialAccessContext",
|
||||||
|
"CredentialEnvelope",
|
||||||
|
"CredentialEnvelopeError",
|
||||||
|
"ResolvedCredentialEnvelope",
|
||||||
|
"create_credential_envelope",
|
||||||
|
"credential_envelope_summary",
|
||||||
|
"credential_visible_to_context",
|
||||||
|
"get_credential_envelope",
|
||||||
|
"get_managed_credential_envelope",
|
||||||
|
"list_credential_envelopes",
|
||||||
|
"list_managed_credential_envelopes",
|
||||||
|
"normalize_credential_scope",
|
||||||
|
"resolve_credential_envelope",
|
||||||
|
"retire_credential_envelope",
|
||||||
|
"update_credential_envelope",
|
||||||
|
]
|
||||||
@@ -30,18 +30,21 @@ def redact_secret_values(value: object, *, placeholder: str = "<redacted>") -> o
|
|||||||
return result
|
return result
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
return [redact_secret_values(item, placeholder=placeholder) for item in value]
|
return [redact_secret_values(item, placeholder=placeholder) for item in value]
|
||||||
|
if isinstance(value, tuple):
|
||||||
|
return tuple(redact_secret_values(item, placeholder=placeholder) for item in value)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def contains_plain_secret(value: object) -> bool:
|
def contains_plain_secret(value: object) -> bool:
|
||||||
if not isinstance(value, Mapping):
|
if isinstance(value, Mapping):
|
||||||
return False
|
|
||||||
for key, item in value.items():
|
for key, item in value.items():
|
||||||
normalized_key = str(key).strip().casefold().replace("-", "_")
|
normalized_key = str(key).strip().casefold().replace("-", "_")
|
||||||
if normalized_key in {"credential_ref", "secret_ref", "secret_reference"}:
|
if normalized_key in {"credential_ref", "secret_ref", "secret_reference"}:
|
||||||
continue
|
continue
|
||||||
if is_sensitive_key(key) and item not in (None, "", {"secret_ref": ""}): # nosec B105 - empty redaction sentinels.
|
if is_sensitive_key(key) and item not in (None, "", {"secret_ref": ""}): # nosec B105 - empty redaction sentinels.
|
||||||
return True
|
return True
|
||||||
if isinstance(item, Mapping) and contains_plain_secret(item):
|
if contains_plain_secret(item):
|
||||||
return True
|
return True
|
||||||
|
elif isinstance(value, (list, tuple)):
|
||||||
|
return any(contains_plain_secret(item) for item in value)
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Protocol, runtime_checkable
|
from typing import Any, Mapping, Protocol, runtime_checkable
|
||||||
|
|
||||||
from cryptography.fernet import Fernet, InvalidToken
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
@@ -18,6 +19,10 @@ class SecretDecryptionError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TransientPayloadError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider" # noqa: S105 # nosec B105 - capability identifier.
|
CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider" # noqa: S105 # nosec B105 - capability identifier.
|
||||||
|
|
||||||
|
|
||||||
@@ -72,3 +77,29 @@ def decrypt_secret(value: str | None) -> str | None:
|
|||||||
return _fernet().decrypt(value.encode("utf-8")).decode("utf-8")
|
return _fernet().decrypt(value.encode("utf-8")).decode("utf-8")
|
||||||
except InvalidToken as exc:
|
except InvalidToken as exc:
|
||||||
raise SecretDecryptionError("Stored secret cannot be decrypted with the configured master key") from exc
|
raise SecretDecryptionError("Stored secret cannot be decrypted with the configured master key") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def seal_transient_payload(payload: Mapping[str, Any]) -> str:
|
||||||
|
"""Seal a short-lived JSON object without persisting server-side state."""
|
||||||
|
|
||||||
|
encoded = json.dumps(
|
||||||
|
dict(payload),
|
||||||
|
separators=(",", ":"),
|
||||||
|
sort_keys=True,
|
||||||
|
).encode("utf-8")
|
||||||
|
return _fernet().encrypt(encoded).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def open_transient_payload(token: str, *, ttl_seconds: int) -> dict[str, Any]:
|
||||||
|
"""Open a sealed JSON object and enforce its maximum age."""
|
||||||
|
|
||||||
|
if ttl_seconds <= 0:
|
||||||
|
raise ValueError("Transient payload TTL must be positive")
|
||||||
|
try:
|
||||||
|
encoded = _fernet().decrypt(token.encode("utf-8"), ttl=ttl_seconds)
|
||||||
|
payload = json.loads(encoded.decode("utf-8"))
|
||||||
|
except (InvalidToken, UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError) as exc:
|
||||||
|
raise TransientPayloadError("Transient payload is invalid or expired") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise TransientPayloadError("Transient payload must contain a JSON object")
|
||||||
|
return payload
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from govoplan_core.db.session import configure_database
|
|||||||
from govoplan_core.server.config import GovoplanServerConfig, load_server_config
|
from govoplan_core.server.config import GovoplanServerConfig, load_server_config
|
||||||
from govoplan_core.server.fastapi import create_govoplan_app
|
from govoplan_core.server.fastapi import create_govoplan_app
|
||||||
from govoplan_core.server.platform import create_platform_router
|
from govoplan_core.server.platform import create_platform_router
|
||||||
|
from govoplan_core.server.credentials import router as credential_router
|
||||||
|
from govoplan_core.server.ownership import router as ownership_router
|
||||||
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
||||||
from govoplan_core.server.route_validation import validate_no_route_collisions
|
from govoplan_core.server.route_validation import validate_no_route_collisions
|
||||||
|
|
||||||
@@ -67,6 +69,8 @@ def _server_api_router(server_config: GovoplanServerConfig, registry) -> APIRout
|
|||||||
for router in server_config.base_routers:
|
for router in server_config.base_routers:
|
||||||
api_router.include_router(router)
|
api_router.include_router(router)
|
||||||
api_router.include_router(create_platform_router(settings=server_config.settings))
|
api_router.include_router(create_platform_router(settings=server_config.settings))
|
||||||
|
api_router.include_router(credential_router)
|
||||||
|
api_router.include_router(ownership_router)
|
||||||
for router in server_config.post_module_routers:
|
for router in server_config.post_module_routers:
|
||||||
api_router.include_router(router)
|
api_router.include_router(router)
|
||||||
for contribution in server_config.extra_routers:
|
for contribution in server_config.extra_routers:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user