Compare commits
133 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0898fcdee | |||
| 9b88ae388b | |||
| 6970bf7457 | |||
| 47e106684d | |||
| 9e219bc4d3 | |||
| ea436a513f | |||
| e7c84e3227 | |||
| cf7afe9dda | |||
| ca8a8c5111 | |||
| f3b388fe7e | |||
| af3e0a055d | |||
| 51d4032b86 | |||
| 0beb9ffea9 | |||
| 9e6a6b5fdc | |||
| 48fb953b93 | |||
| 4bde0495f7 | |||
| a80caf7933 | |||
| 790790ab37 | |||
| 920e3c9834 | |||
| 13893c80cd | |||
| a192a2215f | |||
| e8fed6d25a | |||
| d9b5708df0 | |||
| 68328f3d8e | |||
| 53e947935a | |||
| 324c26da78 | |||
| 389f98e349 | |||
| ce9ef8d88f | |||
| 13bc3d3b4e | |||
| 3f5870281a | |||
| a46df85479 | |||
| c31581b1b9 | |||
| 26ae034153 | |||
| baa2143a26 | |||
| 8b1910b5b7 | |||
| d36bb94335 | |||
| 74034947c6 | |||
| c7183fe7f1 | |||
| 139a352c80 | |||
| 336c94137f | |||
| 93225b6487 | |||
| e11ea81008 | |||
| bc8afeb139 | |||
| f876345656 | |||
| d487726f4d | |||
| e6fc07da37 | |||
| e6d589eb07 | |||
| 59610e21d2 | |||
| cece71d945 | |||
| 22e8183846 | |||
| aa111a5fe1 | |||
| e6062fe9e4 | |||
| 987ca894ed | |||
| 4caa326878 | |||
| 8c4c4456c6 | |||
| 6abe292ac8 | |||
| fea2807754 | |||
| 22646c614c | |||
| 17376332a2 | |||
| 0946bc84a9 | |||
| a18499cbb5 | |||
| 36d7b73bb5 | |||
| b89a2d15f1 | |||
| 7f923afdad | |||
| a7683c5d4a | |||
| 41ad057f7e | |||
| bf0729eb59 | |||
| c4b90181e0 | |||
| 55ed194a99 | |||
| b3b0cf0fca | |||
| fa9119bea7 | |||
| 70ca772138 | |||
| 2eae5c4df6 | |||
| 57fe6c6006 | |||
| 713afdb39b | |||
| 77f8d15d17 | |||
| fda99d40eb | |||
| 5ab1af803b | |||
| 0845e99cf6 | |||
| 28a0a596a6 | |||
| ae74189588 | |||
| 09b5009187 | |||
| 2ca61059dc | |||
| 865901f090 | |||
| 2ac1e64daa | |||
| 7526c5ebb2 | |||
| 8e1f64c790 | |||
| 66e4783d2e | |||
| 7af86b42eb | |||
| ad202f1267 | |||
| 6526f37aae | |||
| 9dabd9356d | |||
| 6502775bf7 | |||
| b2492b820f | |||
| 78d9ae48b2 | |||
| 4cb3e94de3 | |||
| 9131838b98 | |||
| 8e9eb6e1f5 | |||
| 249bf63eb8 | |||
| 248e3dc70e | |||
| 230ecf42b0 | |||
| 825791e9b0 | |||
| 7184b6cdd6 | |||
| ea8c600dce | |||
| 1839693575 | |||
| 183bf7aef0 | |||
| 37a5dfb182 | |||
| 844f934379 | |||
| a98475f7bc | |||
| 1153c9dd36 | |||
| 7eef52776c | |||
| c50ce58ad8 | |||
| 344fc0077f | |||
| 28afc01371 | |||
| 1a29e75db4 | |||
| 57ec960f40 | |||
| 6388afdad8 | |||
| 1a0e90b22d | |||
| 7ad6f6328a | |||
| c79a7124b7 | |||
| abbef5a10b | |||
| 2f559e3f0b | |||
| 9b5418db78 | |||
| 1678602fd6 | |||
| 6e373dcdd6 | |||
| d1c033edc7 | |||
| b5cfba666c | |||
| 78b4afdec4 | |||
| 15596f0742 | |||
| a2320fcb5d | |||
| e6f7c45f0a | |||
| 8aa1943581 | |||
| b9badc9153 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -138,11 +138,13 @@ dist
|
|||||||
|
|
||||||
# Local WebUI test/build scratch directories
|
# Local WebUI test/build scratch directories
|
||||||
.component-test-build/
|
.component-test-build/
|
||||||
|
.file-drop-test-build/
|
||||||
.module-test-build/
|
.module-test-build/
|
||||||
.policy-test-build/
|
.policy-test-build/
|
||||||
.template-preview-test-build/
|
.template-preview-test-build/
|
||||||
.import-test-build/
|
.import-test-build/
|
||||||
webui/.component-test-build/
|
webui/.component-test-build/
|
||||||
|
webui/.file-drop-test-build/
|
||||||
webui/.module-test-build/
|
webui/.module-test-build/
|
||||||
webui/.policy-test-build/
|
webui/.policy-test-build/
|
||||||
webui/.template-preview-test-build/
|
webui/.template-preview-test-build/
|
||||||
|
|||||||
28
README.md
28
README.md
@@ -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
|
||||||
@@ -54,7 +47,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 +67,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 +151,10 @@ PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versio
|
|||||||
|
|
||||||
The local host links sibling module WebUI packages through local file dependencies and Vite filesystem allowances. Release builds should use `webui/package.release.json`, which points WebUI module packages at tagged git refs. See [RELEASE_DEPENDENCIES.md](docs/RELEASE_DEPENDENCIES.md).
|
The local host links sibling module WebUI packages through local file dependencies and Vite filesystem allowances. Release builds should use `webui/package.release.json`, which points WebUI module packages at tagged git refs. See [RELEASE_DEPENDENCIES.md](docs/RELEASE_DEPENDENCIES.md).
|
||||||
|
|
||||||
|
Production builds lazy-load enabled module descriptors and enforce initial and
|
||||||
|
asynchronous JavaScript budgets. See
|
||||||
|
[WEBUI_BUNDLE_BUDGETS.md](docs/WEBUI_BUNDLE_BUDGETS.md).
|
||||||
|
|
||||||
## Module contract
|
## Module contract
|
||||||
|
|
||||||
Backend modules register through the `govoplan.modules` entry point and return a `ModuleManifest`. A manifest can contribute:
|
Backend modules register through the `govoplan.modules` entry point and return a `ModuleManifest`. A manifest can contribute:
|
||||||
|
|||||||
@@ -30,10 +30,14 @@ _TABLE_RENAMES = (
|
|||||||
("governance_templates", "admin_governance_templates"),
|
("governance_templates", "admin_governance_templates"),
|
||||||
("governance_template_assignments", "admin_governance_template_assignments"),
|
("governance_template_assignments", "admin_governance_template_assignments"),
|
||||||
)
|
)
|
||||||
|
_KNOWN_TABLE_NAMES = {name for pair in _TABLE_RENAMES for name in pair}
|
||||||
|
|
||||||
|
|
||||||
def _row_count(bind: sa.Connection, table_name: str) -> int:
|
def _row_count(bind: sa.Connection, table_name: str) -> int:
|
||||||
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {table_name}")).scalar_one())
|
if table_name not in _KNOWN_TABLE_NAMES:
|
||||||
|
raise RuntimeError(f"Unexpected table name: {table_name}")
|
||||||
|
quoted = bind.dialect.identifier_preparer.quote(table_name)
|
||||||
|
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one()) # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||||
|
|
||||||
|
|
||||||
def _rename_tables(renames: tuple[tuple[str, str], ...]) -> None:
|
def _rename_tables(renames: tuple[tuple[str, str], ...]) -> None:
|
||||||
|
|||||||
@@ -19,11 +19,14 @@ LEGACY_SCOPE_TABLE = "tenancy_tenants"
|
|||||||
CORE_SCOPE_TABLE = "core_scopes"
|
CORE_SCOPE_TABLE = "core_scopes"
|
||||||
LEGACY_SLUG_INDEX = "ix_tenancy_tenants_slug"
|
LEGACY_SLUG_INDEX = "ix_tenancy_tenants_slug"
|
||||||
CORE_SLUG_INDEX = "ix_core_scopes_slug"
|
CORE_SLUG_INDEX = "ix_core_scopes_slug"
|
||||||
|
_KNOWN_SCOPE_TABLES = {LEGACY_SCOPE_TABLE, CORE_SCOPE_TABLE}
|
||||||
|
|
||||||
|
|
||||||
def _row_count(bind: sa.Connection, table_name: str) -> int:
|
def _row_count(bind: sa.Connection, table_name: str) -> int:
|
||||||
|
if table_name not in _KNOWN_SCOPE_TABLES:
|
||||||
|
raise RuntimeError(f"Unexpected scope table name: {table_name}")
|
||||||
quoted = bind.dialect.identifier_preparer.quote(table_name)
|
quoted = bind.dialect.identifier_preparer.quote(table_name)
|
||||||
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one())
|
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one()) # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||||
|
|
||||||
|
|
||||||
def _scope_tables(bind: sa.Connection) -> set[str]:
|
def _scope_tables(bind: sa.Connection) -> set[str]:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ revision = "9d0e1f2a3b4c"
|
|||||||
down_revision = "8c9d0e1f2a3b"
|
down_revision = "8c9d0e1f2a3b"
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
_RECONCILE_CREATE_ALL_TABLES = ("admin_governance_template_assignments", "admin_governance_templates", "core_system_settings")
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
@@ -28,9 +29,10 @@ def upgrade() -> None:
|
|||||||
tables = set(inspector.get_table_names())
|
tables = set(inspector.get_table_names())
|
||||||
|
|
||||||
# Reconcile only the empty create_all shape for the newly introduced tables.
|
# Reconcile only the empty create_all shape for the newly introduced tables.
|
||||||
for table_name in ("admin_governance_template_assignments", "admin_governance_templates", "core_system_settings"):
|
for table_name in _RECONCILE_CREATE_ALL_TABLES:
|
||||||
if table_name in tables:
|
if table_name in tables:
|
||||||
count = bind.execute(sa.text(f"SELECT COUNT(*) FROM {table_name}")).scalar_one()
|
quoted = bind.dialect.identifier_preparer.quote(table_name)
|
||||||
|
count = bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one() # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||||
if count:
|
if count:
|
||||||
raise RuntimeError(f"Cannot reconcile non-empty create_all table {table_name}")
|
raise RuntimeError(f"Cannot reconcile non-empty create_all table {table_name}")
|
||||||
op.drop_table(table_name)
|
op.drop_table(table_name)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ def upgrade() -> None:
|
|||||||
placeholders = ", ".join(f":action_{index}" for index, _ in enumerate(SYSTEM_ACTIONS))
|
placeholders = ", ".join(f":action_{index}" for index, _ in enumerate(SYSTEM_ACTIONS))
|
||||||
params = {f"action_{index}": action for index, action in enumerate(SYSTEM_ACTIONS)}
|
params = {f"action_{index}": action for index, action in enumerate(SYSTEM_ACTIONS)}
|
||||||
bind.execute(
|
bind.execute(
|
||||||
sa.text(f"UPDATE audit_log SET scope = 'system' WHERE action IN ({placeholders})"),
|
sa.text(f"UPDATE audit_log SET scope = 'system' WHERE action IN ({placeholders})"), # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||||
params,
|
params,
|
||||||
)
|
)
|
||||||
bind.execute(sa.text("UPDATE audit_log SET scope = 'tenant' WHERE scope IS NULL OR scope NOT IN ('tenant', 'system')"))
|
bind.execute(sa.text("UPDATE audit_log SET scope = 'tenant' WHERE scope IS NULL OR scope NOT IN ('tenant', 'system')"))
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""add reusable core credential envelopes
|
||||||
|
|
||||||
|
Revision ID: c91f0a72be34
|
||||||
|
Revises: 0f1e2d3c4b5a
|
||||||
|
Create Date: 2026-07-23 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "c91f0a72be34"
|
||||||
|
down_revision = "0f1e2d3c4b5a"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "core_credential_envelopes" in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
op.create_table(
|
||||||
|
"core_credential_envelopes",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("scope_type", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("scope_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("credential_kind", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("public_data", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("secret_data_encrypted", sa.Text(), nullable=True),
|
||||||
|
sa.Column("secret_keys", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("allowed_modules", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("allowed_server_refs", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("inherit_to_lower_scopes", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("revision", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["tenant_id"],
|
||||||
|
["core_scopes.id"],
|
||||||
|
name=op.f("fk_core_credential_envelopes_tenant_id_core_scopes"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_credential_envelopes")),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_credential_envelopes_scope",
|
||||||
|
"core_credential_envelopes",
|
||||||
|
["tenant_id", "scope_type", "scope_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_credential_envelopes_active",
|
||||||
|
"core_credential_envelopes",
|
||||||
|
["tenant_id", "is_active", "deleted_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"scope_type",
|
||||||
|
"scope_id",
|
||||||
|
"credential_kind",
|
||||||
|
"is_active",
|
||||||
|
"created_by_user_id",
|
||||||
|
"updated_by_user_id",
|
||||||
|
"deleted_at",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_core_credential_envelopes_{column}"),
|
||||||
|
"core_credential_envelopes",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "core_credential_envelopes" in inspector.get_table_names():
|
||||||
|
op.drop_table("core_credential_envelopes")
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""development-track wrapper for generic ownership transfers."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib.util import module_from_spec, spec_from_file_location
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_path = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "versions"
|
||||||
|
/ "d03a7b9c1e5f_core_ownership_transfers.py"
|
||||||
|
)
|
||||||
|
_spec = spec_from_file_location("govoplan_core_ownership_transfer_migration", _path)
|
||||||
|
if _spec is None or _spec.loader is None:
|
||||||
|
raise RuntimeError(f"Unable to load ownership migration from {_path}")
|
||||||
|
_migration = module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(_migration)
|
||||||
|
|
||||||
|
revision = _migration.revision
|
||||||
|
down_revision = _migration.down_revision
|
||||||
|
branch_labels = _migration.branch_labels
|
||||||
|
depends_on = _migration.depends_on
|
||||||
|
upgrade = _migration.upgrade
|
||||||
|
downgrade = _migration.downgrade
|
||||||
@@ -5,9 +5,15 @@ from logging.config import fileConfig
|
|||||||
from alembic import context
|
from alembic import context
|
||||||
from sqlalchemy import engine_from_config, pool
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
|
try:
|
||||||
|
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate optional access metadata
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
if exc.name != "govoplan_access":
|
||||||
|
raise
|
||||||
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
|
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
|
||||||
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
|
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
|
||||||
|
from govoplan_core.core import ownership as core_ownership_models # noqa: F401 - populate core metadata
|
||||||
|
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
|
||||||
from govoplan_core.core.migrations import migration_metadata_plan
|
from govoplan_core.core.migrations import migration_metadata_plan
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_core.server.default_config import get_server_config
|
from govoplan_core.server.default_config import get_server_config
|
||||||
@@ -20,7 +26,10 @@ database_url = config.attributes.get("database_url") or settings.database_url
|
|||||||
config.set_main_option("sqlalchemy.url", database_url)
|
config.set_main_option("sqlalchemy.url", database_url)
|
||||||
|
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
fileConfig(config.config_file_name)
|
# Migrations can run inside the long-lived application process when module
|
||||||
|
# state changes. Do not let Alembic's logging setup disable loggers that the
|
||||||
|
# server already created (for example slow-request diagnostics).
|
||||||
|
fileConfig(config.config_file_name, disable_existing_loggers=False)
|
||||||
|
|
||||||
|
|
||||||
def _target_metadata():
|
def _target_metadata():
|
||||||
|
|||||||
119
alembic/versions/c91f0a72be34_core_credential_envelopes.py
Normal file
119
alembic/versions/c91f0a72be34_core_credential_envelopes.py
Normal file
@@ -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")
|
||||||
129
alembic/versions/d03a7b9c1e5f_core_ownership_transfers.py
Normal file
129
alembic/versions/d03a7b9c1e5f_core_ownership_transfers.py
Normal file
@@ -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")
|
||||||
59
docs/AUTOMATION_CONTRACTS.md
Normal file
59
docs/AUTOMATION_CONTRACTS.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Automation Contracts
|
||||||
|
|
||||||
|
Core defines provider-neutral automation contracts. It does not own domain
|
||||||
|
schedules, Workflow graphs, or Dataflow execution.
|
||||||
|
|
||||||
|
## Invocation Envelope
|
||||||
|
|
||||||
|
`AutomationInvocation` classifies a start as `manual`, `api`, `schedule`,
|
||||||
|
`event`, `workflow`, `dependency`, `retry`, or `backfill`. It carries opaque
|
||||||
|
trigger and delivery references, event identity, correlation and causation
|
||||||
|
IDs, scheduled time, requesting actor, and bounded metadata. Domain runs store
|
||||||
|
this envelope with their immutable definition revision.
|
||||||
|
|
||||||
|
## Current Authorization
|
||||||
|
|
||||||
|
An automated trigger must not persist a user session, bearer token, API key,
|
||||||
|
or a snapshot of all current permissions. It stores:
|
||||||
|
|
||||||
|
- tenant, account, and membership IDs;
|
||||||
|
- an opaque authorization reference;
|
||||||
|
- the minimum scopes required by the pinned definition and output target.
|
||||||
|
|
||||||
|
At delivery time the optional
|
||||||
|
`auth.automationPrincipalProvider` capability resolves current account,
|
||||||
|
membership, role, group, function, and delegation state. It intersects current
|
||||||
|
authorization with the stored grant. Missing, inactive, or reduced
|
||||||
|
authorization blocks the delivery before effects occur.
|
||||||
|
|
||||||
|
## Definition Governance
|
||||||
|
|
||||||
|
The optional `policy.definitionGovernance` capability evaluates `view`,
|
||||||
|
`edit`, `run`, `reuse`, `derive`, and `automate` for system, tenant, group, and
|
||||||
|
user definitions. A decision contains an ordered source path and effective
|
||||||
|
limits. Derived definitions pin their source revision and hash and retain
|
||||||
|
ancestor ceilings. Templates are reusable definitions and cannot run on their
|
||||||
|
own.
|
||||||
|
|
||||||
|
Without Policy, domain modules use a conservative tenant-local fallback:
|
||||||
|
local definitions remain viewable/editable and active complete flows may run;
|
||||||
|
inheritance, reuse, derivation, and automation are unavailable.
|
||||||
|
|
||||||
|
## Delivery Durability
|
||||||
|
|
||||||
|
Domain trigger implementations persist idempotent deliveries before running.
|
||||||
|
`emit_platform_event` binds event delivery to the producer's SQLAlchemy
|
||||||
|
transaction. When an enabled module provides `platform.eventOutbox`, the event
|
||||||
|
is stored in that transaction and a dispatcher may retry it across restarts and
|
||||||
|
workers. The Audit module provides the current SQL outbox implementation; the
|
||||||
|
Celery `govoplan.events.dispatch_outbox` task drains it through the Dataflow
|
||||||
|
event-ingestion capability and the local event bus.
|
||||||
|
|
||||||
|
The outbox capability remains optional so reduced module combinations can
|
||||||
|
start. Without it, Core queues events on the SQLAlchemy transaction and
|
||||||
|
publishes them to the process-local bus only after the outer commit. A rollback,
|
||||||
|
including a nested savepoint rollback, discards the corresponding events. This
|
||||||
|
fallback is suitable for local or non-critical reactions, but it is not a
|
||||||
|
durable multi-worker automation source. Deployments that rely on event-triggered
|
||||||
|
work must enable the outbox provider and run the `events` worker queue and
|
||||||
|
periodic dispatcher.
|
||||||
43
docs/COMPATIBILITY_INVENTORY.md
Normal file
43
docs/COMPATIBILITY_INVENTORY.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# GovOPlaN Compatibility Inventory
|
||||||
|
|
||||||
|
This inventory classifies compatibility paths covered by
|
||||||
|
`COMPATIBILITY_POLICY.md`. It is intentionally limited to behavior that changes
|
||||||
|
accepted data, imports, permissions, or migration state. Operational fallbacks
|
||||||
|
such as Redis degradation and language fallback are not compatibility paths.
|
||||||
|
|
||||||
|
## Database Bridges
|
||||||
|
|
||||||
|
| Path | Purpose | Retention | Removal |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `govoplan_core.db.migrations.reconcile_legacy_create_all_schema` | Reconciles databases created before Alembic ownership was recorded. | At least one major release after runtime aliases are removed. | Review after `1.0`; keep release-baseline tests. |
|
||||||
|
| Migration table/column aliases in `govoplan_core.db.migrations` | Detect and reconcile pre-split table ownership and migration tracks. | All tagged `0.1.x` upgrade origins plus one major release cycle. | Remove only after the corresponding baseline leaves support. |
|
||||||
|
| Access and module migration backfills for legacy permission names | Converts persisted role assignments without dropping authority. | Same as the database upgrade origin that contains the old role. | Keep migrations immutable; remove only runtime expansion at `0.2`. |
|
||||||
|
|
||||||
|
## Portable-Schema Readers
|
||||||
|
|
||||||
|
| Path | Purpose | Retention | Removal |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `govoplan_core.mail.config.normalize_split_transport_credentials` | Reads pre-split SMTP/IMAP credentials and emits the split representation. | Current and previous two configuration schema versions. | Version-gate once Mail writes an explicit current schema version; reject inputs older than the two-version window. |
|
||||||
|
| `ImapServerConfig.discard_legacy_enabled` | Reads the former nested IMAP `enabled` field without writing it. | Current and previous two configuration schema versions. | Remove with the oldest accepted Mail configuration schema. |
|
||||||
|
| `govoplan_core.core.configuration_packages` readers | Reads explicitly versioned configuration-package manifests. | Current and previous two schema versions. | Retire individual readers as their version leaves the window. |
|
||||||
|
|
||||||
|
## Runtime And API Aliases
|
||||||
|
|
||||||
|
| Path | Purpose | Retention | Removal |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `govoplan_core.security.scope_aliases.LEGACY_SCOPE_ALIASES` | Expands pre-granular permission names. | Tagged `0.1.x` runtime/API window. | Remove at `0.2` after role backfills and migration notes are verified. |
|
||||||
|
| `govoplan_core.security.module_permissions.LEGACY_TO_MODULE_SCOPES` | Maps pre-module-split scopes to canonical owning-module scopes. | Tagged `0.1.x` runtime/API window. | Remove at `0.2`; keep database migration evidence for one major cycle. |
|
||||||
|
| `govoplan_core.privacy.retention` | Stable import facade delegating policy-owned behavior through a capability. | Tagged `0.1.x` import window. | Remove at `0.2` after all in-tree callers use the policy contract and release notes name the replacement. |
|
||||||
|
| Legacy tenant aliases in API response schemas | Preserves active-tenant response fields used by `0.1.x` clients. | Tagged `0.1.x` API window. | Remove at `0.2` with response-schema migration notes. |
|
||||||
|
| Optional fields in Poll response references | Accepts providers built against the earlier Poll contract. | Tagged `0.1.x` runtime contract window. | Remove or require a new interface version at `0.2`. |
|
||||||
|
| Legacy single-tenant summary providers | Allows modules without the batch provider introduced in `0.1.x`. | Tagged `0.1.x` module contract window. | Remove at `0.2` after manifests advertise the batch provider contract. |
|
||||||
|
|
||||||
|
## Removed Paths
|
||||||
|
|
||||||
|
| Path | Reason | Removed |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `govoplan_core.core.module_installer._run_restart_command_legacy` | Private wrapper had no callers and never represented a persisted or published contract. | Current development line |
|
||||||
|
| Retired `govoplan_core.api.admin` and pre-split core model imports | In-tree callers and module packages use their owning modules; regression tests prohibit reintroduction. | Before `0.1.10` |
|
||||||
|
|
||||||
|
Every new compatibility path must be added here with its classification,
|
||||||
|
diagnostic, test owner, and planned removal release.
|
||||||
69
docs/COMPATIBILITY_POLICY.md
Normal file
69
docs/COMPATIBILITY_POLICY.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# GovOPlaN Compatibility Policy
|
||||||
|
|
||||||
|
This document defines the compatibility window that release tooling, module
|
||||||
|
owners, migration authors, import/export providers, and API maintainers must
|
||||||
|
preserve. It is the source of truth for deciding whether compatibility code can
|
||||||
|
be removed.
|
||||||
|
|
||||||
|
## Database Upgrades
|
||||||
|
|
||||||
|
- A released installation from every tagged `0.1.x` version is a supported
|
||||||
|
database upgrade origin.
|
||||||
|
- The recorded public release-baseline ledger starts at `v0.1.7`; earlier
|
||||||
|
`0.1.x` tags predate production installations. If an earlier tagged database
|
||||||
|
is encountered, the release must provide or document a compatibility bridge
|
||||||
|
instead of silently treating the database as a fresh installation.
|
||||||
|
- Released migration revision IDs and recorded release heads are immutable.
|
||||||
|
- Each release must prove an upgrade from every still-supported recorded
|
||||||
|
baseline, as well as a fresh installation, before its tag is published.
|
||||||
|
- Migration-only reconciliation needed by an old database remains available for
|
||||||
|
at least one subsequent major release cycle after the corresponding runtime
|
||||||
|
compatibility path is removed.
|
||||||
|
|
||||||
|
The release-baseline format and commands are documented in
|
||||||
|
`RELEASE_DEPENDENCIES.md`.
|
||||||
|
|
||||||
|
## Configuration And Export Schemas
|
||||||
|
|
||||||
|
- Writers emit only the current schema version.
|
||||||
|
- Readers accept the current schema version and the previous two schema
|
||||||
|
versions.
|
||||||
|
- Older input is rejected with a diagnostic that identifies its version and the
|
||||||
|
required staged upgrade or conversion path.
|
||||||
|
- A module-owned configuration provider must version its input and output
|
||||||
|
schema explicitly. It must not infer an old schema from missing fields once a
|
||||||
|
versioned schema has shipped.
|
||||||
|
- Round-trip and upgrade tests must cover all three readable versions before a
|
||||||
|
schema change is released.
|
||||||
|
|
||||||
|
This window applies to configuration packages, module-owned exports, and other
|
||||||
|
portable GovOPlaN configuration artifacts. Domain interchange standards with
|
||||||
|
their own compatibility rules remain governed by the owning module.
|
||||||
|
|
||||||
|
## Runtime And API Aliases
|
||||||
|
|
||||||
|
- Compatibility aliases must emit an explicit deprecation diagnostic and point
|
||||||
|
to the supported replacement.
|
||||||
|
- New callers must use the canonical contract. In-tree callers may not add new
|
||||||
|
uses of a deprecated alias.
|
||||||
|
- Runtime imports, request fields, response fields, routes, and scope aliases
|
||||||
|
carried for the `0.1.x` split line are retired at `0.2`, with migration notes.
|
||||||
|
- An alias may be removed earlier only when it never shipped in a tag or when a
|
||||||
|
security fix requires removal. The release notes must state the exception.
|
||||||
|
- Database reconciliation code is not a runtime/API alias and follows the
|
||||||
|
longer database window above.
|
||||||
|
|
||||||
|
## Removal Checklist
|
||||||
|
|
||||||
|
Compatibility code can be removed only when all of the following are true:
|
||||||
|
|
||||||
|
1. The path is inventoried as a database bridge, portable-schema reader, or
|
||||||
|
runtime/API alias.
|
||||||
|
2. Its minimum retention window has elapsed.
|
||||||
|
3. In-tree callers and published module manifests use the replacement.
|
||||||
|
4. Upgrade, import, or API regression tests cover the retained window.
|
||||||
|
5. Diagnostics and migration notes identify any staged action operators must
|
||||||
|
take.
|
||||||
|
|
||||||
|
If one condition is not met, version-gate the compatibility path and record its
|
||||||
|
planned removal release instead of deleting it.
|
||||||
@@ -69,6 +69,11 @@ Required package metadata:
|
|||||||
- 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
|
||||||
|
|
||||||
|
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;
|
||||||
mail imports mail templates and delivery defaults; payments imports payment
|
mail imports mail templates and delivery defaults; payments imports payment
|
||||||
|
|||||||
@@ -144,8 +144,12 @@ tools/checks/postgres-integration-check.py \
|
|||||||
```
|
```
|
||||||
|
|
||||||
The integration check runs migrations and startup smoke checks across the
|
The integration check runs migrations and startup smoke checks across the
|
||||||
standard module permutations. `--reset-schema` is destructive and belongs only
|
standard module permutations. It first requires the retirement atomicity proof,
|
||||||
on throwaway databases.
|
using Files' real secret-owning provider and Audit's persistent recorder. That
|
||||||
|
proof uses only random, test-owned schemas and cleans them afterward; it does
|
||||||
|
not reset `public`. `--reset-schema` is destructive and belongs only on
|
||||||
|
throwaway databases. Do not pass `--skip-retirement-atomicity` when collecting
|
||||||
|
release evidence.
|
||||||
|
|
||||||
### Broker And Workers
|
### Broker And Workers
|
||||||
|
|
||||||
@@ -153,16 +157,26 @@ on throwaway databases.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. |
|
| `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. |
|
||||||
| `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. |
|
| `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. |
|
||||||
| `CELERY_QUEUES` | `send_email,append_sent,default` | Queue list expected by worker/process manager definitions. |
|
| `CELERY_QUEUES` | `send_email,append_sent,notifications,calendar,dataflow,events,default` | Queue list expected by worker/process manager definitions. The `events` queue drains transactional platform events; `dataflow` drains trigger deliveries and schedules. |
|
||||||
|
| `PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS` | `8` | Failed durable consumer deliveries are quarantined after this many attempts. |
|
||||||
|
| `PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS` | `90` | Successful event envelopes older than this are removed by the daily retention task. Quarantined evidence is retained. |
|
||||||
|
|
||||||
Worker command:
|
Worker command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m celery -A govoplan_core.celery_app:celery worker \
|
python -m celery -A govoplan_core.celery_app:celery worker \
|
||||||
--queues send_email,append_sent,default \
|
--queues send_email,append_sent,notifications,calendar,dataflow,events,default \
|
||||||
--loglevel INFO
|
--loglevel INFO
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run Celery beat as a separately supervised process. Its built-in one-minute
|
||||||
|
schedule recovers Calendar outbox rows left behind by broker failures, process
|
||||||
|
crashes, and expired worker leases:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m celery -A govoplan_core.celery_app:celery beat --loglevel INFO
|
||||||
|
```
|
||||||
|
|
||||||
### Storage
|
### Storage
|
||||||
|
|
||||||
| Setting | Default | Notes |
|
| Setting | Default | Notes |
|
||||||
@@ -184,15 +198,64 @@ prefer `FILE_STORAGE_*`.
|
|||||||
| Setting | Default | Notes |
|
| Setting | Default | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `CORS_ORIGINS` | local dev origins | Set to the exact WebUI origins in staging/production. |
|
| `CORS_ORIGINS` | local dev origins | Set to the exact WebUI origins in staging/production. |
|
||||||
|
| `GOVOPLAN_TRUSTED_HOSTS` | empty | Exact API host names accepted by the application. Production-like validation requires an explicit list; narrowly scoped `*.example.org` entries are supported. |
|
||||||
|
| `FORWARDED_ALLOW_IPS` | Uvicorn default | Address or network of the trusted reverse proxy. Never use `*` in production-like deployments. |
|
||||||
| `AUTH_SESSION_COOKIE_NAME` | configured default | Change only through a controlled rollout because it logs users out. |
|
| `AUTH_SESSION_COOKIE_NAME` | configured default | Change only through a controlled rollout because it logs users out. |
|
||||||
| `AUTH_CSRF_COOKIE_NAME` | configured default | Must match WebUI/API deployment. |
|
| `AUTH_CSRF_COOKIE_NAME` | configured default | Must match WebUI/API deployment. |
|
||||||
| `AUTH_COOKIE_SECURE` | `false` | Set `true` behind HTTPS. |
|
| `AUTH_COOKIE_SECURE` | `false` | Set `true` behind HTTPS. |
|
||||||
| `AUTH_COOKIE_SAMESITE` | `lax` | Use a stricter value only after testing login and CSRF flows. |
|
| `AUTH_COOKIE_SAMESITE` | `lax` | Use a stricter value only after testing login and CSRF flows. |
|
||||||
| `AUTH_COOKIE_DOMAIN` | empty | Set only when the API and WebUI intentionally share a parent domain. |
|
| `AUTH_COOKIE_DOMAIN` | empty | Set only when the API and WebUI intentionally share a parent domain. |
|
||||||
|
| `GOVOPLAN_HTTP_HSTS_SECONDS` | `31536000` in production, otherwise `0` | Emitted only for HTTPS requests. Set `0` while rehearsing a deployment that is not yet HTTPS-only. |
|
||||||
|
| `GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES` | `536870912` (512 MiB) | Deployment hard ceiling; file and module APIs apply their own lower limits where appropriate. |
|
||||||
|
|
||||||
|
Interactive password login is enabled with fixed-window limits of 10 failures
|
||||||
|
per normalized identity and 100 failures per direct client over 900 seconds.
|
||||||
|
`AUTH_LOGIN_THROTTLE_*` settings change those limits. Counters use `REDIS_URL`
|
||||||
|
when Redis is reachable so replicas share state. Production-like startup fails
|
||||||
|
when throttling is enabled without `REDIS_URL`. Set
|
||||||
|
`GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE=true` only as an explicit
|
||||||
|
single-process risk acceptance. A bounded process-local fallback keeps
|
||||||
|
development and temporary Redis outages functional, with per-process
|
||||||
|
enforcement until Redis recovers; monitor Redis because protection is weaker
|
||||||
|
during that fallback.
|
||||||
|
|
||||||
|
### Outbound Connector Egress
|
||||||
|
|
||||||
|
| Setting | Default | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS` | `true` in dev/test, otherwise `false` | Deployment-wide decision. Set `true` only when pinned HTTP(S), DAV, SMTP, or IMAP transports must reach internal addresses. It does not enable an SDK transport that cannot pin every peer. |
|
||||||
|
| `GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES` | `16777216` (16 MiB) | Maximum buffered JSON, XML, iCalendar, vCard, catalog, and connector error response. |
|
||||||
|
| `GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES` | `536870912` (512 MiB) | Hard upper bound for a single remote file; module upload limits may be lower. |
|
||||||
|
| `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST` | empty | Comma-separated exact environment names usable by deployment-owned connector profiles. Tenant/API-managed profiles cannot select process variables, even when a name is listed. |
|
||||||
|
| `GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST` | empty | Comma-separated exact absolute CA bundle paths. Mount the same files at the same paths on every API and connector worker. |
|
||||||
|
|
||||||
|
Production-like configuration validation requires the private-network choice to
|
||||||
|
be explicit. HTTP connector downloads are streamed up to the configured bound,
|
||||||
|
and credential-bearing DAV redirects remain confined to their configured
|
||||||
|
origin.
|
||||||
|
|
||||||
|
The urllib, HTTPX/httpcore, SMTP, and IMAP transports resolve, validate, and
|
||||||
|
connect to the same approved address record while retaining the original host
|
||||||
|
for HTTP Host, TLS SNI, and certificate verification. Live SMB and S3 access
|
||||||
|
fails closed in both public-only and private-network deployments: the current
|
||||||
|
SDK transports cannot pin every initial and secondary peer or revalidate every
|
||||||
|
SDK-managed redirect/referral. An explicit IP endpoint does not bypass this
|
||||||
|
rule. Production deployments should still enforce the same decision at their
|
||||||
|
worker/container egress firewall or outbound proxy as a second boundary.
|
||||||
|
|
||||||
|
File connector TLS verification may be disabled only in dev/test. A custom CA
|
||||||
|
bundle must be an existing regular file whose resolved absolute path is listed
|
||||||
|
in `GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST`. Environment-backed file connector
|
||||||
|
credentials are supported only in deployment-owned connector JSON and require
|
||||||
|
their exact names in `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST`; UI/API profiles
|
||||||
|
must use encrypted stored credentials or a scoped secret-provider reference.
|
||||||
|
|
||||||
Public URLs are currently supplied by deployment/reverse-proxy configuration and
|
Public URLs are currently supplied by deployment/reverse-proxy configuration and
|
||||||
module settings. Do not hardcode them in core; configuration packages should ask
|
module settings. Do not hardcode them in core; configuration packages should ask
|
||||||
for portal, WebUI, postbox, and notification URLs when they become relevant.
|
for portal, WebUI, postbox, and notification URLs when they become relevant.
|
||||||
|
Uvicorn applies `X-Forwarded-*` only from `FORWARDED_ALLOW_IPS`; keep that value
|
||||||
|
aligned with the reverse proxy and do not expose the application server directly
|
||||||
|
through the same trusted address range.
|
||||||
|
|
||||||
### Module Catalogs, Licenses, And Trust Roots
|
### Module Catalogs, Licenses, And Trust Roots
|
||||||
|
|
||||||
@@ -275,7 +338,7 @@ the checked in `.env.example`. It runs:
|
|||||||
- explicit `ENABLED_MODULES`
|
- explicit `ENABLED_MODULES`
|
||||||
- explicit migrations and `--with-dev-data` bootstrap
|
- explicit migrations and `--with-dev-data` bootstrap
|
||||||
- API via the module-aware devserver
|
- API via the module-aware devserver
|
||||||
- a Celery worker for `send_email,append_sent,default`
|
- a Celery worker for `send_email,append_sent,notifications,calendar,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`
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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. |
|
||||||
@@ -25,6 +26,7 @@ operator, and roadmap pages.
|
|||||||
| Release dependencies and catalogs | `RELEASE_DEPENDENCIES.md` | Release package refs, migration baselines, release lockfiles, catalog trust/licensing, catalog publishing, and release checklist. |
|
| 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
47
docs/EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md
Normal file
47
docs/EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# External References And Integration Maturity
|
||||||
|
|
||||||
|
GovOPlaN integrations use a shared external-reference contract instead of
|
||||||
|
storing connector-specific URLs and identifiers in every module.
|
||||||
|
|
||||||
|
An external reference identifies an object by:
|
||||||
|
|
||||||
|
- external system instance
|
||||||
|
- object type
|
||||||
|
- stable external object ID
|
||||||
|
- optional connector configuration
|
||||||
|
- canonical HTTP(S) URL without embedded credentials
|
||||||
|
- optional source version, ETag, observation time, and non-secret metadata
|
||||||
|
|
||||||
|
The identity key is `system:object_type:object_id`. A GovOPlaN object may retain
|
||||||
|
multiple references, but one reference must never silently change its identity.
|
||||||
|
Moving or escalating work creates a new object and an explicit relationship; it
|
||||||
|
does not rewrite either object's history.
|
||||||
|
|
||||||
|
## Integration Maturity
|
||||||
|
|
||||||
|
Maturity is cumulative:
|
||||||
|
|
||||||
|
1. `discover`: identify configured external systems and their health.
|
||||||
|
2. `link`: retain and open stable external references.
|
||||||
|
3. `search`: include authorized external objects in GovOPlaN search.
|
||||||
|
4. `read`: display authoritative external content.
|
||||||
|
5. `publish`: create or update external content from GovOPlaN.
|
||||||
|
6. `synchronize`: reconcile changes in both directions with conflict handling.
|
||||||
|
7. `migrate`: perform a governed, verifiable transfer into GovOPlaN.
|
||||||
|
8. `replace`: provide the native operational capability without the external tool.
|
||||||
|
|
||||||
|
Connectors must declare and document the maturity they actually implement.
|
||||||
|
`synchronize` requires durable cursors, idempotency, provenance, conflict
|
||||||
|
handling, deletion semantics, and observable failures. A link-only connector
|
||||||
|
must not imply that GovOPlaN holds an authoritative copy.
|
||||||
|
|
||||||
|
## Domain Ownership
|
||||||
|
|
||||||
|
- Domain modules own native GovOPlaN objects and their authorization.
|
||||||
|
- Connectors own protocols, credentials, discovery, transport, and sync state.
|
||||||
|
- Search owns indexing and result aggregation, but source modules remain
|
||||||
|
responsible for authorization.
|
||||||
|
- Core owns only the stable DTOs and extension contracts.
|
||||||
|
|
||||||
|
The Python contract is
|
||||||
|
`govoplan_core.core.external_references.ExternalObjectReference`.
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
# GovOPlaN Master Roadmap
|
# GovOPlaN Master Roadmap
|
||||||
|
|
||||||
This roadmap is the durable product north star and sequencing guide for
|
This roadmap is the technical and module-sequencing companion for GovOPlaN as
|
||||||
GovOPlaN as a modular platform for administrative operations. It keeps the
|
a modular platform for administrative operations. It translates the
|
||||||
product moving without turning every possible public-sector need into an
|
cross-product outcome horizons into dependency waves without turning every
|
||||||
immediate implementation track.
|
possible public-sector need into an immediate implementation track.
|
||||||
|
|
||||||
Use this document for product direction, sequencing, and module routing. Issues
|
Use this document for technical sequencing, module routing, and implementation
|
||||||
are the active backlog; this document is durable planning context and should be
|
gates. Issues are the active backlog; this document is durable architecture
|
||||||
mirrored to the Gitea wiki.
|
planning context and should be mirrored to the Gitea wiki.
|
||||||
|
|
||||||
|
The meta repository's
|
||||||
|
[Connected Governance Platform Roadmap](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
|
||||||
|
describes the corresponding cross-product stakeholder visions, configurable
|
||||||
|
service and operating configurations, connected outcome stories, and
|
||||||
|
capability horizons. The selected five-stage delivery sequence and its gates
|
||||||
|
are in the meta repository's
|
||||||
|
[Reference Journey Program](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/REFERENCE_JOURNEY_PROGRAM.md).
|
||||||
|
Those product documents are canonical; this Core roadmap remains their
|
||||||
|
technical sequencing and module-routing companion.
|
||||||
|
|
||||||
## Product Thesis
|
## Product Thesis
|
||||||
|
|
||||||
@@ -113,7 +123,8 @@ pattern exists.
|
|||||||
|
|
||||||
## Focus Rules
|
## Focus Rules
|
||||||
|
|
||||||
1. Build one reference journey per wave.
|
1. Build one selected reference journey stage at a time; a later capability
|
||||||
|
cluster is not an active program merely because it appears below.
|
||||||
2. Do not implement a module because the repository exists.
|
2. Do not implement a module because the repository exists.
|
||||||
3. Do not add module-to-module imports for optional behavior.
|
3. Do not add module-to-module imports for optional behavior.
|
||||||
4. Every new domain module must justify its own semantics beyond `cases`,
|
4. Every new domain module must justify its own semantics beyond `cases`,
|
||||||
@@ -138,11 +149,15 @@ pattern exists.
|
|||||||
| Internal work queues and tasks | `govoplan-tasks` |
|
| Internal work queues and tasks | `govoplan-tasks` |
|
||||||
| Appointment proposals and booking | `govoplan-appointments`, `govoplan-calendar` |
|
| Appointment proposals and booking | `govoplan-appointments`, `govoplan-calendar` |
|
||||||
| Postbox, email, and notifications | `govoplan-postbox`, `govoplan-mail`, `govoplan-notifications` |
|
| Postbox, email, and notifications | `govoplan-postbox`, `govoplan-mail`, `govoplan-notifications` |
|
||||||
|
| Canonical subjects and account links | `govoplan-identity` |
|
||||||
|
| Organizational structures, units, and functions | `govoplan-organizations` |
|
||||||
|
| Identity-to-function assignments and directory synchronization | `govoplan-idm` |
|
||||||
| Identity trust, device keys, and encrypted postbox key contracts | `govoplan-identity-trust`, `govoplan-access`, `govoplan-postbox` |
|
| Identity trust, device keys, and encrypted postbox key contracts | `govoplan-identity-trust`, `govoplan-access`, `govoplan-postbox` |
|
||||||
| Service directory/catalog | `govoplan-portal` |
|
| Service directory/catalog | `govoplan-portal` |
|
||||||
| Permit/document generation | `govoplan-templates`, `govoplan-dms` |
|
| Permit/document generation | `govoplan-templates`, `govoplan-dms` |
|
||||||
| Payment capture and accounting handoff | `govoplan-payments`, `govoplan-ledger` |
|
| Payment capture and accounting handoff | `govoplan-payments`, `govoplan-ledger` |
|
||||||
| Roles, permissions, tenants, policy, audit | `govoplan-access`, `govoplan-tenancy`, `govoplan-policy`, `govoplan-audit` |
|
| Authentication projection, roles, permissions, acting context | `govoplan-access` |
|
||||||
|
| Tenants, policy, and audit | `govoplan-tenancy`, `govoplan-policy`, `govoplan-audit` |
|
||||||
| External software integration | `govoplan-connectors` |
|
| External software integration | `govoplan-connectors` |
|
||||||
| Recurring extraction and transformation | possible future `govoplan-datasources`, possible future `govoplan-dataflow` |
|
| Recurring extraction and transformation | possible future `govoplan-datasources`, possible future `govoplan-dataflow` |
|
||||||
| Reports, BI, and management visibility | `govoplan-reporting` |
|
| Reports, BI, and management visibility | `govoplan-reporting` |
|
||||||
@@ -189,56 +204,82 @@ an editor applies a high-impact configuration change.
|
|||||||
|
|
||||||
## Reference Journeys
|
## Reference Journeys
|
||||||
|
|
||||||
The roadmap should be driven by three journeys.
|
The active sequence is selected. Workflow remains deliberately deferred and is
|
||||||
|
not a dependency of these journeys.
|
||||||
|
|
||||||
### Journey 1: Permit To Payment
|
### Journey 1: Campaign Demonstration Composition
|
||||||
|
|
||||||
This is the primary public-administration journey.
|
Campaign is the first complete proof of modular composition. Campaign owns
|
||||||
|
intent, recipient snapshots, personalization, execution state, and delivery
|
||||||
|
evidence. Mail owns reusable profiles, credentials, protocol policy, and
|
||||||
|
provider execution; Campaign stores only a selected profile reference. Files
|
||||||
|
owns storage, connector profiles, file policy, and provenance.
|
||||||
|
|
||||||
1. A person applies for a permit through the public portal.
|
The technical gate is a pinned Campaign/Mail/Files composition with central
|
||||||
2. The applicant uploads required files and submits structured form data.
|
UI, adaptive user/admin/operator/integration documentation, target SMTP/IMAP
|
||||||
3. Submission creates a case, a workflow instance, and an internal task.
|
and file-provider evidence, and explicit test/send/resend/retry/reconciliation
|
||||||
4. Completing the task creates a postbox message, a notification, and an email
|
semantics. Readers must not receive backend paths, worker claims, secrets, or
|
||||||
notification with an appointment proposal.
|
raw provider diagnostics.
|
||||||
5. The applicant accepts an appointment, which updates the calendar and the
|
|
||||||
workflow state.
|
|
||||||
6. During the appointment, the case is opened and the permit is generated from
|
|
||||||
a governed template.
|
|
||||||
7. The payment is processed and linked to the case and accounting handoff.
|
|
||||||
8. The permit, payment evidence, communication history, audit trail, retention
|
|
||||||
state, and records evidence remain available according to policy.
|
|
||||||
|
|
||||||
This journey proves the platform can coordinate modules without core knowing
|
### Journey 2: Function-Bound Postbox Delivery
|
||||||
module internals.
|
|
||||||
|
|
||||||
### Journey 2: Training To Certificate
|
Postbox accepts delivery to an addressable postbox or a function in an
|
||||||
|
organizational unit. Organizations owns units and functions, Identity owns
|
||||||
|
subjects, IDM owns identity-to-function assignments and upstream sync, and
|
||||||
|
Access resolves current roles, delegation, acting context, and permission.
|
||||||
|
|
||||||
This is the best university-administration and internal-administration journey.
|
Campaign consumes a typed delivery-target capability without importing Postbox
|
||||||
|
or identity internals. Reassignment changes future access without moving the
|
||||||
|
message; vacancy or ambiguous acting context fails visibly; delivery, access,
|
||||||
|
and correction remain auditable.
|
||||||
|
|
||||||
1. Course or training offer is planned.
|
### Journey 3: Data-Backed Templates, Reports, And Deep Launch
|
||||||
2. Room, trainer, resource, and capacity are booked.
|
|
||||||
3. Participants register or are assigned.
|
|
||||||
4. Attendance is tracked.
|
|
||||||
5. Certificate or participation confirmation is issued.
|
|
||||||
6. Evidence remains available through records, files, audit, and docs.
|
|
||||||
|
|
||||||
This journey keeps `booking`, `resources`, `learning`, and `certificates`
|
An authenticated user follows an opaque, short-lived launch reference from HIS
|
||||||
focused instead of becoming broad ERP replacements.
|
or another specialist system. GovOPlaN re-authorizes the actor, resolves a
|
||||||
|
curated data context server-side, displays source/freshness/version, and renders
|
||||||
|
one reproducible document and report.
|
||||||
|
|
||||||
### Journey 3: Report To Resolution
|
Templates owns definition/version/schema/rendering, Reporting owns source
|
||||||
|
selection/parameters/execution/export, Files owns generated bytes, and
|
||||||
|
connectors own protocol access. URLs do not carry credentials, arbitrary SQL,
|
||||||
|
or trusted raw personal data. Retries are idempotent and generation evidence
|
||||||
|
connects source, snapshot/reference, transformation, definition, parameters,
|
||||||
|
output checksum, actor, and policy.
|
||||||
|
|
||||||
This is the internal operations and municipal issue-reporting journey.
|
### Journey 4: Governed University BI Path
|
||||||
|
|
||||||
1. A person reports an issue.
|
Starting from the Journey 3 source contract, one bounded university dataset is
|
||||||
2. The issue is triaged into helpdesk, facilities, assets, or a case.
|
catalogued, staged by snapshot or watermark, validated, transformed through a
|
||||||
3. Work is assigned, tracked, and escalated.
|
versioned lineage graph, and exposed as a policy-aware analytical data product.
|
||||||
4. Evidence, communication, and status updates are preserved.
|
The result must preserve official-key mappings, organizational and reporting
|
||||||
5. Reports show workload, SLA, recurring problems, and completion.
|
date semantics, quality findings, quarantine/replay, transparent calculation,
|
||||||
|
and reproducible promotion between development, test, and production.
|
||||||
|
|
||||||
This journey prevents `helpdesk`, `issue-reporting`, `facilities`, and `assets`
|
Reporting consumes the product. Create `govoplan-datasources` or
|
||||||
from becoming disconnected ticket silos.
|
`govoplan-dataflow` only after the concrete path proves repeated ownership that
|
||||||
|
does not belong to connectors, Reporting, or the producing domain module.
|
||||||
|
|
||||||
## Roadmap Waves
|
### Journey 5: Collaborative Document Lifecycle
|
||||||
|
|
||||||
|
An uploaded or generated artifact becomes a DMS document. Files continues to
|
||||||
|
own bytes; DMS owns identity, versions, renditions, editing sessions, locks,
|
||||||
|
comments, review, approval, comparison, and recovery; a collaboration connector
|
||||||
|
owns provider-specific protocol behavior; Records owns later classification,
|
||||||
|
hold, archive, and disposal.
|
||||||
|
|
||||||
|
The gate requires no silent lost updates, short-lived and currently authorized
|
||||||
|
editing sessions, idempotent authenticated callbacks, visible uncertain saves,
|
||||||
|
immutable accepted renditions, and a Records-ready handoff with stable content
|
||||||
|
and provenance.
|
||||||
|
|
||||||
|
## Capability Dependency Waves
|
||||||
|
|
||||||
|
The waves below remain a dependency and ownership catalogue for the wider
|
||||||
|
product vision. They are not the active delivery order. The five selected
|
||||||
|
journeys above and the meta roadmap decide what is implemented now; other
|
||||||
|
clusters remain dormant until a selected journey consumes them or they are
|
||||||
|
explicitly reprioritized.
|
||||||
|
|
||||||
### Wave 0: Platform Spine
|
### Wave 0: Platform Spine
|
||||||
|
|
||||||
@@ -248,9 +289,13 @@ Refine:
|
|||||||
|
|
||||||
- `govoplan-core`: module discovery, capabilities, events, migrations, release
|
- `govoplan-core`: module discovery, capabilities, events, migrations, release
|
||||||
catalog, configuration package runtime, WebUI shell.
|
catalog, configuration package runtime, WebUI shell.
|
||||||
- `govoplan-access`: identities, sessions, API keys, users, groups, roles,
|
- `govoplan-identity`: canonical identities and account links.
|
||||||
memberships, function assignments, delegation, RBAC decisions.
|
- `govoplan-organizations`: organizational structures, units, and functions.
|
||||||
- `govoplan-tenancy`: tenant and organizational-unit boundaries.
|
- `govoplan-idm`: identity-to-function assignments, directory synchronization,
|
||||||
|
preview, conflicts, and reconciliation.
|
||||||
|
- `govoplan-access`: sessions, API keys, users, groups, roles, memberships,
|
||||||
|
function-to-role projection, delegation, acting context, and RBAC decisions.
|
||||||
|
- `govoplan-tenancy`: tenant lifecycle and tenant boundaries.
|
||||||
- `govoplan-identity-trust`: initial trust contracts for device keys, public key
|
- `govoplan-identity-trust`: initial trust contracts for device keys, public key
|
||||||
directory, assurance, and later encrypted postbox key access.
|
directory, assurance, and later encrypted postbox key access.
|
||||||
- `govoplan-policy`: policy sources, policy decisions, retention inputs.
|
- `govoplan-policy`: policy sources, policy decisions, retention inputs.
|
||||||
@@ -367,7 +412,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.
|
||||||
@@ -530,17 +575,30 @@ Before a module becomes release-included, it needs:
|
|||||||
- smoke test or permutation test
|
- smoke test or permutation test
|
||||||
- no required imports from optional modules
|
- no required imports from optional modules
|
||||||
|
|
||||||
## Priority Order Summary
|
## Technical Dependency Order Summary
|
||||||
|
|
||||||
1. Stabilize the platform spine.
|
Use this active order while respecting the ownership and implementation gates
|
||||||
2. Deliver permit-to-payment MVP.
|
in the capability waves:
|
||||||
3. Build booking and resource operations.
|
|
||||||
4. Add learning and certificates.
|
1. Keep the platform/release spine green and extend connector, identity,
|
||||||
5. Add issue reporting and helpdesk.
|
external-effect, provenance, documentation, focused-view, recovery, and
|
||||||
6. Add records, DMS, search, and transparency.
|
version contracts only as the current journey requires.
|
||||||
7. Add procurement, contracts, grants, and finance handoff.
|
2. Complete and package Campaign with Mail-owned profiles, Files, target
|
||||||
8. Add committee and consultation workflows.
|
delivery/recovery, central UI, and adaptive documentation.
|
||||||
9. Expand integration, dataflow, reporting, and operations.
|
3. Implement function-bound Postbox delivery through
|
||||||
|
Organizations–Identity–IDM–Access and consume it from Campaign through a
|
||||||
|
typed capability.
|
||||||
|
4. Implement one data-backed Templates/Reporting path and safe HIS-style deep
|
||||||
|
launch.
|
||||||
|
5. Extend that concrete source into one governed university analytical data
|
||||||
|
product before generalizing data-source or dataflow ownership.
|
||||||
|
6. Implement Files-backed DMS versions and one provider-neutral collaborative
|
||||||
|
editing lifecycle, then connect Records handoff.
|
||||||
|
7. Maintain already integrated Calendar/Scheduling/Poll and other foundations;
|
||||||
|
activate another capability cluster only when the current journey needs it
|
||||||
|
or the product roadmap explicitly reprioritizes it.
|
||||||
|
8. Resume Workflow only by explicit product decision and constrain it with
|
||||||
|
stable actions from one demonstrated package.
|
||||||
|
|
||||||
## Deliberate Deferrals
|
## Deliberate Deferrals
|
||||||
|
|
||||||
@@ -548,7 +606,8 @@ Defer these until a reference journey proves the need:
|
|||||||
|
|
||||||
- full ERP replacement
|
- full ERP replacement
|
||||||
- native project management beyond connector support
|
- native project management beyond connector support
|
||||||
- broad BI/dataflow platform
|
- an unbounded general-purpose dataflow platform; the bounded governed BI
|
||||||
|
reference journey is selected
|
||||||
- every possible public-sector protocol adapter
|
- every possible public-sector protocol adapter
|
||||||
- rich LMS behavior beyond training administration
|
- rich LMS behavior beyond training administration
|
||||||
- full qualified digital signing/trust services beyond the identity-trust and
|
- full qualified digital signing/trust services beyond the identity-trust and
|
||||||
@@ -566,48 +625,48 @@ 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 | first `govoplan-workflow`; possible future `govoplan-automation` | `GovOPlaN/govoplan-workflow#1` |
|
||||||
| E2EE role/function postbox architecture | `govoplan-postbox`, `govoplan-identity-trust`, `govoplan-access`, `govoplan-policy`, `govoplan-audit` | `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/catalog | `govoplan-portal` | `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 decision | connector-first through `govoplan-connectors`; no native project module yet | `GovOPlaN/govoplan-core#196`, `GovOPlaN/govoplan-connectors#1` |
|
||||||
| Datasources for databases, CSV, files, APIs | no repository yet; start with connectors/files/reporting and create `govoplan-datasources` only after the first package proves shared source-catalog ownership | `add-ideas/govoplan-core#197` |
|
| Datasources for databases, CSV, files, APIs | no repository yet; start with connectors/files/reporting and create `govoplan-datasources` only after the first package proves shared source-catalog ownership | `GovOPlaN/govoplan-core#197` |
|
||||||
| Dataflow for pipelines, BI, publication | no repository yet; start with workflow/reporting/connectors and create `govoplan-dataflow` only after repeated pipeline/lineage contracts emerge | `add-ideas/govoplan-core#198` |
|
| Dataflow for pipelines, BI, publication | no repository yet; start with workflow/reporting/connectors and create `govoplan-dataflow` only after repeated pipeline/lineage contracts emerge | `GovOPlaN/govoplan-core#198` |
|
||||||
| Monthly datasource and transformation workflows | first as configuration package across connectors, files, workflow, reporting, and templates | `add-ideas/govoplan-core#216` |
|
| Monthly datasource and transformation workflows | first as configuration package across connectors, files, workflow, reporting, and templates | `GovOPlaN/govoplan-core#216` |
|
||||||
| Templates for letters, emails, forms, reports | `govoplan-templates`, separate from reporting | `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 module concept | `govoplan-workflow` | `GovOPlaN/govoplan-core#175` |
|
||||||
| 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 | `govoplan-connectors` plus possible future `govoplan-dataflow` | `GovOPlaN/govoplan-connectors#3`, `GovOPlaN/govoplan-core#198` |
|
||||||
| Governed connector configuration, dry-run, and simulation runtime | `govoplan-connectors` | `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:
|
||||||
|
|
||||||
@@ -645,19 +704,24 @@ Release composition and tag-only repository handling are documented in
|
|||||||
|
|
||||||
## Next Practical Work
|
## Next Practical Work
|
||||||
|
|
||||||
The next planning step should create or update Gitea issues for Wave 0 and Wave
|
The active cross-product story is
|
||||||
1 only. Later waves should stay as roadmap context until the permit-to-payment
|
[`GovOPlaN/govoplan#14`](https://git.add-ideas.de/GovOPlaN/govoplan/issues/14).
|
||||||
MVP is demonstrable.
|
Module repositories own implementation issues; do not clone their state here.
|
||||||
|
|
||||||
Recommended immediate issue buckets:
|
Immediate issue buckets:
|
||||||
|
|
||||||
- platform spine hardening
|
- fail-closed connector destination pinning and private-network deployment
|
||||||
- configuration package preflight and rollback
|
control for every real transport
|
||||||
- forms-runtime MVP
|
- Mail-profile-only Campaign authoring/build/delivery and safe legacy failure
|
||||||
- portal submission MVP
|
- immediate audited secret deletion when a provider/profile is removed
|
||||||
- cases/workflow/tasks integration MVP
|
- Campaign central-component and role-safe UI acceptance
|
||||||
- template-generated decision document
|
- adaptive Campaign, Mail, and Files task/process/admin/operator/integration/
|
||||||
- postbox/notification handoff
|
security/acceptance documentation
|
||||||
- appointment/booking handoff
|
- target SMTP/IMAP, file-provider, queue/reconciliation, install/upgrade, and
|
||||||
- payment evidence handoff
|
restore proof for the pinned Campaign reference composition
|
||||||
- configured documentation for the reference process
|
|
||||||
|
Once that gate is demonstrable, activate the existing Postbox model, access,
|
||||||
|
API, inbox, and Campaign integration issues. Templates/Reporting, governed BI,
|
||||||
|
and DMS collaboration remain durable selected direction, but should be
|
||||||
|
decomposed only as the preceding stage stabilizes or a bounded independent
|
||||||
|
contract can be implemented without pre-deciding target-system choices.
|
||||||
|
|||||||
@@ -74,6 +74,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,7 +91,8 @@ 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
|
||||||
@@ -96,6 +101,15 @@ The following contracts are the baseline API that modules can rely on:
|
|||||||
|
|
||||||
Changes to these contracts must be versioned or accompanied by compatibility shims.
|
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
|
||||||
@@ -126,6 +140,15 @@ Feature modules should prefer these capabilities over direct reads of
|
|||||||
access/tenant ORM models when they need labels, group membership, default
|
access/tenant ORM models when they need labels, group membership, default
|
||||||
access provisioning, counts, audit actor labels, or tenant metadata.
|
access provisioning, counts, audit actor labels, or tenant metadata.
|
||||||
|
|
||||||
|
Other stable runtime capabilities currently include:
|
||||||
|
|
||||||
|
- `identity.directory` and `identity.search`
|
||||||
|
- `organizations.directory`
|
||||||
|
- `idm.directory`
|
||||||
|
- `calendar.outbox` and `calendar.scheduling`
|
||||||
|
- `poll.scheduling`
|
||||||
|
- `notifications.dispatch`
|
||||||
|
|
||||||
### Named Interface Contracts
|
### Named Interface Contracts
|
||||||
|
|
||||||
Capabilities are runtime objects. Named interface contracts are compatibility
|
Capabilities are runtime objects. Named interface contracts are compatibility
|
||||||
@@ -147,15 +170,25 @@ intended for SemVer major-version lines. Missing optional interfaces are
|
|||||||
allowed, but an installed provider with an incompatible version blocks
|
allowed, but an installed provider with an incompatible version blocks
|
||||||
activation because the integration would otherwise bind to an unsafe API.
|
activation because the integration would otherwise bind to an unsafe API.
|
||||||
|
|
||||||
Current named interfaces:
|
Current named interfaces, generated from the source manifests by the workspace
|
||||||
|
contract checks, are:
|
||||||
|
|
||||||
- `files.campaign_attachments`
|
- `addresses.contact_writer`, `addresses.lookup`, `addresses.recipient_source`
|
||||||
|
- `calendar.outbox`, `calendar.scheduling`
|
||||||
|
- `campaigns.access`, `campaigns.delivery_tasks`,
|
||||||
|
`campaigns.mail_policy_context`, `campaigns.policy_context`,
|
||||||
|
`campaigns.retention`
|
||||||
|
- `dist_lists.expand`, `dist_lists.source`, `dist_lists.writer`
|
||||||
|
- `evaluation.feedback`, `evaluation.result_aggregation`, `evaluation.scoring`
|
||||||
|
- `files.access`, `files.campaign_attachments`
|
||||||
- `mail.campaign_delivery`
|
- `mail.campaign_delivery`
|
||||||
- `campaigns.access`
|
- `notifications.dispatch`
|
||||||
- `campaigns.delivery_tasks`
|
- `poll.availability_matrix`, `poll.option_selection`,
|
||||||
- `campaigns.mail_policy_context`
|
`poll.response_collection`, `poll.signed_participation`,
|
||||||
- `campaigns.policy_context`
|
`poll.workflow_context`
|
||||||
- `campaigns.retention`
|
- `rest.function_publication`
|
||||||
|
- `scheduling.candidate_slots`, `scheduling.decision_handoff`
|
||||||
|
- `soap.operation_publication`
|
||||||
|
|
||||||
Core validates named interface contracts in three places:
|
Core validates named interface contracts in three places:
|
||||||
|
|
||||||
@@ -244,6 +277,23 @@ unsafe methods.
|
|||||||
This avoids retransmitting unchanged snapshots. It does not identify which row
|
This avoids retransmitting unchanged snapshots. It does not identify which row
|
||||||
changed inside a collection.
|
changed inside a collection.
|
||||||
|
|
||||||
|
### Mutation Preconditions
|
||||||
|
|
||||||
|
Weak response ETags are cache validators only. Mutable aggregates expose a
|
||||||
|
separate positive, monotonic revision and an opaque strong ETag generated by
|
||||||
|
`govoplan_core.core.concurrency.strong_resource_etag`. HTTP mutations send that
|
||||||
|
strong ETag in `If-Match`; capability and worker calls carry the equivalent
|
||||||
|
typed `expected_revision`.
|
||||||
|
|
||||||
|
Core's compare-and-set primitive advances the revision in the same transaction
|
||||||
|
as the domain mutation. A missing HTTP precondition is `428 Precondition
|
||||||
|
Required`, a stale HTTP precondition is `412 Precondition Failed`, and a
|
||||||
|
domain/reconciliation conflict is `409 Conflict`. Conflict responses contain
|
||||||
|
bounded resource and revision metadata rather than the complete current
|
||||||
|
object. Modules may opt into the conservative three-way merge helper, but must
|
||||||
|
declare protected workflow, delivery, ownership, lock, evidence, signature,
|
||||||
|
and cryptographic paths that can never be merged automatically.
|
||||||
|
|
||||||
### Delta Collections
|
### Delta Collections
|
||||||
|
|
||||||
Collection endpoints that can expose row-level changes should use the shared
|
Collection endpoints that can expose row-level changes should use the shared
|
||||||
@@ -314,6 +364,31 @@ full snapshot with `full: true`. A first-use `seq:0` watermark remains valid
|
|||||||
until such a floor exists, even if unrelated collections have advanced the
|
until such a floor exists, even if unrelated collections have advanced the
|
||||||
global sequence.
|
global sequence.
|
||||||
|
|
||||||
|
### Bounded Reference Selectors
|
||||||
|
|
||||||
|
Cross-module selectors use the module-neutral contract in
|
||||||
|
`govoplan_core.core.references`; consumers must not load an optional module's
|
||||||
|
complete directory and filter it in memory.
|
||||||
|
|
||||||
|
- Providers receive a normalized `ReferenceSearchRequest` with `kind`,
|
||||||
|
`tenant_id`, `query`, `selected_values`, `limit`, optional opaque `cursor`,
|
||||||
|
and policy context.
|
||||||
|
- Providers apply visibility and text filtering before materializing rows and
|
||||||
|
return `ReferenceSearchPage(options, next_cursor, has_more)`.
|
||||||
|
- A page contains at most the requested bounded search results. Already-selected
|
||||||
|
references are retained in addition to that bound so historical values remain
|
||||||
|
readable and removable even when they are inactive, deleted, or outside the
|
||||||
|
current search page.
|
||||||
|
- API consumers expose `next_cursor` and `has_more`. The current searchable
|
||||||
|
selector requests the first bounded page for each query; later load-more UI
|
||||||
|
can use the same cursor without changing the provider contract.
|
||||||
|
- `access.reference_options` supplies SQL-backed account, membership, and group
|
||||||
|
searches. When it is absent, Core degrades to the legacy Access directory or
|
||||||
|
to principal-only/unavailable references without importing Access.
|
||||||
|
- The shared WebUI `apiReferenceOptionProvider` resolves selected values in
|
||||||
|
chunks of at most 200, preventing a large existing selection from turning
|
||||||
|
into an unbounded request.
|
||||||
|
|
||||||
### Cursor/Keyset Pages
|
### Cursor/Keyset Pages
|
||||||
|
|
||||||
Offset pagination remains supported for compatibility and for first page loads,
|
Offset pagination remains supported for compatibility and for first page loads,
|
||||||
@@ -434,6 +509,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
|
||||||
@@ -554,11 +641,18 @@ Uninstall remains non-destructive unless the operator explicitly requests
|
|||||||
|
|
||||||
## WebUI Contract
|
## WebUI Contract
|
||||||
|
|
||||||
A WebUI module exports a `PlatformWebModule` from its package. The object contributes local/fallback metadata and route render functions.
|
A WebUI module exports a `PlatformWebModule` from its package. The object
|
||||||
|
contributes local/fallback metadata and route render functions. The package
|
||||||
|
must ship `src/module.ts` with the default contribution export: Core's Vite
|
||||||
|
host imports that descriptor directly after the backend reports the module as
|
||||||
|
enabled. This keeps package-root re-exports from pulling page implementations
|
||||||
|
into the initial shell.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
const FilesPage = lazy(() => import("./features/files/FilesPage"));
|
||||||
|
|
||||||
export const filesModule: PlatformWebModule = {
|
export const filesModule: PlatformWebModule = {
|
||||||
id: "files",
|
id: "files",
|
||||||
label: "Files",
|
label: "Files",
|
||||||
@@ -573,6 +667,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`
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ consistent while each module still owns its domain rules.
|
|||||||
| RBAC/access policy | `govoplan-access` | access capabilities in `govoplan_core.core.access` | Permission decisions should use access capability contracts. Explain responses should adopt `PolicyDecision` when an API-level explanation is added. |
|
| RBAC/access policy | `govoplan-access` | access capabilities in `govoplan_core.core.access` | Permission decisions should use access capability contracts. Explain responses should adopt `PolicyDecision` when an API-level explanation is added. |
|
||||||
| Governance defaults | `govoplan-admin` plus `govoplan-access` materializer | admin settings, governance template routes, access materialization capability | System governance can block tenant-local groups, roles, and API keys. |
|
| Governance defaults | `govoplan-admin` plus `govoplan-access` materializer | admin settings, governance template routes, access materialization capability | System governance can block tenant-local groups, roles, and API keys. |
|
||||||
| Delegation and ownership policy | access/campaign/mail/files modules | capability checks and owner-scoped APIs | Source provenance should use this contract when policies become externally explainable. |
|
| Delegation and ownership policy | access/campaign/mail/files modules | capability checks and owner-scoped APIs | Source provenance should use this contract when policies become externally explainable. |
|
||||||
|
| Definition governance | `govoplan-policy` | capability `policy.definitionGovernance` | Resolves view, edit, run/start, reuse, derive, and automate for system, tenant, group, and user Dataflow/Workflow definitions. |
|
||||||
|
|
||||||
## Policy Decision
|
## Policy Decision
|
||||||
|
|
||||||
@@ -97,6 +98,34 @@ New backend code should import policy-owned retention behavior from
|
|||||||
`govoplan-policy` or request the capability, not add new implementation logic
|
`govoplan-policy` or request the capability, not add new implementation logic
|
||||||
to core.
|
to core.
|
||||||
|
|
||||||
|
The retention API DTOs live in `govoplan_core.privacy.schemas`.
|
||||||
|
`PrivacyRetentionPolicyItem`, `PrivacyRetentionPolicyPatchItem`,
|
||||||
|
`RETENTION_POLICY_FIELD_KEYS`, and `default_allow_lower_level_limits()` are
|
||||||
|
platform contracts because admin, access compatibility, and policy routes expose
|
||||||
|
the same stable retention payload shape. The policy engine's internal
|
||||||
|
`PrivacyRetentionPolicy` and `PrivacyRetentionPolicyPatch` models stay in
|
||||||
|
`govoplan-policy`, because they carry implementation validators and merge
|
||||||
|
behavior that are not generic API contracts.
|
||||||
|
|
||||||
|
Tenant administration DTOs remain owned by `govoplan-tenancy`; access keeps
|
||||||
|
matching compatibility DTOs only for its legacy admin surface. Admin overview
|
||||||
|
responses remain module-local because the same counters are exposed from
|
||||||
|
different menu contexts and are not yet a separately versioned platform API.
|
||||||
|
|
||||||
|
## Definition Governance
|
||||||
|
|
||||||
|
Dataflow and Workflow submit a `DefinitionGovernanceRequest` using only stable
|
||||||
|
scope, principal, status, definition-kind, and limit fields. Policy returns a
|
||||||
|
standard `PolicyDecision`. System definitions may be inherited as read-only;
|
||||||
|
group and user definitions are visible only in matching contexts. Templates
|
||||||
|
may be viewed and derived but never run or automated. A derived definition
|
||||||
|
passes its pinned ancestor limits back through the request context, and Policy
|
||||||
|
applies those limits as ceilings rather than defaults that can be broadened.
|
||||||
|
|
||||||
|
When the capability is absent, modules must not silently emulate cross-scope
|
||||||
|
inheritance. Their conservative fallback is limited to local tenant
|
||||||
|
definitions and disables reuse, derivation, and automation.
|
||||||
|
|
||||||
## Frontend Contract
|
## Frontend Contract
|
||||||
|
|
||||||
Policy UIs must:
|
Policy UIs must:
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ The trust layer should provide:
|
|||||||
- key rotation and epoch tracking
|
- key rotation and epoch tracking
|
||||||
- recovery policy hooks
|
- recovery policy hooks
|
||||||
|
|
||||||
|
Recovery must be organizationally governed. A server-held universal plaintext
|
||||||
|
key would defeat the E2EE claim; any escrow, threshold recovery, or emergency
|
||||||
|
grant needs an explicit assurance profile, authority/quorum, audit trail, and
|
||||||
|
user-visible consequence.
|
||||||
|
|
||||||
## Role And Function Postboxes
|
## Role And Function Postboxes
|
||||||
|
|
||||||
Role-bound access needs special handling. A postbox can be bound to an
|
Role-bound access needs special handling. A postbox can be bound to an
|
||||||
@@ -75,6 +80,30 @@ rewrapping service:
|
|||||||
Key epochs are required when role membership changes. Older messages may remain
|
Key epochs are required when role membership changes. Older messages may remain
|
||||||
readable according to policy, but new access must use the current epoch.
|
readable according to policy, but new access must use the current epoch.
|
||||||
|
|
||||||
|
The function-bound container exists independently of membership. It may remain
|
||||||
|
vacant and continue to receive ciphertext without falling back to an unrelated
|
||||||
|
personal mailbox. Zero, one, or several incumbents are valid states. Each
|
||||||
|
incumbent receives an independently auditable, device-bound wrapped-key path;
|
||||||
|
the postbox is never copied into their account ownership.
|
||||||
|
|
||||||
|
A new assignment or hand-over rotates the function/postbox key epoch. Envelope
|
||||||
|
encryption permits the normal rotation path to rewrap per-message data keys
|
||||||
|
rather than rewrite large ciphertext objects; a security policy may require
|
||||||
|
full content re-encryption for selected compromise or cryptographic-profile
|
||||||
|
events. The history available to a new incumbent must be selected policy (all
|
||||||
|
retained history, a bounded historical window, or assignment-time content) and
|
||||||
|
recorded with the grant.
|
||||||
|
|
||||||
|
Delegation is a time-bounded represented-function grant, not a copy or
|
||||||
|
substitution of the postbox. Expiry or withdrawal stops future key release and
|
||||||
|
actions. It cannot revoke plaintext already decrypted, printed, exported, or
|
||||||
|
captured outside the platform. Multiple simultaneous incumbents and delegates
|
||||||
|
remain distinguishable in key-fetch and action evidence.
|
||||||
|
|
||||||
|
Postbox content and signed manifests are immutable. Correction or replacement
|
||||||
|
creates a linked new object/version; it never silently substitutes ciphertext
|
||||||
|
or evidence that another actor may already have inspected.
|
||||||
|
|
||||||
## External Recipients
|
## External Recipients
|
||||||
|
|
||||||
External recipients may need one-time or time-limited access without a full
|
External recipients may need one-time or time-limited access without a full
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ before deciding to replace specialist workflows. This document is the core
|
|||||||
strategy index. The executable connector catalogue lives in
|
strategy index. The executable connector catalogue lives in
|
||||||
`govoplan-connectors/docs/PUBLIC_SECTOR_INTEGRATION_CATALOGUE.md`.
|
`govoplan-connectors/docs/PUBLIC_SECTOR_INTEGRATION_CATALOGUE.md`.
|
||||||
|
|
||||||
|
The canonical cumulative maturity model and external-object DTO are documented
|
||||||
|
in [EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md](EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md).
|
||||||
|
|
||||||
## Strategy Labels
|
## Strategy Labels
|
||||||
|
|
||||||
Use one or more of these labels for every external system family:
|
Use one or more of these labels for every external system family:
|
||||||
|
|||||||
@@ -40,17 +40,26 @@ cd /mnt/DATA/git/govoplan
|
|||||||
Update those refs when cutting a release:
|
Update those refs when cutting a release:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
govoplan-access git@git.add-ideas.de:add-ideas/govoplan-access.git v0.1.6
|
govoplan-tenancy git@git.add-ideas.de:GovOPlaN/govoplan-tenancy.git v0.1.8
|
||||||
govoplan-admin git@git.add-ideas.de:add-ideas/govoplan-admin.git v0.1.6
|
govoplan-organizations git@git.add-ideas.de:GovOPlaN/govoplan-organizations.git v0.1.8
|
||||||
govoplan-tenancy git@git.add-ideas.de:add-ideas/govoplan-tenancy.git v0.1.6
|
govoplan-identity git@git.add-ideas.de:GovOPlaN/govoplan-identity.git v0.1.8
|
||||||
govoplan-organizations git@git.add-ideas.de:add-ideas/govoplan-organizations.git v0.1.6
|
govoplan-idm git@git.add-ideas.de:GovOPlaN/govoplan-idm.git v0.1.8
|
||||||
govoplan-identity git@git.add-ideas.de:add-ideas/govoplan-identity.git v0.1.6
|
govoplan-access git@git.add-ideas.de:GovOPlaN/govoplan-access.git v0.1.8
|
||||||
govoplan-policy git@git.add-ideas.de:add-ideas/govoplan-policy.git v0.1.6
|
govoplan-admin git@git.add-ideas.de:GovOPlaN/govoplan-admin.git v0.1.8
|
||||||
govoplan-audit git@git.add-ideas.de:add-ideas/govoplan-audit.git v0.1.6
|
govoplan-policy git@git.add-ideas.de:GovOPlaN/govoplan-policy.git v0.1.8
|
||||||
govoplan-files git@git.add-ideas.de:add-ideas/govoplan-files.git v0.1.6
|
govoplan-audit git@git.add-ideas.de:GovOPlaN/govoplan-audit.git v0.1.8
|
||||||
govoplan-mail git@git.add-ideas.de:add-ideas/govoplan-mail.git v0.1.6
|
govoplan-dashboard git@git.add-ideas.de:GovOPlaN/govoplan-dashboard.git v0.1.8
|
||||||
govoplan-campaign git@git.add-ideas.de:add-ideas/govoplan-campaign.git v0.1.6
|
govoplan-addresses git@git.add-ideas.de:GovOPlaN/govoplan-addresses.git v0.1.8
|
||||||
govoplan-calendar git@git.add-ideas.de:add-ideas/govoplan-calendar.git v0.1.6
|
govoplan-files git@git.add-ideas.de:GovOPlaN/govoplan-files.git v0.1.8
|
||||||
|
govoplan-mail git@git.add-ideas.de:GovOPlaN/govoplan-mail.git v0.1.8
|
||||||
|
govoplan-campaign git@git.add-ideas.de:GovOPlaN/govoplan-campaign.git v0.1.8
|
||||||
|
govoplan-calendar git@git.add-ideas.de:GovOPlaN/govoplan-calendar.git v0.1.8
|
||||||
|
govoplan-poll git@git.add-ideas.de:GovOPlaN/govoplan-poll.git v0.1.8
|
||||||
|
govoplan-scheduling git@git.add-ideas.de:GovOPlaN/govoplan-scheduling.git v0.1.8
|
||||||
|
govoplan-notifications git@git.add-ideas.de:GovOPlaN/govoplan-notifications.git v0.1.8
|
||||||
|
govoplan-evaluation git@git.add-ideas.de:GovOPlaN/govoplan-evaluation.git v0.1.8
|
||||||
|
govoplan-docs git@git.add-ideas.de:GovOPlaN/govoplan-docs.git v0.1.8
|
||||||
|
govoplan-ops git@git.add-ideas.de:GovOPlaN/govoplan-ops.git v0.1.8
|
||||||
```
|
```
|
||||||
|
|
||||||
## WebUI Packages
|
## WebUI Packages
|
||||||
@@ -70,18 +79,18 @@ cd /mnt/DATA/git/govoplan-core/webui
|
|||||||
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run build
|
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
The module repositories include root-level npm package manifests so git
|
Module repositories with a frontend include root-level npm package manifests
|
||||||
installs can resolve `@govoplan/access-webui`, `@govoplan/admin-webui`,
|
so the `@govoplan/*-webui` dependencies in `webui/package.release.json` can be
|
||||||
`@govoplan/files-webui`, `@govoplan/mail-webui`,
|
resolved from repository roots even though their source lives below
|
||||||
`@govoplan/campaign-webui`, and `@govoplan/calendar-webui` from repository
|
`webui/src`.
|
||||||
roots even though their source lives below `webui/src`.
|
|
||||||
|
|
||||||
### Release Lockfile Strategy
|
### Release Lockfile Strategy
|
||||||
|
|
||||||
The supported release composition currently is the full GovOPlaN product: core
|
The supported backend release composition is the set pinned in the meta
|
||||||
plus access, admin, tenancy, organizations, identity, policy, audit,
|
repository's `requirements-release.txt`. The supported frontend composition
|
||||||
dashboard, files, mail, campaign, calendar, docs, and ops. Keep one committed
|
is the independently buildable module set pinned in
|
||||||
full-product release lockfile at
|
`webui/package.release.json`; backend-only modules do not need a frontend
|
||||||
|
package entry. Keep one committed full-product release lockfile at
|
||||||
`webui/package-lock.release.json`, generated from
|
`webui/package-lock.release.json`, generated from
|
||||||
`webui/package.release.json` in a clean release workspace. Development
|
`webui/package.release.json` in a clean release workspace. Development
|
||||||
`package-lock.json` may continue to point at local `file:` dependencies.
|
`package-lock.json` may continue to point at local `file:` dependencies.
|
||||||
@@ -104,7 +113,7 @@ working tree has already been bumped, pass the current version explicitly:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan
|
cd /mnt/DATA/git/govoplan
|
||||||
tools/release/push-release-tag.sh --version 0.1.6
|
tools/release/push-release-tag.sh --version 0.1.8
|
||||||
```
|
```
|
||||||
|
|
||||||
`/mnt/DATA/git/govoplan/tools/release/generate-release-catalog.py` reads installed/discovered
|
`/mnt/DATA/git/govoplan/tools/release/generate-release-catalog.py` reads installed/discovered
|
||||||
@@ -126,7 +135,6 @@ are not listed in `requirements-release.txt` or `webui/package.release.json`.
|
|||||||
|
|
||||||
Current tag-only module repositories:
|
Current tag-only module repositories:
|
||||||
|
|
||||||
- `govoplan-addresses`
|
|
||||||
- `govoplan-appointments`
|
- `govoplan-appointments`
|
||||||
- `govoplan-cases`
|
- `govoplan-cases`
|
||||||
- `govoplan-connectors`
|
- `govoplan-connectors`
|
||||||
@@ -135,13 +143,11 @@ Current tag-only module repositories:
|
|||||||
- `govoplan-fit-connect`
|
- `govoplan-fit-connect`
|
||||||
- `govoplan-forms`
|
- `govoplan-forms`
|
||||||
- `govoplan-identity-trust`
|
- `govoplan-identity-trust`
|
||||||
- `govoplan-idm`
|
|
||||||
- `govoplan-ledger`
|
- `govoplan-ledger`
|
||||||
- `govoplan-notifications`
|
|
||||||
- `govoplan-payments`
|
- `govoplan-payments`
|
||||||
- `govoplan-portal`
|
- `govoplan-portal`
|
||||||
|
- `govoplan-postbox`
|
||||||
- `govoplan-reporting`
|
- `govoplan-reporting`
|
||||||
- `govoplan-scheduling`
|
|
||||||
- `govoplan-search`
|
- `govoplan-search`
|
||||||
- `govoplan-tasks`
|
- `govoplan-tasks`
|
||||||
- `govoplan-templates`
|
- `govoplan-templates`
|
||||||
@@ -782,10 +788,27 @@ tools/checks/postgres-integration-check.py \
|
|||||||
The script checks migrations and `/health` startup for core-only, files-only,
|
The script checks migrations and `/health` startup for core-only, files-only,
|
||||||
mail-only, campaign-only, campaign+files, campaign+mail, and full-product
|
mail-only, campaign-only, campaign+files, campaign+mail, and full-product
|
||||||
module sets. `--reset-schema` is destructive and must only be used against a
|
module sets. `--reset-schema` is destructive and must only be used against a
|
||||||
throwaway database.
|
throwaway database. Before those permutations, the required Core proof runs in
|
||||||
|
random, test-owned schemas without modifying `public`. It exercises Files' real
|
||||||
|
credential-owning retirement provider and proves that credential scrubbing,
|
||||||
|
non-secret audit
|
||||||
|
insertion, and table retirement commit together; database-injected audit and
|
||||||
|
DDL failures roll the entire unit back. A 500 ms PostgreSQL `lock_timeout` and
|
||||||
|
captured backend process IDs also prove that each `DROP TABLE` uses the
|
||||||
|
installer Session connection instead of waiting through a second connection.
|
||||||
|
The meta check enables the release-gate flag so missing PostgreSQL configuration
|
||||||
|
or full-stack test packages are a hard failure; ordinary Core-only test discovery
|
||||||
|
skips this integration proof. Do not pass `--skip-retirement-atomicity` when
|
||||||
|
collecting release evidence.
|
||||||
|
|
||||||
## Migration Baselines
|
## Migration Baselines
|
||||||
|
|
||||||
|
Release migration support follows `COMPATIBILITY_POLICY.md`: every tagged
|
||||||
|
`0.1.x` installation is a supported upgrade origin, released revision IDs are
|
||||||
|
immutable, and migration-only reconciliation remains available for at least one
|
||||||
|
subsequent major release cycle after the matching runtime compatibility path is
|
||||||
|
removed.
|
||||||
|
|
||||||
Development migrations may be small and numerous while a feature is moving.
|
Development migrations may be small and numerous while a feature is moving.
|
||||||
GovOPlaN keeps those detailed migrations on an explicit development track and
|
GovOPlaN keeps those detailed migrations on an explicit development track and
|
||||||
publishes reviewed release shortcuts on the release track. Before a stable
|
publishes reviewed release shortcuts on the release track. Before a stable
|
||||||
@@ -875,7 +898,7 @@ before that baseline, so pre-v0.1.7 development revisions are not release
|
|||||||
upgrade targets. Future release-to-release changes must start from a recorded
|
upgrade targets. Future release-to-release changes must start from a recorded
|
||||||
release baseline and add a new release-track step-up instead of replacing prior
|
release baseline and add a new release-track step-up instead of replacing prior
|
||||||
release shortcuts. The tracking issue is
|
release shortcuts. The tracking issue is
|
||||||
`add-ideas/govoplan-core#223`.
|
`GovOPlaN/govoplan-core#223`.
|
||||||
|
|
||||||
## Related Operator Documents
|
## Related Operator Documents
|
||||||
|
|
||||||
|
|||||||
@@ -40,10 +40,16 @@ set +a
|
|||||||
|
|
||||||
The command reports all known blockers at once. Production-like/self-hosted
|
The command reports all known blockers at once. Production-like/self-hosted
|
||||||
profiles require explicit `APP_ENV`, `DATABASE_URL`, `MASTER_KEY_B64`,
|
profiles require explicit `APP_ENV`, `DATABASE_URL`, `MASTER_KEY_B64`,
|
||||||
`ENABLED_MODULES`, and `CORS_ORIGINS`. Production rejects SQLite, development
|
`ENABLED_MODULES`, `CORS_ORIGINS`, `GOVOPLAN_TRUSTED_HOSTS`, and a deployment-wide decision for
|
||||||
|
`GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS`. Production rejects SQLite, development
|
||||||
bootstrap, insecure auth cookies, and unsigned catalog trust roots when a
|
bootstrap, insecure auth cookies, and unsigned catalog trust roots when a
|
||||||
catalog source is configured.
|
catalog source is configured.
|
||||||
|
|
||||||
|
Connector process-secret names and custom CA files are deployment-owned through
|
||||||
|
the exact, default-empty `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST` and
|
||||||
|
`GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST`; tenant/API configuration cannot widen
|
||||||
|
either boundary.
|
||||||
|
|
||||||
## Production-Like Dev Stack
|
## Production-Like Dev Stack
|
||||||
|
|
||||||
Use the local production-like wrapper for repeatable rehearsal:
|
Use the local production-like wrapper for repeatable rehearsal:
|
||||||
|
|||||||
24
docs/THROTTLING.md
Normal file
24
docs/THROTTLING.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Shared fixed-window throttling
|
||||||
|
|
||||||
|
Core exposes `govoplan_core.core.throttling` for security-sensitive endpoints
|
||||||
|
that need bounded fixed-window counters. Subjects are SHA-256 hashed before they
|
||||||
|
become store keys. A configured Redis instance provides atomic counters shared
|
||||||
|
across API workers. Development and temporary Redis outages use a bounded
|
||||||
|
process-local fallback. Production-like startup rejects an enabled login
|
||||||
|
throttle without `REDIS_URL` unless
|
||||||
|
`GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE=true` explicitly acknowledges the
|
||||||
|
single-process limitation. When Redis fails at runtime, local attempts are still
|
||||||
|
mirrored so losing the distributed store does not reset the active worker's
|
||||||
|
protection window.
|
||||||
|
|
||||||
|
Callers define one or more `ThrottleDimension` values with a controlled
|
||||||
|
namespace, a subject and a positive limit. They must call `check` before an
|
||||||
|
expensive verifier, `record` after a failed attempt, and may `reset` the relevant
|
||||||
|
dimension after successful verification. A blocked decision includes a
|
||||||
|
`retry_after_seconds` value suitable for an HTTP `Retry-After` header.
|
||||||
|
|
||||||
|
The first consumer is Scheduling's anonymous participation password challenge.
|
||||||
|
Its namespace is `poll-participation-password`; its subject combines tenant,
|
||||||
|
scheduling request and Poll's non-secret invitation-token fingerprint. Access's
|
||||||
|
login throttle predates this primitive and should be migrated onto it in a
|
||||||
|
separate compatibility-preserving slice.
|
||||||
@@ -6,7 +6,7 @@ binding design reference: future implementation should follow these decisions
|
|||||||
unless the decision is explicitly revised here and affected screens are updated
|
unless the decision is explicitly revised here and affected screens are updated
|
||||||
to match.
|
to match.
|
||||||
|
|
||||||
Active tracking issue: `add-ideas/govoplan-core#225`.
|
Active tracking issue: `GovOPlaN/govoplan-core#225`.
|
||||||
|
|
||||||
## Operating Rule
|
## Operating Rule
|
||||||
|
|
||||||
@@ -38,6 +38,18 @@ contestability, responsibility, and traceability at the point of action.
|
|||||||
| UX-012 | Automated actions must remain inspectable. The UI must show the system actor, trigger, policy result, observed effects, and failure/manual-intervention state when automation changes administrative state. | Accepted | Workflow, automation, connectors, tasks, audit |
|
| UX-012 | Automated actions must remain inspectable. The UI must show the system actor, trigger, policy result, observed effects, and failure/manual-intervention state when automation changes administrative state. | Accepted | Workflow, automation, connectors, tasks, audit |
|
||||||
| UX-013 | Contestable decisions must expose provenance. Denials, locks, generated outputs, calculated defaults, policy decisions, access decisions, and retention decisions need a reachable source path. | Accepted | Policy, access, templates, workflow, retention, records |
|
| UX-013 | Contestable decisions must expose provenance. Denials, locks, generated outputs, calculated defaults, policy decisions, access decisions, and retention decisions need a reachable source path. | Accepted | Policy, access, templates, workflow, retention, records |
|
||||||
| UX-014 | Retraction, expiry, undo, rollback, and delete controls must state the real limit of the operation. Corrective or future-only actions must not be described as if they undo already observed effects. | Accepted | Postbox, files, records, installer, workflow, payments |
|
| UX-014 | Retraction, expiry, undo, rollback, and delete controls must state the real limit of the operation. Corrective or future-only actions must not be described as if they undo already observed effects. | Accepted | Postbox, files, records, installer, workflow, payments |
|
||||||
|
| UX-015 | Core owns the platform appearance contract. Modules must use shared CSS tokens and shared controls for theme-aware UI; they must not define independent light/dark palette systems. | Accepted | Core shell and all module WebUIs |
|
||||||
|
| UX-016 | Full-page create/edit surfaces keep `Discard` and the named `Save …` action in the upper-right page action cluster, with Save at the far right. Their position remains stable through validation and loading states. | Accepted | All full-page create/edit surfaces |
|
||||||
|
| UX-017 | Table row actions use icon-only controls in a stable rightmost column and intent order: inspect/open, edit, copy, transfer/share/download, retry/restore, destructive action last. Every icon requires a translated accessible name and tooltip. | Accepted | All structured tables |
|
||||||
|
| UX-018 | A collapsible card containing only one table gives the table the card's full available body, without decorative inner wrappers, duplicate padding, max-widths, or nested scrolling. | Accepted | List, detail, workflow, and configuration surfaces |
|
||||||
|
| UX-019 | Focused-view precedence is manual session pin, current-task suggestion, user default, role/tenant default, then the full interface. The active source and a full-interface escape remain visible; a suggested view never changes authorization or implies consent. | Accepted | Shell, modules, future workflow composition |
|
||||||
|
| UX-020 | Centrally exported Core components are mandatory wherever their contract covers the interaction. A custom reusable control, presentation primitive, or module-local substitute requires explicit product-owner authorization, a narrowly specific purpose, and documented rationale and scope; it must not duplicate a central component. | Accepted | Core WebUI and all module WebUIs |
|
||||||
|
| UX-021 | A collection-wide create action belongs in that collection's page heading and is not duplicated in a persistent side panel. When a side panel is the creation surface, it is present for the creation view only. | Accepted | List-detail, directory, and create surfaces |
|
||||||
|
| UX-022 | Use central `Card` components for logical sections, `DataGrid` for tabular row collections and their ordered actions, and `ToggleSwitch` for boolean settings. Repeatable people/contact editors use one structured row per person with name, email address, and actions; free-form address parsing is reserved for an explicitly designed bulk-import flow. | Accepted | All WebUI forms and collection editors |
|
||||||
|
| UX-023 | `FieldLabel` is the standard label/help surface for every field that is not self-explanatory. Any field rendered without it must be recorded in the omission register below, including its accessible-name source and rationale. Users may hide inline help markers through their persisted interface preference; the field label itself remains visible. | Accepted | All Core and module forms |
|
||||||
|
| UX-024 | Explicit `Discard` actions and dirty in-application navigation use the shared `UnsavedChangesProvider` dialog. A page registers save/discard behavior with `useUnsavedDraftGuard`; its Discard button calls `requestDiscard`, and route changes use `useGuardedNavigate` or `requestNavigation`. | Accepted | All create/edit surfaces |
|
||||||
|
| UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code |
|
||||||
|
| UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables |
|
||||||
|
|
||||||
## Confirmed Implementation Decisions
|
## Confirmed Implementation Decisions
|
||||||
|
|
||||||
@@ -138,6 +150,121 @@ adaptive form, not force a linear wizard.
|
|||||||
- Wizard shells remain available for assisted setup, first-run guidance,
|
- Wizard shells remain available for assisted setup, first-run guidance,
|
||||||
imports, discovery-heavy flows, and operational preflight workflows.
|
imports, discovery-heavy flows, and operational preflight workflows.
|
||||||
|
|
||||||
|
### DUE-008: Platform Theme Contract
|
||||||
|
|
||||||
|
Decision: the WebUI shell exposes a small, stable appearance contract based on
|
||||||
|
shared CSS tokens and persisted user preference selection.
|
||||||
|
|
||||||
|
- Core applies `system`, `light`, and `dark` preferences at the document root.
|
||||||
|
- Core owns shared tokens such as `--bg`, `--bar`, `--panel`, `--surface`,
|
||||||
|
`--line`, `--line-dark`, `--text`, `--text-strong`, `--muted`, semantic
|
||||||
|
status colors, radii, shadows, and disabled-control colors.
|
||||||
|
- Modules must style new UI with these tokens and shared controls. Module-local
|
||||||
|
CSS may tune layout and spacing, but it must not introduce a separate
|
||||||
|
appearance system.
|
||||||
|
- Appearance controls live in user settings first. Tenant defaults and policy
|
||||||
|
enforcement can be added later without changing the token contract.
|
||||||
|
- Visual preview in settings is illustrative; it must reflect token families,
|
||||||
|
not become a second theme implementation.
|
||||||
|
|
||||||
|
### DUE-009: Central Component And Exception Contract
|
||||||
|
|
||||||
|
Decision: module interfaces are compositions of the components exported by
|
||||||
|
`@govoplan/core-webui`. When Core already owns the matching interaction, using
|
||||||
|
the central component is required rather than preferred.
|
||||||
|
|
||||||
|
A route or domain-specific page composed from central components is ordinary
|
||||||
|
module composition. A new reusable UI control, presentation primitive, or
|
||||||
|
module-local substitute is a custom component. Before one is implemented, the
|
||||||
|
product owner must explicitly authorize it and the owning decision or issue must
|
||||||
|
record:
|
||||||
|
|
||||||
|
- its single, narrowly defined purpose and intended consumers
|
||||||
|
- why central components or their composition cannot meet that purpose
|
||||||
|
- the permitted scope and the boundary it must not grow beyond
|
||||||
|
- accessibility, reachable states, theme behavior, and test expectations
|
||||||
|
- whether the component remains domain-owned or is a candidate for Core
|
||||||
|
|
||||||
|
Custom components must not duplicate, fork, or cosmetically replace a central
|
||||||
|
component. An existing local implementation does not grant an exception. If a
|
||||||
|
central contract later covers the need, migrate to it unless the product owner
|
||||||
|
explicitly retains the exception.
|
||||||
|
|
||||||
|
### DUE-010: Scheduling Request Reference Composition
|
||||||
|
|
||||||
|
Decision: Scheduling requests provide a concrete reference application of the
|
||||||
|
universal placement and component rules.
|
||||||
|
|
||||||
|
- The persistent left panel stacks `My scheduling requests` and `Scheduling
|
||||||
|
requests for me`; it is list context, not a second creation affordance.
|
||||||
|
- The left panel's `Scheduling requests` header owns one `Add` action. It opens
|
||||||
|
the shared view/create/edit surface in the right main panel.
|
||||||
|
- Basic information, Calendar integration, candidate slots, and participants
|
||||||
|
use the central `Card` component as four logical sections.
|
||||||
|
- Candidate slots and participants use the central `DataGrid`, including its
|
||||||
|
standard row-action placement and order.
|
||||||
|
- Calendar integration uses the central `ToggleSwitch`, with its dependent
|
||||||
|
controls shown when enabled.
|
||||||
|
- Each participant is edited as one structured row with name, email address,
|
||||||
|
and actions. The normal editor does not parse a free-form list of addresses;
|
||||||
|
that interaction requires a separate, explicitly designed bulk-import flow.
|
||||||
|
|
||||||
|
Equivalent list/create/edit surfaces use the same underlying rules. These are
|
||||||
|
not Scheduling-local component variants.
|
||||||
|
|
||||||
|
### DUE-011: Field Help, Discard, And Table Action Contracts
|
||||||
|
|
||||||
|
Decision: the central components own these interactions; modules compose them
|
||||||
|
instead of reproducing their behavior.
|
||||||
|
|
||||||
|
- `FormField` and `ToggleSwitch` already render `FieldLabel`. Direct field
|
||||||
|
compositions use `FieldLabel` explicitly when the meaning or limitation is
|
||||||
|
not self-explanatory.
|
||||||
|
- `help` content is contextual guidance, not the accessible name. The persisted
|
||||||
|
`show_inline_help_hints` user preference hides only the `InlineHelp` marker by
|
||||||
|
applying `ui-hide-help-hints` at the document root.
|
||||||
|
- A dirty editor registers once with `useUnsavedDraftGuard`. An explicit
|
||||||
|
Discard button calls `useUnsavedChanges().requestDiscard(afterResolve)`; SPA
|
||||||
|
navigation uses `useGuardedNavigate` or `requestNavigation`. Both paths show
|
||||||
|
the same shared unsaved-changes dialog. A browser tab/window unload remains a
|
||||||
|
browser-controlled confirmation because browsers do not permit a custom
|
||||||
|
modal at that boundary.
|
||||||
|
- `TableActionGroup` receives the table's stable action set. Use `disabled` and
|
||||||
|
`disabledReason` for row state; omit an action only when that action does not
|
||||||
|
belong to the table. `minimumSlots` reserves trailing positions for an empty
|
||||||
|
row. `DataGridEmptyAction` does this for the standard add/move/remove layout.
|
||||||
|
- A paginated `DataGrid` has exactly one query owner. Client mode receives the
|
||||||
|
complete logical row set and applies filtering and sorting before slicing a
|
||||||
|
page. Server mode receives only the loaded page, requires `onQueryChange`,
|
||||||
|
and the backend applies every emitted filter/sort before pagination while
|
||||||
|
returning `totalRows` for the filtered result. Server list filters declare
|
||||||
|
their complete option domain instead of deriving it from the loaded page.
|
||||||
|
External filter affordances such as summary-count shortcuts update the
|
||||||
|
grid's `query` contract; the grid header controls and backend query therefore
|
||||||
|
always display and execute the same filter state.
|
||||||
|
- Feedback and confirmation use `Dialog`, `ConfirmDialog`, or
|
||||||
|
`DismissibleAlert`. They never fall back to `window.alert`.
|
||||||
|
|
||||||
|
#### FieldLabel Omission Register
|
||||||
|
|
||||||
|
Every Core field surface that intentionally does not render `FieldLabel` is
|
||||||
|
listed here. Module repositories keep an equivalent register in their durable
|
||||||
|
UI documentation until a central cross-repository audit is available.
|
||||||
|
|
||||||
|
| Core scope | Why `FieldLabel` is omitted | Accessible/context label source |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `PasswordField`, `ColorPickerField`, `DateField`, `TimeField`, and `DateTimeField` input internals | These are label-neutral composite primitives and are placed inside `FormField`/`FieldLabel` by the consuming form. Rendering another label inside the primitive would duplicate it. | Enclosing label; a direct consumer must pass an accessible name and record that direct composition here. |
|
||||||
|
| `ToggleSwitch` native checkbox | The shared component already renders its visible text through `FieldLabel`; the native input must not render a second label. | The enclosing native label and derived `aria-label`. |
|
||||||
|
| `FileDropZone` hidden file input | The input is an implementation detail of the labelled keyboard-operable drop target. | Drop target text and `inputLabel`/`aria-label`. |
|
||||||
|
| `AdminSelectionList` and `DataGrid` list-filter checkboxes | Each option is self-explanatory and already enclosed by its visible option label. | Enclosing native option label. |
|
||||||
|
| `EmailAddressInput` compact Name and Email fields | These two conventional fields are self-explanatory in the compact address popover; richer address guidance belongs to the enclosing field. | Visible native labels; the free-form editor also has a descriptive `aria-label`. |
|
||||||
|
| `DataGrid` page-size, filter, and inline cell editors | The surrounding column header/filter heading supplies field context; repeating a labelled help marker in every cell would add noise. | Column header, filter heading/native label, or generated cell `aria-label`. |
|
||||||
|
| Retention-policy value controls | `PolicyRow` owns the field label, help, effective value, and provenance for its control. | The containing `PolicyRow` label/help contract. |
|
||||||
|
|
||||||
|
#### Alert Exception Register
|
||||||
|
|
||||||
|
No `window.alert` or global `alert` exception is authorized.
|
||||||
|
|
||||||
## Implementation Sequence
|
## Implementation Sequence
|
||||||
|
|
||||||
| Phase | Scope | Output |
|
| Phase | Scope | Output |
|
||||||
@@ -200,6 +327,22 @@ Every new or changed admin/configuration surface should answer:
|
|||||||
- Does the action surface show consequence, reversibility, and audit evidence
|
- Does the action surface show consequence, reversibility, and audit evidence
|
||||||
when rights, duties, records, money, communication, external systems, or
|
when rights, duties, records, money, communication, external systems, or
|
||||||
workflow state are affected?
|
workflow state are affected?
|
||||||
|
- Does the surface use every applicable central Core component? If it contains
|
||||||
|
a custom component, is the product-owner authorization, narrow purpose,
|
||||||
|
rationale, scope, and non-duplication evidence recorded?
|
||||||
|
- Is a collection-wide create action in the collection heading rather than
|
||||||
|
duplicated in a persistent side panel?
|
||||||
|
- Are logical sections, tabular collections, boolean settings, and repeatable
|
||||||
|
people/contact rows composed with `Card`, `DataGrid`, `ToggleSwitch`, and one
|
||||||
|
structured row per person respectively?
|
||||||
|
- Does every non-self-explanatory field use `FieldLabel`, and is every omission
|
||||||
|
recorded with its rationale and accessible-name source?
|
||||||
|
- Do explicit Discard and dirty navigation use the shared unsaved-changes
|
||||||
|
registration/dialog rather than a page-local confirmation?
|
||||||
|
- Does every row retain the table's action set in the same order, disabling
|
||||||
|
unavailable actions and reserving the same empty-row slots?
|
||||||
|
- Is feedback rendered with a central dialog/alert component, with no
|
||||||
|
unauthorized `window.alert` or global `alert` call?
|
||||||
- If automation is involved, can the user see the trigger, system actor,
|
- If automation is involved, can the user see the trigger, system actor,
|
||||||
observed effects, and failure/manual-intervention state?
|
observed effects, and failure/manual-intervention state?
|
||||||
- Are technical details available without being the first thing the user sees?
|
- Are technical details available without being the first thing the user sees?
|
||||||
|
|||||||
71
docs/WEBUI_BUNDLE_BUDGETS.md
Normal file
71
docs/WEBUI_BUNDLE_BUDGETS.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# WebUI Loading And Bundle Budgets
|
||||||
|
|
||||||
|
The Core WebUI host owns the loading boundary for installed module packages.
|
||||||
|
Vite discovers configured packages at build time, but emits an asynchronous
|
||||||
|
loader for each package's `src/module.ts` contribution descriptor. At runtime,
|
||||||
|
Core imports only descriptors whose backend manifests are enabled and identify
|
||||||
|
the matching `frontend.package_name`.
|
||||||
|
|
||||||
|
The direct descriptor entry is intentional. A package root may re-export pages
|
||||||
|
for consumers; importing that barrel as module wiring can cause those pages to
|
||||||
|
be evaluated before navigation. Route pages and substantial panels should use
|
||||||
|
`React.lazy`, and Core wraps routes in the shared loading/error boundary.
|
||||||
|
|
||||||
|
## Enforced Budgets
|
||||||
|
|
||||||
|
`webui/bundle-budget.json` contains the production limits:
|
||||||
|
|
||||||
|
| Measurement | Raw limit | Gzip limit |
|
||||||
|
| --- | ---: | ---: |
|
||||||
|
| Initial JavaScript static import closure | 512 KiB | 160 KiB |
|
||||||
|
| Largest individual asynchronous JavaScript chunk | 384 KiB | 110 KiB |
|
||||||
|
|
||||||
|
`npm run build` writes a Vite manifest, measures the entry and its recursive
|
||||||
|
static imports, writes `dist/bundle-metrics.json`, and fails when either budget
|
||||||
|
is exceeded. `npm run test:module-permutations` applies the same gate to every
|
||||||
|
permutation and records the collected results in
|
||||||
|
`dist/module-permutation-bundle-metrics.json`. In CI, each result is also added
|
||||||
|
to the step summary.
|
||||||
|
|
||||||
|
Budgets are limits, not targets. A change that approaches a limit should add a
|
||||||
|
new lazy boundary or remove unnecessary entry code instead of raising the
|
||||||
|
limit without measurement and review.
|
||||||
|
|
||||||
|
## 2026-07-30 Baseline
|
||||||
|
|
||||||
|
Measurements use the same full-product source tree and Node 22 runtime. The
|
||||||
|
post-change build additionally includes the Search module in the default and
|
||||||
|
full-product sets.
|
||||||
|
|
||||||
|
| Initial-load measurement | Before | After | Reduction |
|
||||||
|
| --- | ---: | ---: | ---: |
|
||||||
|
| JavaScript assets in initial static closure | 1 | 1 | 0% |
|
||||||
|
| Raw JavaScript | 1,387,043 B | 453,769 B | 67.3% |
|
||||||
|
| Gzip level 9 | 364,767 B | 141,725 B | 61.1% |
|
||||||
|
| Brotli quality 11 | 254,797 B | 106,701 B | 58.1% |
|
||||||
|
| Parse proxy median | 18.776 ms | 7.750 ms | 58.7% |
|
||||||
|
| Parse proxy p95 | 21.980 ms | 8.739 ms | 60.2% |
|
||||||
|
|
||||||
|
The parse proxy constructs a fresh `node:vm` `SourceTextModule` from the entry
|
||||||
|
source 30 times with a randomized source marker. It is useful for a controlled
|
||||||
|
before/after comparison, but is not enforced in CI because absolute timings
|
||||||
|
vary across runners. Transfer budgets use deterministic raw and gzip byte
|
||||||
|
counts.
|
||||||
|
|
||||||
|
The first budgeted full-product build reported:
|
||||||
|
|
||||||
|
- initial JavaScript: 453,769 B raw / 141,725 B gzip;
|
||||||
|
- largest async chunk: `CampaignWorkspace`, 353,724 B raw / 98,142 B gzip.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan-core/webui
|
||||||
|
npm run build
|
||||||
|
npm run check:bundle-budget
|
||||||
|
npm run test:module-permutations
|
||||||
|
```
|
||||||
|
|
||||||
|
The build gate also catches accidental eager imports: a page pulled into the
|
||||||
|
entry closure consumes the initial budget, while an oversized page or module
|
||||||
|
descriptor consumes the asynchronous chunk budget.
|
||||||
@@ -45,7 +45,7 @@ Remediation applied on 2026-07-09:
|
|||||||
- upgraded the campaign ZIP dependency to `pyzipper>=0.4,<1`, resolving to
|
- upgraded the campaign ZIP dependency to `pyzipper>=0.4,<1`, resolving to
|
||||||
`pyzipper==0.4.0`
|
`pyzipper==0.4.0`
|
||||||
- upgraded the local audit environment to `pip==26.1.2`
|
- upgraded the local audit environment to `pip==26.1.2`
|
||||||
- removed the obsolete local `govoplan-module-multimailer` editable install
|
- removed the obsolete local mailer-module editable install
|
||||||
from the audit environment so the audit reflects the split module product
|
from the audit environment so the audit reflects the split module product
|
||||||
|
|
||||||
Post-remediation result:
|
Post-remediation result:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"description": "Portal form, case workflow, task creation, mail notification, payment setup, and access roles for a simple application process.",
|
"description": "Portal form, case workflow, task creation, mail notification, payment setup, and access roles for a simple application process.",
|
||||||
"publisher": "ADD ideas",
|
"publisher": "ADD ideas",
|
||||||
"category": "workflow",
|
"category": "workflow",
|
||||||
"artifact_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-configuration-packages.git@application-handling-basic-v0.1.0",
|
"artifact_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-configuration-packages.git@application-handling-basic-v0.1.0",
|
||||||
"artifact_sha256": "<sha256>",
|
"artifact_sha256": "<sha256>",
|
||||||
"required_modules": [
|
"required_modules": [
|
||||||
{ "module_id": "portal", "version": ">=0.1.0" },
|
{ "module_id": "portal", "version": ">=0.1.0" },
|
||||||
|
|||||||
@@ -81,8 +81,588 @@
|
|||||||
],
|
],
|
||||||
"recorded_at": "2026-07-11T01:39:45Z",
|
"recorded_at": "2026-07-11T01:39:45Z",
|
||||||
"release": "0.1.7",
|
"release": "0.1.7",
|
||||||
"track": "release",
|
"squash_policy": "reviewed-manual",
|
||||||
"squash_policy": "reviewed-manual"
|
"track": "release"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revision": "2c3d4e5f7081"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revision": "3d4e5f708192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revision": "8f9a0b1c2d3e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revision": "9e0f1a2b3c4d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revision": "a7b8c9d0e1f3"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"owner_heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-access",
|
||||||
|
"revisions": [
|
||||||
|
"4a5b6c7d8e9f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revisions": [
|
||||||
|
"9e0f1a2b3c4d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revisions": [
|
||||||
|
"2c3d4e5f7081"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-core",
|
||||||
|
"revisions": [
|
||||||
|
"4f2a9c8e7b6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revisions": [
|
||||||
|
"a7b8c9d0e1f3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-identity",
|
||||||
|
"revisions": [
|
||||||
|
"5c6d7e8f9a10"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revisions": [
|
||||||
|
"8f9a0b1c2d3e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revisions": [
|
||||||
|
"3d4e5f708192"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-organizations",
|
||||||
|
"revisions": [
|
||||||
|
"6d7e8f9a0b1c"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recorded_at": "2026-07-20T02:45:41Z",
|
||||||
|
"release": "0.1.8",
|
||||||
|
"squash_policy": "reviewed-manual",
|
||||||
|
"track": "release"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revision": "4d5e6f7a9203"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revision": "608192abcdef"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revision": "6e7f8a9b0c1d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revision": "6f7a8b9c0d1e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revision": "8f9a0b1c2d3e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revision": "a7b8c9d0e1f3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revision": "af1b2c3d4e5f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revision": "c9d4e7f1a2b3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revision": "e1f2a4b5c6d"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"owner_heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-access",
|
||||||
|
"revisions": [
|
||||||
|
"4a5b6c7d8e9f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revisions": [
|
||||||
|
"e1f2a4b5c6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revisions": [
|
||||||
|
"af1b2c3d4e5f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revisions": [
|
||||||
|
"4d5e6f7a9203"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-core",
|
||||||
|
"revisions": [
|
||||||
|
"4f2a9c8e7b6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revisions": [
|
||||||
|
"a7b8c9d0e1f3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-identity",
|
||||||
|
"revisions": [
|
||||||
|
"5c6d7e8f9a10"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revisions": [
|
||||||
|
"8f9a0b1c2d3e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revisions": [
|
||||||
|
"608192abcdef"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revisions": [
|
||||||
|
"6f7a8b9c0d1e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-organizations",
|
||||||
|
"revisions": [
|
||||||
|
"6d7e8f9a0b1c"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revisions": [
|
||||||
|
"6e7f8a9b0c1d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revisions": [
|
||||||
|
"c9d4e7f1a2b3"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recorded_at": "2026-07-22T02:42:27Z",
|
||||||
|
"release": "0.1.11",
|
||||||
|
"squash_policy": "reviewed-manual",
|
||||||
|
"track": "release"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revision": "608192abcdef"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revision": "6e7f8a9b0c1d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revision": "6f7a8b9c0d1e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revision": "8f9a0b1c2d3e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revision": "a7b8c9d0e1f3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revision": "af1b2c3d4e5f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revision": "c9d4e7f1a2b3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revision": "d8b3e2c1f4a5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revision": "e1f2a4b5c6d"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"owner_heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-access",
|
||||||
|
"revisions": [
|
||||||
|
"4a5b6c7d8e9f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revisions": [
|
||||||
|
"e1f2a4b5c6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revisions": [
|
||||||
|
"af1b2c3d4e5f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revisions": [
|
||||||
|
"d8b3e2c1f4a5"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-core",
|
||||||
|
"revisions": [
|
||||||
|
"4f2a9c8e7b6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revisions": [
|
||||||
|
"a7b8c9d0e1f3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-identity",
|
||||||
|
"revisions": [
|
||||||
|
"5c6d7e8f9a10"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revisions": [
|
||||||
|
"8f9a0b1c2d3e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revisions": [
|
||||||
|
"608192abcdef"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revisions": [
|
||||||
|
"6f7a8b9c0d1e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-organizations",
|
||||||
|
"revisions": [
|
||||||
|
"6d7e8f9a0b1c"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revisions": [
|
||||||
|
"6e7f8a9b0c1d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revisions": [
|
||||||
|
"c9d4e7f1a2b3"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recorded_at": "2026-07-22T07:06:21Z",
|
||||||
|
"release": "0.1.12",
|
||||||
|
"squash_policy": "reviewed-manual",
|
||||||
|
"track": "release"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revision": "608192abcdef"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revision": "6e7f8a9b0c1d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revision": "6f7a8b9c0d1e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revision": "8f9a0b1c2d3e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revision": "a7b8c9d0e1f3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revision": "af1b2c3d4e5f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revision": "c9d4e7f1a2b3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revision": "d8b3e2c1f4a5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revision": "e1f2a4b5c6d"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"owner_heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-access",
|
||||||
|
"revisions": [
|
||||||
|
"4a5b6c7d8e9f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revisions": [
|
||||||
|
"e1f2a4b5c6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revisions": [
|
||||||
|
"af1b2c3d4e5f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revisions": [
|
||||||
|
"d8b3e2c1f4a5"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-core",
|
||||||
|
"revisions": [
|
||||||
|
"4f2a9c8e7b6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revisions": [
|
||||||
|
"a7b8c9d0e1f3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-identity",
|
||||||
|
"revisions": [
|
||||||
|
"5c6d7e8f9a10"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revisions": [
|
||||||
|
"8f9a0b1c2d3e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revisions": [
|
||||||
|
"608192abcdef"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revisions": [
|
||||||
|
"6f7a8b9c0d1e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-organizations",
|
||||||
|
"revisions": [
|
||||||
|
"6d7e8f9a0b1c"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revisions": [
|
||||||
|
"6e7f8a9b0c1d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revisions": [
|
||||||
|
"c9d4e7f1a2b3"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recorded_at": "2026-07-22T08:39:02Z",
|
||||||
|
"release": "0.1.13",
|
||||||
|
"squash_policy": "reviewed-manual",
|
||||||
|
"track": "release"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revision": "608192abcdef"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revision": "6e7f8a9b0c1d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revision": "6f7a8b9c0d1e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revision": "8f9a0b1c2d3e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revision": "a7b8c9d0e1f3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revision": "af1b2c3d4e5f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revision": "c9d4e7f1a2b3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revision": "d8b3e2c1f4a5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revision": "e1f2a4b5c6d"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"owner_heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-access",
|
||||||
|
"revisions": [
|
||||||
|
"4a5b6c7d8e9f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revisions": [
|
||||||
|
"e1f2a4b5c6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revisions": [
|
||||||
|
"af1b2c3d4e5f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revisions": [
|
||||||
|
"d8b3e2c1f4a5"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-core",
|
||||||
|
"revisions": [
|
||||||
|
"4f2a9c8e7b6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revisions": [
|
||||||
|
"a7b8c9d0e1f3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-identity",
|
||||||
|
"revisions": [
|
||||||
|
"5c6d7e8f9a10"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revisions": [
|
||||||
|
"8f9a0b1c2d3e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revisions": [
|
||||||
|
"608192abcdef"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revisions": [
|
||||||
|
"6f7a8b9c0d1e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-organizations",
|
||||||
|
"revisions": [
|
||||||
|
"6d7e8f9a0b1c"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revisions": [
|
||||||
|
"6e7f8a9b0c1d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revisions": [
|
||||||
|
"c9d4e7f1a2b3"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recorded_at": "2026-07-22T18:15:01Z",
|
||||||
|
"release": "0.1.14",
|
||||||
|
"squash_policy": "reviewed-manual",
|
||||||
|
"track": "release"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version": 1
|
"version": 1
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"action": "install",
|
"action": "install",
|
||||||
"python_package": "govoplan-files",
|
"python_package": "govoplan-files",
|
||||||
"python_ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.4",
|
"python_ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.4",
|
||||||
"webui_package": "@govoplan/files-webui",
|
"webui_package": "@govoplan/files-webui",
|
||||||
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4",
|
"webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.4",
|
||||||
"migration_safety": "forward_only",
|
"migration_safety": "forward_only",
|
||||||
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
|
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
|
||||||
"migration_after": ["access"],
|
"migration_after": ["access"],
|
||||||
@@ -54,14 +54,14 @@
|
|||||||
],
|
],
|
||||||
"artifact_integrity": {
|
"artifact_integrity": {
|
||||||
"python": {
|
"python": {
|
||||||
"ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.4",
|
"ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.4",
|
||||||
"sha256": "<sha256 of the resolved Python artifact>",
|
"sha256": "<sha256 of the resolved Python artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-0.1.4.spdx.json",
|
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-0.1.4.spdx.json",
|
||||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-0.1.4.intoto.jsonl",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-0.1.4.intoto.jsonl",
|
||||||
"git_ref": "refs/tags/v0.1.4"
|
"git_ref": "refs/tags/v0.1.4"
|
||||||
},
|
},
|
||||||
"webui": {
|
"webui": {
|
||||||
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4",
|
"ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.4",
|
||||||
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-webui-0.1.4.spdx.json",
|
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-webui-0.1.4.spdx.json",
|
||||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-webui-0.1.4.intoto.jsonl",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-webui-0.1.4.intoto.jsonl",
|
||||||
@@ -78,25 +78,33 @@
|
|||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"action": "install",
|
"action": "install",
|
||||||
"python_package": "govoplan-mail",
|
"python_package": "govoplan-mail",
|
||||||
"python_ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.4",
|
"python_ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.4",
|
||||||
"webui_package": "@govoplan/mail-webui",
|
"webui_package": "@govoplan/mail-webui",
|
||||||
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4",
|
"webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.4",
|
||||||
"provides_interfaces": [
|
"provides_interfaces": [
|
||||||
{
|
{
|
||||||
"name": "mail.campaign_delivery",
|
"name": "mail.campaign_delivery",
|
||||||
"version": "1.4.0"
|
"version": "0.2.0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requires_interfaces": [
|
||||||
|
{
|
||||||
|
"name": "campaigns.access",
|
||||||
|
"version_min": "0.1.0",
|
||||||
|
"version_max_exclusive": "0.2.0",
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"artifact_integrity": {
|
"artifact_integrity": {
|
||||||
"python": {
|
"python": {
|
||||||
"ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.4",
|
"ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.4",
|
||||||
"sha256": "<sha256 of the resolved Python artifact>",
|
"sha256": "<sha256 of the resolved Python artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-0.1.4.spdx.json",
|
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-0.1.4.spdx.json",
|
||||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-0.1.4.intoto.jsonl",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-0.1.4.intoto.jsonl",
|
||||||
"git_ref": "refs/tags/v0.1.4"
|
"git_ref": "refs/tags/v0.1.4"
|
||||||
},
|
},
|
||||||
"webui": {
|
"webui": {
|
||||||
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4",
|
"ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.4",
|
||||||
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-webui-0.1.4.spdx.json",
|
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-webui-0.1.4.spdx.json",
|
||||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-webui-0.1.4.intoto.jsonl",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-webui-0.1.4.intoto.jsonl",
|
||||||
@@ -113,19 +121,19 @@
|
|||||||
"version": "0.1.6",
|
"version": "0.1.6",
|
||||||
"action": "install",
|
"action": "install",
|
||||||
"python_package": "govoplan-dashboard",
|
"python_package": "govoplan-dashboard",
|
||||||
"python_ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git@v0.1.6",
|
"python_ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.6",
|
||||||
"webui_package": "@govoplan/dashboard-webui",
|
"webui_package": "@govoplan/dashboard-webui",
|
||||||
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.6",
|
"webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.6",
|
||||||
"artifact_integrity": {
|
"artifact_integrity": {
|
||||||
"python": {
|
"python": {
|
||||||
"ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git@v0.1.6",
|
"ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.6",
|
||||||
"sha256": "<sha256 of the resolved Python artifact>",
|
"sha256": "<sha256 of the resolved Python artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-0.1.6.spdx.json",
|
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-0.1.6.spdx.json",
|
||||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-0.1.6.intoto.jsonl",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-0.1.6.intoto.jsonl",
|
||||||
"git_ref": "refs/tags/v0.1.6"
|
"git_ref": "refs/tags/v0.1.6"
|
||||||
},
|
},
|
||||||
"webui": {
|
"webui": {
|
||||||
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.6",
|
"ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.6",
|
||||||
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-webui-0.1.6.spdx.json",
|
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-webui-0.1.6.spdx.json",
|
||||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-webui-0.1.6.intoto.jsonl",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-webui-0.1.6.intoto.jsonl",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-core"
|
name = "govoplan-core"
|
||||||
version = "0.1.8"
|
version = "0.1.14"
|
||||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
@@ -27,6 +27,12 @@ where = ["src"]
|
|||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
govoplan_core = ["py.typed"]
|
govoplan_core = ["py.typed"]
|
||||||
|
|
||||||
|
[tool.setuptools.data-files]
|
||||||
|
"govoplan_core_runtime" = ["alembic.ini"]
|
||||||
|
"govoplan_core_runtime/alembic" = ["alembic/env.py", "alembic/script.py.mako"]
|
||||||
|
"govoplan_core_runtime/alembic/versions" = ["alembic/versions/*.py"]
|
||||||
|
"govoplan_core_runtime/alembic/dev_versions" = ["alembic/dev_versions/*.py"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
govoplan-config = "govoplan_core.commands.config:main"
|
govoplan-config = "govoplan_core.commands.config:main"
|
||||||
govoplan-devserver = "govoplan_core.devserver:main"
|
govoplan-devserver = "govoplan_core.devserver:main"
|
||||||
@@ -42,3 +48,7 @@ dev = [
|
|||||||
"httpx==0.28.1",
|
"httpx==0.28.1",
|
||||||
"httpx2>=2.5,<3",
|
"httpx2>=2.5,<3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/test_api_smoke.py" = ["E402"]
|
||||||
|
"tests/test_module_system.py" = ["E402"]
|
||||||
|
|||||||
@@ -39,6 +39,24 @@ class DeltaCollectionResponse(BaseModel):
|
|||||||
full: bool = False
|
full: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceOptionResponse(BaseModel):
|
||||||
|
value: str
|
||||||
|
label: str
|
||||||
|
description: str | None = None
|
||||||
|
kind: str | None = None
|
||||||
|
availability: Literal["available", "inactive", "unavailable"] = "available"
|
||||||
|
disabled: bool = False
|
||||||
|
source_module: str | None = None
|
||||||
|
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceOptionListResponse(BaseModel):
|
||||||
|
options: list[ReferenceOptionResponse] = Field(default_factory=list)
|
||||||
|
provider_available: bool = True
|
||||||
|
next_cursor: str | None = None
|
||||||
|
has_more: bool = False
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -94,6 +112,28 @@ class UserInfo(BaseModel):
|
|||||||
ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences)
|
ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSessionUserInfo(BaseModel):
|
||||||
|
id: str
|
||||||
|
account_id: str
|
||||||
|
email: str
|
||||||
|
display_name: str | None = None
|
||||||
|
tenant_display_name: str | None = None
|
||||||
|
is_tenant_admin: bool = False
|
||||||
|
password_reset_required: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSessionResponse(BaseModel):
|
||||||
|
authenticated: bool = True
|
||||||
|
auth_method: Literal["session", "api_key"] = "session"
|
||||||
|
user: AuthSessionUserInfo
|
||||||
|
# Backwards-compatible alias for the active tenant.
|
||||||
|
tenant: TenantInfo
|
||||||
|
active_tenant: TenantInfo
|
||||||
|
session_id: str | None = None
|
||||||
|
api_key_id: str | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProfileUpdateRequest(BaseModel):
|
class ProfileUpdateRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -143,9 +183,43 @@ class PrincipalContextInfo(BaseModel):
|
|||||||
display_name: str | None = None
|
display_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuthShellResponse(BaseModel):
|
||||||
|
user: UserInfo
|
||||||
|
# Backwards-compatible alias for the active tenant.
|
||||||
|
tenant: TenantInfo
|
||||||
|
active_tenant: TenantInfo
|
||||||
|
tenants: list[TenantMembershipInfo] = Field(default_factory=list)
|
||||||
|
scopes: list[str]
|
||||||
|
principal: PrincipalContextInfo | None = None
|
||||||
|
profile_loaded: bool = False
|
||||||
|
roles_loaded: bool = False
|
||||||
|
groups_loaded: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AuthProfileResponse(BaseModel):
|
||||||
|
user: UserInfo
|
||||||
|
# Backwards-compatible alias for the active tenant.
|
||||||
|
tenant: TenantInfo
|
||||||
|
active_tenant: TenantInfo
|
||||||
|
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
||||||
|
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||||
|
default_language: str = "en"
|
||||||
|
profile_loaded: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class AuthRolesResponse(BaseModel):
|
||||||
|
roles: list[RoleInfo] = Field(default_factory=list)
|
||||||
|
roles_loaded: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class AuthGroupsResponse(BaseModel):
|
||||||
|
groups: list[GroupInfo] = Field(default_factory=list)
|
||||||
|
groups_loaded: bool = True
|
||||||
|
|
||||||
|
|
||||||
class LoginResponse(BaseModel):
|
class LoginResponse(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer" # noqa: S105 - OAuth token type, not a credential.
|
||||||
expires_at: datetime
|
expires_at: datetime
|
||||||
user: UserInfo
|
user: UserInfo
|
||||||
# Backwards-compatible alias for the active tenant.
|
# Backwards-compatible alias for the active tenant.
|
||||||
@@ -159,6 +233,9 @@ class LoginResponse(BaseModel):
|
|||||||
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
||||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||||
default_language: str = "en"
|
default_language: str = "en"
|
||||||
|
profile_loaded: bool = True
|
||||||
|
roles_loaded: bool = True
|
||||||
|
groups_loaded: bool = True
|
||||||
|
|
||||||
|
|
||||||
class MeResponse(BaseModel):
|
class MeResponse(BaseModel):
|
||||||
@@ -174,3 +251,6 @@ class MeResponse(BaseModel):
|
|||||||
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
available_languages: list[LanguageInfo] = Field(default_factory=list)
|
||||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||||
default_language: str = "en"
|
default_language: str = "en"
|
||||||
|
profile_loaded: bool = True
|
||||||
|
roles_loaded: bool = True
|
||||||
|
groups_loaded: bool = True
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ 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.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
|
||||||
@@ -171,9 +171,13 @@ class _NullAuditRecorder:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _publish_audit_platform_event(item: AuditRecordRef) -> None:
|
def _publish_audit_platform_event(
|
||||||
|
session: Session,
|
||||||
|
item: AuditRecordRef,
|
||||||
|
) -> None:
|
||||||
trace = _compact_trace(item.details.get("_trace") if isinstance(item.details, Mapping) else None)
|
trace = _compact_trace(item.details.get("_trace") if isinstance(item.details, Mapping) else None)
|
||||||
publish_platform_event(
|
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),
|
||||||
@@ -252,7 +256,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
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
"""Core auth dependency facade.
|
"""Core auth dependency facade.
|
||||||
|
|
||||||
Routers depend on this module instead of a concrete access-provider package.
|
Routers depend on this module instead of a concrete access-provider package.
|
||||||
@@ -7,6 +5,8 @@ The active auth module provides the request principal through the platform
|
|||||||
capability registry.
|
capability registry.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from fastapi import Depends, Header, HTTPException, Request, status
|
from fastapi import Depends, Header, HTTPException, Request, status
|
||||||
@@ -118,7 +118,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")
|
||||||
|
|||||||
@@ -1,10 +1,37 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from celery import Celery
|
from celery import Celery
|
||||||
|
|
||||||
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider
|
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.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.mail import CAPABILITY_MAIL_DELIVERY_OUTBOX, MailDeliveryOutboxProvider
|
||||||
from govoplan_core.core.modules import ModuleContext
|
from govoplan_core.core.modules import ModuleContext
|
||||||
|
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
|
||||||
|
from govoplan_core.core.postbox import (
|
||||||
|
CAPABILITY_POSTBOX_ROUTING,
|
||||||
|
PostboxRoutingProvider,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.workflows import (
|
||||||
|
CAPABILITY_WORKFLOW_RUNTIME_WORKER,
|
||||||
|
WorkflowRuntimeWorker,
|
||||||
|
)
|
||||||
|
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.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
|
||||||
@@ -13,7 +40,7 @@ from govoplan_core.server.registry import available_module_manifests, build_plat
|
|||||||
configure_database(settings.database_url)
|
configure_database(settings.database_url)
|
||||||
|
|
||||||
celery = Celery(
|
celery = Celery(
|
||||||
"multimailer",
|
"govoplan",
|
||||||
broker=settings.redis_url,
|
broker=settings.redis_url,
|
||||||
backend=settings.redis_url,
|
backend=settings.redis_url,
|
||||||
)
|
)
|
||||||
@@ -21,21 +48,85 @@ celery = Celery(
|
|||||||
celery.conf.update(
|
celery.conf.update(
|
||||||
task_default_queue="default",
|
task_default_queue="default",
|
||||||
task_routes={
|
task_routes={
|
||||||
"multimailer.send_email": {"queue": "send_email"},
|
"govoplan.campaigns.send_email": {"queue": "send_email"},
|
||||||
"multimailer.append_sent": {"queue": "append_sent"},
|
"govoplan.campaigns.append_sent": {"queue": "append_sent"},
|
||||||
|
"govoplan.notifications.deliver": {"queue": "notifications"},
|
||||||
|
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
|
||||||
|
"govoplan.mail.dispatch_outbox": {"queue": "mail"},
|
||||||
|
"govoplan.mail.purge_outbox": {"queue": "mail"},
|
||||||
|
"govoplan.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"},
|
||||||
},
|
},
|
||||||
worker_prefetch_multiplier=1,
|
worker_prefetch_multiplier=1,
|
||||||
task_acks_late=True,
|
task_acks_late=True,
|
||||||
task_reject_on_worker_lost=True,
|
task_reject_on_worker_lost=True,
|
||||||
|
beat_schedule={
|
||||||
|
"calendar-outbox-every-minute": {
|
||||||
|
"task": "govoplan.calendar.dispatch_outbox",
|
||||||
|
"schedule": 60.0,
|
||||||
|
"args": (None, 100),
|
||||||
|
},
|
||||||
|
"mail-outbox-every-five-seconds": {
|
||||||
|
"task": "govoplan.mail.dispatch_outbox",
|
||||||
|
"schedule": 5.0,
|
||||||
|
"args": (None, 25),
|
||||||
|
},
|
||||||
|
"mail-outbox-retention-daily": {
|
||||||
|
"task": "govoplan.mail.purge_outbox",
|
||||||
|
"schedule": 24 * 60 * 60.0,
|
||||||
|
"args": (250,),
|
||||||
|
},
|
||||||
|
"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,),
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@celery.task(name="multimailer.ping")
|
@celery.task(name="govoplan.ping")
|
||||||
def ping():
|
def ping():
|
||||||
return "pong"
|
return "pong"
|
||||||
|
|
||||||
|
|
||||||
def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider:
|
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(settings.enabled_modules, raw_enabled_modules)
|
||||||
available_modules = available_module_manifests(enabled_modules=candidate_modules, ignore_load_errors=True)
|
available_modules = available_module_manifests(enabled_modules=candidate_modules, ignore_load_errors=True)
|
||||||
@@ -44,13 +135,110 @@ def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider:
|
|||||||
context = ModuleContext(registry=registry, settings=settings)
|
context = ModuleContext(registry=registry, settings=settings)
|
||||||
configure_runtime(context)
|
configure_runtime(context)
|
||||||
registry.configure_capability_context(context)
|
registry.configure_capability_context(context)
|
||||||
|
return registry
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider:
|
||||||
|
registry = _platform_registry()
|
||||||
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_DELIVERY_TASKS)
|
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_DELIVERY_TASKS)
|
||||||
if not isinstance(capability, CampaignDeliveryTaskProvider):
|
if not isinstance(capability, CampaignDeliveryTaskProvider):
|
||||||
raise RuntimeError("Campaign delivery task capability is invalid")
|
raise RuntimeError("Campaign delivery task capability is invalid")
|
||||||
return capability
|
return capability
|
||||||
|
|
||||||
|
|
||||||
@celery.task(name="multimailer.send_email", bind=True, max_retries=0)
|
def _notification_dispatch() -> NotificationDispatchProvider:
|
||||||
|
registry = _platform_registry()
|
||||||
|
capability = registry.require_capability(CAPABILITY_NOTIFICATIONS_DISPATCH)
|
||||||
|
if not isinstance(capability, NotificationDispatchProvider):
|
||||||
|
raise RuntimeError("Notification dispatch capability is invalid")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _calendar_outbox() -> CalendarOutboxProvider | None:
|
||||||
|
registry = _platform_registry()
|
||||||
|
if not registry.has_capability(CAPABILITY_CALENDAR_OUTBOX):
|
||||||
|
return None
|
||||||
|
capability = registry.require_capability(CAPABILITY_CALENDAR_OUTBOX)
|
||||||
|
if not isinstance(capability, CalendarOutboxProvider):
|
||||||
|
raise RuntimeError("Calendar outbox capability is invalid")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _mail_delivery_outbox() -> MailDeliveryOutboxProvider | None:
|
||||||
|
registry = _platform_registry()
|
||||||
|
if not registry.has_capability(CAPABILITY_MAIL_DELIVERY_OUTBOX):
|
||||||
|
return None
|
||||||
|
capability = registry.require_capability(CAPABILITY_MAIL_DELIVERY_OUTBOX)
|
||||||
|
if not isinstance(capability, MailDeliveryOutboxProvider):
|
||||||
|
raise RuntimeError("Mail delivery outbox capability is invalid")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _dataflow_trigger_dispatcher(
|
||||||
|
registry: PlatformRegistry | None = None,
|
||||||
|
) -> DataflowTriggerDispatcher | None:
|
||||||
|
registry = registry or _platform_registry()
|
||||||
|
if not registry.has_capability(CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER):
|
||||||
|
return None
|
||||||
|
capability = registry.require_capability(
|
||||||
|
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER
|
||||||
|
)
|
||||||
|
if not isinstance(capability, DataflowTriggerDispatcher):
|
||||||
|
raise RuntimeError("Dataflow trigger dispatcher capability is invalid")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _dataflow_run_worker(
|
||||||
|
registry: PlatformRegistry | None = None,
|
||||||
|
) -> DataflowRunWorker | None:
|
||||||
|
registry = registry or _platform_registry()
|
||||||
|
if not registry.has_capability(CAPABILITY_DATAFLOW_RUN_WORKER):
|
||||||
|
return None
|
||||||
|
capability = registry.require_capability(CAPABILITY_DATAFLOW_RUN_WORKER)
|
||||||
|
if not isinstance(capability, DataflowRunWorker):
|
||||||
|
raise RuntimeError("Dataflow run worker capability is invalid")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _workflow_runtime_worker(
|
||||||
|
registry: PlatformRegistry | None = None,
|
||||||
|
) -> WorkflowRuntimeWorker | None:
|
||||||
|
registry = registry or _platform_registry()
|
||||||
|
if not registry.has_capability(CAPABILITY_WORKFLOW_RUNTIME_WORKER):
|
||||||
|
return None
|
||||||
|
capability = registry.require_capability(
|
||||||
|
CAPABILITY_WORKFLOW_RUNTIME_WORKER
|
||||||
|
)
|
||||||
|
if not isinstance(capability, WorkflowRuntimeWorker):
|
||||||
|
raise RuntimeError("Workflow runtime worker capability is invalid")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _postbox_routing_provider(
|
||||||
|
registry: PlatformRegistry | None = None,
|
||||||
|
) -> PostboxRoutingProvider | None:
|
||||||
|
registry = registry or _platform_registry()
|
||||||
|
if not registry.has_capability(CAPABILITY_POSTBOX_ROUTING):
|
||||||
|
return None
|
||||||
|
capability = registry.require_capability(CAPABILITY_POSTBOX_ROUTING)
|
||||||
|
if not isinstance(capability, PostboxRoutingProvider):
|
||||||
|
raise RuntimeError("Postbox routing capability is invalid")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _platform_event_outbox(
|
||||||
|
registry: PlatformRegistry | None = None,
|
||||||
|
) -> PlatformEventOutbox | None:
|
||||||
|
registry = registry or _platform_registry()
|
||||||
|
if not registry.has_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX):
|
||||||
|
return None
|
||||||
|
capability = registry.require_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
|
||||||
|
if not isinstance(capability, PlatformEventOutbox):
|
||||||
|
raise RuntimeError("Platform event outbox capability is invalid")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
@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.
|
||||||
|
|
||||||
@@ -65,7 +253,7 @@ def send_email(self, job_id: str):
|
|||||||
return dict(_campaign_delivery_tasks().send_campaign_job(session, job_id=job_id, enqueue_imap_task=True))
|
return dict(_campaign_delivery_tasks().send_campaign_job(session, job_id=job_id, enqueue_imap_task=True))
|
||||||
|
|
||||||
|
|
||||||
@celery.task(name="multimailer.append_sent", bind=True, max_retries=None)
|
@celery.task(name="govoplan.campaigns.append_sent", bind=True, max_retries=None)
|
||||||
def append_sent(self, job_id: str):
|
def append_sent(self, job_id: str):
|
||||||
"""Append the exact sent MIME to the configured IMAP Sent folder."""
|
"""Append the exact sent MIME to the configured IMAP Sent folder."""
|
||||||
|
|
||||||
@@ -78,3 +266,303 @@ def append_sent(self, job_id: str):
|
|||||||
if getattr(exc, "temporary", None) is True:
|
if getattr(exc, "temporary", None) is True:
|
||||||
raise self.retry(exc=exc, countdown=300)
|
raise self.retry(exc=exc, countdown=300)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(name="govoplan.notifications.deliver", bind=True, max_retries=0)
|
||||||
|
def deliver_notification(self, notification_id: str):
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
result = dict(_notification_dispatch().deliver_notification(session, notification_id=notification_id))
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(name="govoplan.notifications.deliver_pending", bind=True, max_retries=0)
|
||||||
|
def deliver_pending_notifications(self, tenant_id: str | None = None, limit: int = 50):
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
result = dict(_notification_dispatch().deliver_pending(session, tenant_id=tenant_id, limit=limit))
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(name="govoplan.mail.dispatch_outbox", bind=True, max_retries=0)
|
||||||
|
def dispatch_mail_outbox(
|
||||||
|
self,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
limit: int = 25,
|
||||||
|
):
|
||||||
|
"""Drain durable Mail commands; retry and reconciliation live in Mail."""
|
||||||
|
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
provider = _mail_delivery_outbox()
|
||||||
|
if provider is None:
|
||||||
|
return {
|
||||||
|
"selected": 0,
|
||||||
|
"accepted": 0,
|
||||||
|
"partially_refused": 0,
|
||||||
|
"retrying": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"outcome_unknown": 0,
|
||||||
|
"command_ids": [],
|
||||||
|
}
|
||||||
|
return dict(
|
||||||
|
provider.dispatch_due(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
limit=limit,
|
||||||
|
worker_id=getattr(self.request, "hostname", None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(name="govoplan.mail.purge_outbox", bind=True, max_retries=0)
|
||||||
|
def purge_mail_outbox(self, limit: int = 250):
|
||||||
|
"""Minimize expired Mail payloads while retaining delivery evidence."""
|
||||||
|
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
provider = _mail_delivery_outbox()
|
||||||
|
if provider is None:
|
||||||
|
return {"purged": 0}
|
||||||
|
return dict(provider.purge_expired(session, limit=limit))
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(name="govoplan.calendar.dispatch_outbox", bind=True, max_retries=0)
|
||||||
|
def dispatch_calendar_outbox(self, tenant_id: str | None = None, limit: int = 50):
|
||||||
|
"""Drain durable Calendar operations; retry timing lives in the database."""
|
||||||
|
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
provider = _calendar_outbox()
|
||||||
|
if provider is None:
|
||||||
|
return {"processed": 0, "succeeded": 0, "retrying": 0, "failed": 0, "operations": []}
|
||||||
|
result = dict(provider.dispatch_due(session, tenant_id=tenant_id, limit=limit))
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(
|
||||||
|
name="govoplan.dataflow.dispatch_triggers",
|
||||||
|
bind=True,
|
||||||
|
max_retries=0,
|
||||||
|
)
|
||||||
|
def dispatch_dataflow_triggers(self, limit: int = 100):
|
||||||
|
"""Drain durable Dataflow trigger deliveries and due schedules."""
|
||||||
|
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
provider = _dataflow_trigger_dispatcher()
|
||||||
|
if provider is None:
|
||||||
|
return {
|
||||||
|
"queued": 0,
|
||||||
|
"processed": 0,
|
||||||
|
"succeeded": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"blocked": 0,
|
||||||
|
"skipped": 0,
|
||||||
|
}
|
||||||
|
result = dict(provider.dispatch_due(session, limit=limit))
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(
|
||||||
|
name="govoplan.dataflow.dispatch_runs",
|
||||||
|
bind=True,
|
||||||
|
max_retries=0,
|
||||||
|
)
|
||||||
|
def dispatch_dataflow_runs(self, limit: int = 10):
|
||||||
|
"""Claim and execute durable Dataflow runs outside the API process."""
|
||||||
|
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
provider = _dataflow_run_worker()
|
||||||
|
if provider is None:
|
||||||
|
return {
|
||||||
|
"claimed": 0,
|
||||||
|
"succeeded": 0,
|
||||||
|
"retrying": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"cancelled": 0,
|
||||||
|
}
|
||||||
|
result = dict(
|
||||||
|
provider.dispatch_pending(
|
||||||
|
session,
|
||||||
|
limit=limit,
|
||||||
|
worker_id=getattr(self.request, "hostname", None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(
|
||||||
|
name="govoplan.dataflow.purge_runs",
|
||||||
|
bind=True,
|
||||||
|
max_retries=0,
|
||||||
|
)
|
||||||
|
def purge_dataflow_runs(self, limit: int = 500):
|
||||||
|
"""Apply Dataflow evidence-retention policy without deleting run records."""
|
||||||
|
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
provider = _dataflow_run_worker()
|
||||||
|
if provider is None:
|
||||||
|
return {"purged": 0}
|
||||||
|
result = dict(provider.purge_expired(session, limit=limit))
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(
|
||||||
|
name="govoplan.workflow.reconcile",
|
||||||
|
bind=True,
|
||||||
|
max_retries=0,
|
||||||
|
)
|
||||||
|
def reconcile_workflow_instances(self, limit: int = 50):
|
||||||
|
"""Resume asynchronous Workflow steps from durable provider state."""
|
||||||
|
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
provider = _workflow_runtime_worker()
|
||||||
|
if provider is None:
|
||||||
|
return {
|
||||||
|
"inspected": 0,
|
||||||
|
"advanced": 0,
|
||||||
|
"waiting": 0,
|
||||||
|
"failed": 0,
|
||||||
|
}
|
||||||
|
result = dict(provider.reconcile_pending(session, limit=limit))
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(
|
||||||
|
name="govoplan.postbox.dispatch_routes",
|
||||||
|
bind=True,
|
||||||
|
max_retries=0,
|
||||||
|
)
|
||||||
|
def dispatch_postbox_routes(
|
||||||
|
self,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
):
|
||||||
|
"""Deliver due Postbox vacancy escalations from durable route rows."""
|
||||||
|
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
provider = _postbox_routing_provider()
|
||||||
|
if provider is None:
|
||||||
|
return {
|
||||||
|
"selected": 0,
|
||||||
|
"delivered": 0,
|
||||||
|
"vacant": 0,
|
||||||
|
"rescheduled": 0,
|
||||||
|
"cancelled": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"route_ids": [],
|
||||||
|
}
|
||||||
|
result = dict(
|
||||||
|
provider.dispatch_due_routes(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(
|
||||||
|
name="govoplan.events.dispatch_outbox",
|
||||||
|
bind=True,
|
||||||
|
max_retries=0,
|
||||||
|
)
|
||||||
|
def dispatch_platform_events(self, limit: int = 100):
|
||||||
|
"""Deliver committed platform events through persistent consumer ledgers."""
|
||||||
|
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
registry = _platform_registry()
|
||||||
|
outbox = _platform_event_outbox(registry)
|
||||||
|
if outbox is None:
|
||||||
|
return {
|
||||||
|
"selected": 0,
|
||||||
|
"delivered": 0,
|
||||||
|
"retrying": 0,
|
||||||
|
"quarantined": 0,
|
||||||
|
"dispatched": 0,
|
||||||
|
"observer_failed": 0,
|
||||||
|
}
|
||||||
|
dataflow_dispatcher = _dataflow_trigger_dispatcher(registry)
|
||||||
|
consumers = ()
|
||||||
|
if dataflow_dispatcher is not None:
|
||||||
|
def deliver_to_dataflow(
|
||||||
|
event: PlatformEvent,
|
||||||
|
_delivery_key: str,
|
||||||
|
) -> None:
|
||||||
|
dataflow_dispatcher.ingest_event(
|
||||||
|
session,
|
||||||
|
event=event,
|
||||||
|
)
|
||||||
|
|
||||||
|
consumers = (
|
||||||
|
DurableEventConsumer(
|
||||||
|
consumer_id="dataflow.event-triggers.v1",
|
||||||
|
event_types=frozenset({"*"}),
|
||||||
|
classifications=frozenset({"public", "internal"}),
|
||||||
|
handler=deliver_to_dataflow,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = dict(
|
||||||
|
outbox.dispatch_pending(
|
||||||
|
session,
|
||||||
|
consumers=consumers,
|
||||||
|
observer=publish_platform_event,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(
|
||||||
|
name="govoplan.events.purge_outbox",
|
||||||
|
bind=True,
|
||||||
|
max_retries=0,
|
||||||
|
)
|
||||||
|
def purge_platform_events(self, limit: int = 500):
|
||||||
|
"""Remove old terminal event envelopes while retaining quarantine evidence."""
|
||||||
|
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
outbox = _platform_event_outbox()
|
||||||
|
if outbox is None:
|
||||||
|
return {"deleted": 0}
|
||||||
|
before = datetime.now(timezone.utc) - timedelta(
|
||||||
|
days=settings.platform_event_outbox_terminal_retention_days
|
||||||
|
)
|
||||||
|
result = dict(
|
||||||
|
outbox.purge_terminal(
|
||||||
|
session,
|
||||||
|
before=before,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from govoplan_core.core.module_installer import (
|
from govoplan_core.core.module_installer import (
|
||||||
ModuleInstallerError,
|
ModuleInstallerError,
|
||||||
@@ -27,6 +26,13 @@ from govoplan_core.core.module_installer import (
|
|||||||
update_module_installer_request,
|
update_module_installer_request,
|
||||||
update_module_installer_daemon_status,
|
update_module_installer_daemon_status,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.module_installer_notifications import (
|
||||||
|
build_runtime_notification_registry,
|
||||||
|
emit_module_installer_notification,
|
||||||
|
installer_notification_body,
|
||||||
|
installer_notification_priority,
|
||||||
|
installer_notification_subject,
|
||||||
|
)
|
||||||
from govoplan_core.core.module_license import issue_module_license, module_license_diagnostics
|
from govoplan_core.core.module_license import issue_module_license, module_license_diagnostics
|
||||||
from govoplan_core.core.module_package_catalog import sign_module_package_catalog, validate_module_package_catalog
|
from govoplan_core.core.module_package_catalog import sign_module_package_catalog, validate_module_package_catalog
|
||||||
from govoplan_core.core.module_management import (
|
from govoplan_core.core.module_management import (
|
||||||
@@ -39,7 +45,7 @@ from govoplan_core.server.registry import available_module_manifests
|
|||||||
from govoplan_core.settings import settings
|
from govoplan_core.settings import settings
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def _build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description="Preflight, apply, or roll back a GovOPlaN module package install plan.")
|
parser = argparse.ArgumentParser(description="Preflight, apply, or roll back a GovOPlaN module package install plan.")
|
||||||
parser.add_argument("--database-url", default=settings.database_url, help="Database URL containing system_settings.")
|
parser.add_argument("--database-url", default=settings.database_url, help="Database URL containing system_settings.")
|
||||||
parser.add_argument("--runtime-dir", type=Path, help="Directory for installer locks and run snapshots.")
|
parser.add_argument("--runtime-dir", type=Path, help="Directory for installer locks and run snapshots.")
|
||||||
@@ -95,11 +101,43 @@ def main() -> int:
|
|||||||
parser.add_argument("--license-signing-private-key", type=Path, help="PEM Ed25519 private key for --issue-license.")
|
parser.add_argument("--license-signing-private-key", type=Path, help="PEM Ed25519 private key for --issue-license.")
|
||||||
parser.add_argument("--license-issuer", help="Optional issuer string for --issue-license.")
|
parser.add_argument("--license-issuer", help="Optional issuer string for --issue-license.")
|
||||||
parser.add_argument("--license-notes", help="Optional operator note for --issue-license.")
|
parser.add_argument("--license-notes", help="Optional operator note for --issue-license.")
|
||||||
args = parser.parse_args()
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = _build_parser().parse_args()
|
||||||
runtime_dir = args.runtime_dir or default_installer_runtime_dir(args.database_url)
|
runtime_dir = args.runtime_dir or default_installer_runtime_dir(args.database_url)
|
||||||
try:
|
try:
|
||||||
|
return _dispatch_command(args=args, runtime_dir=runtime_dir)
|
||||||
|
except ModuleInstallerError as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
|
||||||
|
for handler in (
|
||||||
|
_handle_license_command,
|
||||||
|
_handle_catalog_command,
|
||||||
|
_handle_runtime_command,
|
||||||
|
_handle_queue_command,
|
||||||
|
_handle_run_history_command,
|
||||||
|
):
|
||||||
|
result = handler(args=args, runtime_dir=runtime_dir)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
return _handle_install_command(args=args, runtime_dir=runtime_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_license_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
|
||||||
|
del runtime_dir
|
||||||
if args.issue_license:
|
if args.issue_license:
|
||||||
|
return _issue_license_command(args)
|
||||||
|
if args.validate_license is not None:
|
||||||
|
return _validate_license_command(args)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_license_command(args: argparse.Namespace) -> int:
|
||||||
if not args.license_id or not args.license_subject or not args.license_valid_until:
|
if not args.license_id or not args.license_subject or not args.license_valid_until:
|
||||||
raise ModuleInstallerError("--issue-license requires --license-id, --license-subject, and --license-valid-until.")
|
raise ModuleInstallerError("--issue-license requires --license-id, --license-subject, and --license-valid-until.")
|
||||||
if not args.license_signing_key_id or not args.license_signing_private_key:
|
if not args.license_signing_key_id or not args.license_signing_private_key:
|
||||||
@@ -134,7 +172,9 @@ def main() -> int:
|
|||||||
output_format=args.format,
|
output_format=args.format,
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
if args.validate_license is not None:
|
|
||||||
|
|
||||||
|
def _validate_license_command(args: argparse.Namespace) -> int:
|
||||||
path = Path(args.validate_license).expanduser() if args.validate_license else None
|
path = Path(args.validate_license).expanduser() if args.validate_license else None
|
||||||
result = module_license_diagnostics(
|
result = module_license_diagnostics(
|
||||||
path,
|
path,
|
||||||
@@ -144,7 +184,18 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
_print_result(result, output_format=args.format)
|
_print_result(result, output_format=args.format)
|
||||||
return 0 if result.get("valid") and not result.get("missing_features") else 1
|
return 0 if result.get("valid") and not result.get("missing_features") else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_catalog_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
|
||||||
|
del runtime_dir
|
||||||
if args.sign_package_catalog:
|
if args.sign_package_catalog:
|
||||||
|
return _sign_package_catalog_command(args)
|
||||||
|
if args.validate_package_catalog is not None:
|
||||||
|
return _validate_package_catalog_command(args)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sign_package_catalog_command(args: argparse.Namespace) -> int:
|
||||||
if not args.catalog_signing_key_id or not args.catalog_signing_private_key:
|
if not args.catalog_signing_key_id or not args.catalog_signing_private_key:
|
||||||
raise ModuleInstallerError("--sign-package-catalog requires --catalog-signing-key-id and --catalog-signing-private-key.")
|
raise ModuleInstallerError("--sign-package-catalog requires --catalog-signing-key-id and --catalog-signing-private-key.")
|
||||||
path = sign_module_package_catalog(
|
path = sign_module_package_catalog(
|
||||||
@@ -155,7 +206,9 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
_print_result({"signed": True, "path": str(path), "key_id": args.catalog_signing_key_id}, output_format=args.format)
|
_print_result({"signed": True, "path": str(path), "key_id": args.catalog_signing_key_id}, output_format=args.format)
|
||||||
return 0
|
return 0
|
||||||
if args.validate_package_catalog is not None:
|
|
||||||
|
|
||||||
|
def _validate_package_catalog_command(args: argparse.Namespace) -> int:
|
||||||
path = Path(args.validate_package_catalog).expanduser() if args.validate_package_catalog else None
|
path = Path(args.validate_package_catalog).expanduser() if args.validate_package_catalog else None
|
||||||
result = validate_module_package_catalog(
|
result = validate_module_package_catalog(
|
||||||
path,
|
path,
|
||||||
@@ -165,11 +218,18 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
_print_result(result, output_format=args.format)
|
_print_result(result, output_format=args.format)
|
||||||
return 0 if result.get("valid") else 1
|
return 0 if result.get("valid") else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_runtime_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
|
||||||
if args.daemon_status:
|
if args.daemon_status:
|
||||||
_print_result(module_installer_daemon_status(runtime_dir=runtime_dir), output_format=args.format)
|
_print_result(module_installer_daemon_status(runtime_dir=runtime_dir), output_format=args.format)
|
||||||
return 0
|
return 0
|
||||||
if args.daemon or args.daemon_once:
|
if args.daemon or args.daemon_once:
|
||||||
return _run_daemon(args=args, runtime_dir=runtime_dir)
|
return _run_daemon(args=args, runtime_dir=runtime_dir)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_queue_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
|
||||||
if args.list_requests:
|
if args.list_requests:
|
||||||
_print_result({
|
_print_result({
|
||||||
"requests": list(list_module_installer_requests(runtime_dir=runtime_dir)),
|
"requests": list(list_module_installer_requests(runtime_dir=runtime_dir)),
|
||||||
@@ -186,13 +246,13 @@ def main() -> int:
|
|||||||
_print_result(retry_module_installer_request(runtime_dir=runtime_dir, request_id=args.retry_request, requested_by="cli"), output_format=args.format)
|
_print_result(retry_module_installer_request(runtime_dir=runtime_dir, request_id=args.retry_request, requested_by="cli"), output_format=args.format)
|
||||||
return 0
|
return 0
|
||||||
if args.enqueue_supervised:
|
if args.enqueue_supervised:
|
||||||
request = queue_module_installer_request(
|
request = queue_module_installer_request(runtime_dir=runtime_dir, requested_by="cli", options=_request_options_from_args(args))
|
||||||
runtime_dir=runtime_dir,
|
|
||||||
requested_by="cli",
|
|
||||||
options=_request_options_from_args(args),
|
|
||||||
)
|
|
||||||
_print_result(request, output_format=args.format)
|
_print_result(request, output_format=args.format)
|
||||||
return 0
|
return 0
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_run_history_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
|
||||||
if args.list_runs:
|
if args.list_runs:
|
||||||
_print_result({
|
_print_result({
|
||||||
"runs": list(list_module_installer_runs(runtime_dir=runtime_dir)),
|
"runs": list(list_module_installer_runs(runtime_dir=runtime_dir)),
|
||||||
@@ -206,6 +266,11 @@ def main() -> int:
|
|||||||
_print_result(module_installer_lock_status(runtime_dir=runtime_dir), output_format=args.format)
|
_print_result(module_installer_lock_status(runtime_dir=runtime_dir), output_format=args.format)
|
||||||
return 0
|
return 0
|
||||||
if args.rollback:
|
if args.rollback:
|
||||||
|
return _rollback_command(args=args, runtime_dir=runtime_dir)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _rollback_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
|
||||||
result = rollback_module_install_run(
|
result = rollback_module_install_run(
|
||||||
run_id=args.rollback,
|
run_id=args.rollback,
|
||||||
runtime_dir=runtime_dir,
|
runtime_dir=runtime_dir,
|
||||||
@@ -217,6 +282,8 @@ def main() -> int:
|
|||||||
_print_result(result.as_dict(), output_format=args.format)
|
_print_result(result.as_dict(), output_format=args.format)
|
||||||
return 0 if result.return_code == 0 else 1
|
return 0 if result.return_code == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_install_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
|
||||||
configure_database(str(args.database_url))
|
configure_database(str(args.database_url))
|
||||||
available = available_module_manifests(ignore_load_errors=True)
|
available = available_module_manifests(ignore_load_errors=True)
|
||||||
with get_database().session() as session:
|
with get_database().session() as session:
|
||||||
@@ -224,6 +291,21 @@ def main() -> int:
|
|||||||
desired = saved_desired_enabled_modules(session, configured)
|
desired = saved_desired_enabled_modules(session, configured)
|
||||||
plan = saved_module_install_plan(session)
|
plan = saved_module_install_plan(session)
|
||||||
if args.supervise:
|
if args.supervise:
|
||||||
|
return _supervise_install_command(args=args, runtime_dir=runtime_dir, session=session, available=available, desired=desired, plan=plan)
|
||||||
|
if args.apply or args.dry_run:
|
||||||
|
return _apply_install_command(args=args, runtime_dir=runtime_dir, session=session, available=available, desired=desired, plan=plan)
|
||||||
|
return _preflight_install_command(args=args, runtime_dir=runtime_dir, session=session, available=available, desired=desired, plan=plan)
|
||||||
|
|
||||||
|
|
||||||
|
def _supervise_install_command(
|
||||||
|
*,
|
||||||
|
args: argparse.Namespace,
|
||||||
|
runtime_dir: Path,
|
||||||
|
session: object,
|
||||||
|
available: object,
|
||||||
|
desired: object,
|
||||||
|
plan: object,
|
||||||
|
) -> int:
|
||||||
result = supervise_module_install_plan(
|
result = supervise_module_install_plan(
|
||||||
session=session,
|
session=session,
|
||||||
plan=plan,
|
plan=plan,
|
||||||
@@ -248,7 +330,17 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
_print_result(result.as_dict(), output_format=args.format)
|
_print_result(result.as_dict(), output_format=args.format)
|
||||||
return 0 if result.return_code == 0 else 1
|
return 0 if result.return_code == 0 else 1
|
||||||
if args.apply or args.dry_run:
|
|
||||||
|
|
||||||
|
def _apply_install_command(
|
||||||
|
*,
|
||||||
|
args: argparse.Namespace,
|
||||||
|
runtime_dir: Path,
|
||||||
|
session: object,
|
||||||
|
available: object,
|
||||||
|
desired: object,
|
||||||
|
plan: object,
|
||||||
|
) -> int:
|
||||||
result = run_module_install_plan(
|
result = run_module_install_plan(
|
||||||
session=session,
|
session=session,
|
||||||
plan=plan,
|
plan=plan,
|
||||||
@@ -271,6 +363,16 @@ def main() -> int:
|
|||||||
_print_result(result.as_dict(), output_format=args.format)
|
_print_result(result.as_dict(), output_format=args.format)
|
||||||
return 0 if result.return_code == 0 else 1
|
return 0 if result.return_code == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
def _preflight_install_command(
|
||||||
|
*,
|
||||||
|
args: argparse.Namespace,
|
||||||
|
runtime_dir: Path,
|
||||||
|
session: object,
|
||||||
|
available: object,
|
||||||
|
desired: object,
|
||||||
|
plan: object,
|
||||||
|
) -> int:
|
||||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
from govoplan_core.core.maintenance import saved_maintenance_mode
|
||||||
|
|
||||||
maintenance = saved_maintenance_mode(session)
|
maintenance = saved_maintenance_mode(session)
|
||||||
@@ -286,9 +388,6 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
_print_preflight(preflight.as_dict(), output_format=args.format)
|
_print_preflight(preflight.as_dict(), output_format=args.format)
|
||||||
return 0 if preflight.allowed else 1
|
return 0 if preflight.allowed else 1
|
||||||
except ModuleInstallerError as exc:
|
|
||||||
print(f"error: {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
def _default_webui_root() -> Path:
|
def _default_webui_root() -> Path:
|
||||||
@@ -355,6 +454,7 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
|
|||||||
request_id = str(request["request_id"])
|
request_id = str(request["request_id"])
|
||||||
options = request.get("options") if isinstance(request.get("options"), dict) else {}
|
options = request.get("options") if isinstance(request.get("options"), dict) else {}
|
||||||
configure_database(str(args.database_url))
|
configure_database(str(args.database_url))
|
||||||
|
_emit_installer_daemon_notification(request, event_kind="module_installer.request.running")
|
||||||
try:
|
try:
|
||||||
available = available_module_manifests(ignore_load_errors=True)
|
available = available_module_manifests(ignore_load_errors=True)
|
||||||
with get_database().session() as session:
|
with get_database().session() as session:
|
||||||
@@ -384,7 +484,7 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
|
|||||||
health_interval_seconds=_float_option(options, "health_interval_seconds", args.health_interval_seconds),
|
health_interval_seconds=_float_option(options, "health_interval_seconds", args.health_interval_seconds),
|
||||||
request_context=_request_context(request),
|
request_context=_request_context(request),
|
||||||
)
|
)
|
||||||
update_module_installer_request(
|
updated_request = update_module_installer_request(
|
||||||
runtime_dir=runtime_dir,
|
runtime_dir=runtime_dir,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
patch={
|
patch={
|
||||||
@@ -393,8 +493,12 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
|
|||||||
"result": result.as_dict(),
|
"result": result.as_dict(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
_emit_installer_daemon_notification(
|
||||||
|
updated_request,
|
||||||
|
event_kind="module_installer.request.completed" if result.return_code == 0 else "module_installer.request.failed",
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
update_module_installer_request(
|
updated_request = update_module_installer_request(
|
||||||
runtime_dir=runtime_dir,
|
runtime_dir=runtime_dir,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
patch={
|
patch={
|
||||||
@@ -403,6 +507,34 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
|
|||||||
"error": str(exc),
|
"error": str(exc),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
_emit_installer_daemon_notification(updated_request, event_kind="module_installer.request.failed")
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_installer_daemon_notification(request: dict[str, object], *, event_kind: str) -> None:
|
||||||
|
tenant_id = str(request.get("tenant_id") or "")
|
||||||
|
if not tenant_id:
|
||||||
|
return
|
||||||
|
registry = build_runtime_notification_registry(settings)
|
||||||
|
if registry is None:
|
||||||
|
return
|
||||||
|
status_value = str(request.get("status") or event_kind.rsplit(".", 1)[-1])
|
||||||
|
try:
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
emitted = emit_module_installer_notification(
|
||||||
|
session=session,
|
||||||
|
registry=registry,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
request=request,
|
||||||
|
event_kind=event_kind,
|
||||||
|
subject=installer_notification_subject(event_kind, request),
|
||||||
|
body_text=installer_notification_body(event_kind, request),
|
||||||
|
recipient_id=str(request.get("requested_by") or "") or None,
|
||||||
|
priority=installer_notification_priority(status_value),
|
||||||
|
)
|
||||||
|
if emitted:
|
||||||
|
session.commit()
|
||||||
|
except Exception: # noqa: BLE001 - notification bridge must not block installer work.
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def _request_options_from_args(args: argparse.Namespace) -> dict[str, object]:
|
def _request_options_from_args(args: argparse.Namespace) -> dict[str, object]:
|
||||||
@@ -427,6 +559,7 @@ def _request_context(request: dict[str, object]) -> dict[str, object]:
|
|||||||
context: dict[str, object] = {
|
context: dict[str, object] = {
|
||||||
"request_id": request.get("request_id"),
|
"request_id": request.get("request_id"),
|
||||||
"requested_by": request.get("requested_by"),
|
"requested_by": request.get("requested_by"),
|
||||||
|
"tenant_id": request.get("tenant_id"),
|
||||||
}
|
}
|
||||||
if request.get("retry_of"):
|
if request.get("retry_of"):
|
||||||
context["retry_of"] = request["retry_of"]
|
context["retry_of"] = request["retry_of"]
|
||||||
|
|||||||
@@ -28,6 +28,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 +51,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 +61,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",
|
||||||
|
|||||||
183
src/govoplan_core/core/automation.py
Normal file
183
src/govoplan_core/core/automation.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||||
|
)
|
||||||
|
|
||||||
|
AutomationInvocationKind = Literal[
|
||||||
|
"manual",
|
||||||
|
"api",
|
||||||
|
"schedule",
|
||||||
|
"event",
|
||||||
|
"workflow",
|
||||||
|
"dependency",
|
||||||
|
"retry",
|
||||||
|
"backfill",
|
||||||
|
]
|
||||||
|
AutomationSubjectKind = Literal["delegated_user", "service_account"]
|
||||||
|
AUTOMATION_PRINCIPAL_CONTRACT_VERSION = "1"
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER",
|
||||||
|
"AUTOMATION_PRINCIPAL_CONTRACT_VERSION",
|
||||||
|
"AutomationInvocation",
|
||||||
|
"AutomationInvocationKind",
|
||||||
|
"AutomationPrincipalProvider",
|
||||||
|
"AutomationPrincipalRequest",
|
||||||
|
"AutomationPrincipalResolution",
|
||||||
|
"AutomationSubjectKind",
|
||||||
|
"automation_principal_provider",
|
||||||
|
]
|
||||||
98
src/govoplan_core/core/calendar.py
Normal file
98
src/govoplan_core/core/calendar.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_CALENDAR_SCHEDULING = "calendar.scheduling"
|
||||||
|
CAPABILITY_CALENDAR_OUTBOX = "calendar.outbox"
|
||||||
|
CALENDAR_AVAILABILITY_READ_SCOPE = "calendar:availability:read"
|
||||||
|
CALENDAR_EVENT_WRITE_SCOPE = "calendar:event:write"
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarCapabilityError(ValueError):
|
||||||
|
"""Stable error raised by Calendar capability implementations."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CalendarEventRequest:
|
||||||
|
summary: str
|
||||||
|
start_at: datetime
|
||||||
|
calendar_id: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
location: str | None = None
|
||||||
|
status: str = "CONFIRMED"
|
||||||
|
transparency: str = "OPAQUE"
|
||||||
|
classification: str = "PUBLIC"
|
||||||
|
end_at: datetime | None = None
|
||||||
|
timezone: str | None = None
|
||||||
|
attendees: tuple[Mapping[str, object], ...] = ()
|
||||||
|
categories: tuple[str, ...] = ()
|
||||||
|
related_to: tuple[Mapping[str, object], ...] = ()
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CalendarEventRef:
|
||||||
|
id: str
|
||||||
|
calendar_id: str
|
||||||
|
uid: str
|
||||||
|
external_state: str = "local"
|
||||||
|
outbox_operation_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class CalendarSchedulingProvider(Protocol):
|
||||||
|
def list_freebusy(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
start_at: datetime,
|
||||||
|
end_at: datetime,
|
||||||
|
calendar_ids: Sequence[str] | None = None,
|
||||||
|
) -> Sequence[Mapping[str, object]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def create_event(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str | None,
|
||||||
|
request: CalendarEventRequest,
|
||||||
|
) -> CalendarEventRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class CalendarOutboxProvider(Protocol):
|
||||||
|
"""Stable worker boundary for Calendar-owned external side effects."""
|
||||||
|
|
||||||
|
def dispatch_due(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def calendar_scheduling_provider(registry: object | None) -> CalendarSchedulingProvider | None:
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_CALENDAR_SCHEDULING):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_CALENDAR_SCHEDULING)
|
||||||
|
return capability if isinstance(capability, CalendarSchedulingProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def calendar_outbox_provider(registry: object | None) -> CalendarOutboxProvider | None:
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_CALENDAR_OUTBOX):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_CALENDAR_OUTBOX)
|
||||||
|
return capability if isinstance(capability, CalendarOutboxProvider) else None
|
||||||
@@ -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,
|
||||||
|
|||||||
559
src/govoplan_core/core/concurrency.py
Normal file
559
src/govoplan_core/core/concurrency.py
Normal file
@@ -0,0 +1,559 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Callable, Iterable, Mapping, Sequence
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
||||||
|
class ConcurrencyError(RuntimeError):
|
||||||
|
"""Base class for mutation precondition and compare-and-set failures."""
|
||||||
|
|
||||||
|
|
||||||
|
class MissingPreconditionError(ConcurrencyError):
|
||||||
|
def __init__(self, *, resource_type: str, resource_id: str) -> None:
|
||||||
|
self.resource_type = resource_type
|
||||||
|
self.resource_id = resource_id
|
||||||
|
super().__init__("A strong If-Match precondition is required for this mutation.")
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"code": "precondition_required",
|
||||||
|
"resource": {
|
||||||
|
"type": self.resource_type,
|
||||||
|
"id": self.resource_id,
|
||||||
|
},
|
||||||
|
"retryable": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RevisionConflictError(ConcurrencyError):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
current_revision: int,
|
||||||
|
submitted_base_revision: int,
|
||||||
|
refresh_path: str | None = None,
|
||||||
|
conflicts: Sequence["MergeConflict"] = (),
|
||||||
|
merge_candidate: Any = None,
|
||||||
|
current_etag: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.resource_type = resource_type
|
||||||
|
self.resource_id = resource_id
|
||||||
|
self.current_revision = int(current_revision)
|
||||||
|
self.submitted_base_revision = int(submitted_base_revision)
|
||||||
|
self.refresh_path = refresh_path
|
||||||
|
self.conflicts = tuple(conflicts)
|
||||||
|
self.merge_candidate = merge_candidate
|
||||||
|
self.current_etag = current_etag
|
||||||
|
super().__init__(
|
||||||
|
f"{resource_type} {resource_id} changed from revision "
|
||||||
|
f"{submitted_base_revision} to {current_revision}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def safe_merge_available(self) -> bool:
|
||||||
|
return self.merge_candidate is not None and not self.conflicts
|
||||||
|
|
||||||
|
def as_dict(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
include_values: bool = False,
|
||||||
|
include_merge_candidate: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"code": "revision_conflict",
|
||||||
|
"resource": {
|
||||||
|
"type": self.resource_type,
|
||||||
|
"id": self.resource_id,
|
||||||
|
},
|
||||||
|
"current_revision": self.current_revision,
|
||||||
|
"submitted_base_revision": self.submitted_base_revision,
|
||||||
|
"retryable": True,
|
||||||
|
"safe_merge_available": self.safe_merge_available,
|
||||||
|
"conflicts": [
|
||||||
|
conflict.as_dict(include_values=include_values)
|
||||||
|
for conflict in self.conflicts[:100]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if self.refresh_path:
|
||||||
|
result["refresh_path"] = self.refresh_path
|
||||||
|
if self.current_etag:
|
||||||
|
result["current_etag"] = self.current_etag
|
||||||
|
if include_merge_candidate and self.safe_merge_available:
|
||||||
|
result["merge_candidate"] = copy.deepcopy(self.merge_candidate)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class MergeConflict:
|
||||||
|
path: str
|
||||||
|
kind: str
|
||||||
|
base_value: Any = None
|
||||||
|
local_value: Any = None
|
||||||
|
current_value: Any = None
|
||||||
|
|
||||||
|
def as_dict(self, *, include_values: bool = False) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"path": self.path or "/",
|
||||||
|
"kind": self.kind,
|
||||||
|
}
|
||||||
|
if include_values:
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
"base_value": _bounded_json_value(self.base_value),
|
||||||
|
"local_value": _bounded_json_value(self.local_value),
|
||||||
|
"current_value": _bounded_json_value(self.current_value),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ThreeWayMergeResult:
|
||||||
|
value: Any
|
||||||
|
conflicts: list[MergeConflict] = field(default_factory=list)
|
||||||
|
applied_paths: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def merged(self) -> bool:
|
||||||
|
return not self.conflicts
|
||||||
|
|
||||||
|
|
||||||
|
ProtectedPath = str | Callable[[str], bool]
|
||||||
|
|
||||||
|
|
||||||
|
def strong_resource_etag(
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
revision: int,
|
||||||
|
) -> str:
|
||||||
|
"""Return an opaque strong ETag for one mutable aggregate revision."""
|
||||||
|
|
||||||
|
normalized_revision = int(revision)
|
||||||
|
if normalized_revision < 1:
|
||||||
|
raise ValueError("Resource revisions must be positive integers")
|
||||||
|
digest = hashlib.sha256(
|
||||||
|
"\x00".join(
|
||||||
|
(
|
||||||
|
"govoplan-strong-revision-v1",
|
||||||
|
str(resource_type),
|
||||||
|
str(resource_id),
|
||||||
|
str(normalized_revision),
|
||||||
|
)
|
||||||
|
).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
return f'"sha256-{digest}"'
|
||||||
|
|
||||||
|
|
||||||
|
def if_match_matches(header_value: str | None, expected_etag: str) -> bool:
|
||||||
|
"""Apply strong comparison semantics to an If-Match header."""
|
||||||
|
|
||||||
|
if not header_value:
|
||||||
|
return False
|
||||||
|
for raw_candidate in header_value.split(","):
|
||||||
|
candidate = raw_candidate.strip()
|
||||||
|
if candidate == "*":
|
||||||
|
return True
|
||||||
|
if candidate.startswith("W/"):
|
||||||
|
continue
|
||||||
|
if candidate == expected_etag:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def assert_revision_precondition(
|
||||||
|
if_match: str | None,
|
||||||
|
*,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
submitted_base_revision: int,
|
||||||
|
) -> None:
|
||||||
|
if not if_match:
|
||||||
|
raise MissingPreconditionError(
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
)
|
||||||
|
submitted_etag = strong_resource_etag(
|
||||||
|
resource_type,
|
||||||
|
resource_id,
|
||||||
|
submitted_base_revision,
|
||||||
|
)
|
||||||
|
if not if_match_matches(if_match, submitted_etag):
|
||||||
|
raise ConcurrencyError(
|
||||||
|
"If-Match does not identify the submitted base revision."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def claim_revision(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
model: type[Any],
|
||||||
|
filters: Iterable[Any],
|
||||||
|
revision_attribute: str,
|
||||||
|
expected_revision: int,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
refresh_path: str | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Atomically claim the next revision in the caller's transaction."""
|
||||||
|
|
||||||
|
revision_column = getattr(model, revision_attribute)
|
||||||
|
expected = int(expected_revision)
|
||||||
|
normalized_filters = tuple(filters)
|
||||||
|
query = session.query(model).filter(*normalized_filters)
|
||||||
|
updated = query.filter(revision_column == expected).update(
|
||||||
|
{revision_column: revision_column + 1},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
if updated == 1:
|
||||||
|
session.flush()
|
||||||
|
return expected + 1
|
||||||
|
|
||||||
|
current = (
|
||||||
|
session.query(revision_column)
|
||||||
|
.filter(*normalized_filters)
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
if current is None:
|
||||||
|
raise LookupError(f"{resource_type} {resource_id} was not found")
|
||||||
|
raise RevisionConflictError(
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
current_revision=int(current),
|
||||||
|
submitted_base_revision=expected,
|
||||||
|
refresh_path=refresh_path,
|
||||||
|
current_etag=strong_resource_etag(
|
||||||
|
resource_type,
|
||||||
|
resource_id,
|
||||||
|
int(current),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def three_way_merge(
|
||||||
|
base: Any,
|
||||||
|
local: Any,
|
||||||
|
current: Any,
|
||||||
|
*,
|
||||||
|
protected_paths: Sequence[ProtectedPath] = (),
|
||||||
|
stable_id_fields: Sequence[str] = ("id",),
|
||||||
|
) -> ThreeWayMergeResult:
|
||||||
|
"""Conservatively merge local changes onto a concurrently changed value."""
|
||||||
|
|
||||||
|
return _merge_value(
|
||||||
|
copy.deepcopy(base),
|
||||||
|
copy.deepcopy(local),
|
||||||
|
copy.deepcopy(current),
|
||||||
|
path="",
|
||||||
|
protected_paths=tuple(protected_paths),
|
||||||
|
stable_id_fields=tuple(stable_id_fields),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_value(
|
||||||
|
base: Any,
|
||||||
|
local: Any,
|
||||||
|
current: Any,
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
protected_paths: Sequence[ProtectedPath],
|
||||||
|
stable_id_fields: Sequence[str],
|
||||||
|
) -> ThreeWayMergeResult:
|
||||||
|
local_changed = local != base
|
||||||
|
current_changed = current != base
|
||||||
|
if not local_changed:
|
||||||
|
return ThreeWayMergeResult(value=current)
|
||||||
|
if local == current:
|
||||||
|
return ThreeWayMergeResult(value=current)
|
||||||
|
if _is_protected(path, protected_paths):
|
||||||
|
return _conflict(path, "protected_path", base, local, current)
|
||||||
|
if not current_changed:
|
||||||
|
return ThreeWayMergeResult(
|
||||||
|
value=local,
|
||||||
|
applied_paths=[path or "/"],
|
||||||
|
)
|
||||||
|
if isinstance(base, Mapping) and isinstance(local, Mapping) and isinstance(current, Mapping):
|
||||||
|
return _merge_mapping(
|
||||||
|
base,
|
||||||
|
local,
|
||||||
|
current,
|
||||||
|
path=path,
|
||||||
|
protected_paths=protected_paths,
|
||||||
|
stable_id_fields=stable_id_fields,
|
||||||
|
)
|
||||||
|
if isinstance(base, list) and isinstance(local, list) and isinstance(current, list):
|
||||||
|
return _merge_list(
|
||||||
|
base,
|
||||||
|
local,
|
||||||
|
current,
|
||||||
|
path=path,
|
||||||
|
protected_paths=protected_paths,
|
||||||
|
stable_id_fields=stable_id_fields,
|
||||||
|
)
|
||||||
|
return _conflict(path, "same_path_changed", base, local, current)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_mapping(
|
||||||
|
base: Mapping[str, Any],
|
||||||
|
local: Mapping[str, Any],
|
||||||
|
current: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
protected_paths: Sequence[ProtectedPath],
|
||||||
|
stable_id_fields: Sequence[str],
|
||||||
|
) -> ThreeWayMergeResult:
|
||||||
|
missing = object()
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
conflicts: list[MergeConflict] = []
|
||||||
|
applied_paths: list[str] = []
|
||||||
|
keys = list(dict.fromkeys((*current.keys(), *local.keys(), *base.keys())))
|
||||||
|
for key in keys:
|
||||||
|
child_path = _join_path(path, str(key))
|
||||||
|
base_value = base.get(key, missing)
|
||||||
|
local_value = local.get(key, missing)
|
||||||
|
current_value = current.get(key, missing)
|
||||||
|
merged = _merge_presence(
|
||||||
|
base_value,
|
||||||
|
local_value,
|
||||||
|
current_value,
|
||||||
|
missing=missing,
|
||||||
|
path=child_path,
|
||||||
|
protected_paths=protected_paths,
|
||||||
|
stable_id_fields=stable_id_fields,
|
||||||
|
)
|
||||||
|
conflicts.extend(merged.conflicts)
|
||||||
|
applied_paths.extend(merged.applied_paths)
|
||||||
|
if merged.value is not missing:
|
||||||
|
result[str(key)] = merged.value
|
||||||
|
return ThreeWayMergeResult(
|
||||||
|
value=result,
|
||||||
|
conflicts=conflicts,
|
||||||
|
applied_paths=applied_paths,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_presence(
|
||||||
|
base: Any,
|
||||||
|
local: Any,
|
||||||
|
current: Any,
|
||||||
|
*,
|
||||||
|
missing: object,
|
||||||
|
path: str,
|
||||||
|
protected_paths: Sequence[ProtectedPath],
|
||||||
|
stable_id_fields: Sequence[str],
|
||||||
|
) -> ThreeWayMergeResult:
|
||||||
|
if local is missing and current is missing:
|
||||||
|
return ThreeWayMergeResult(value=missing)
|
||||||
|
if base is missing:
|
||||||
|
if local is missing:
|
||||||
|
return ThreeWayMergeResult(value=current)
|
||||||
|
if current is missing:
|
||||||
|
if _is_protected(path, protected_paths):
|
||||||
|
return _conflict(path, "protected_path", None, local, None)
|
||||||
|
return ThreeWayMergeResult(value=local, applied_paths=[path])
|
||||||
|
if local == current:
|
||||||
|
return ThreeWayMergeResult(value=current)
|
||||||
|
return _conflict(path, "concurrent_add", None, local, current)
|
||||||
|
if local is missing:
|
||||||
|
if current == base:
|
||||||
|
if _is_protected(path, protected_paths):
|
||||||
|
return _conflict(path, "protected_path", base, None, current)
|
||||||
|
return ThreeWayMergeResult(value=missing, applied_paths=[path])
|
||||||
|
return _conflict(path, "delete_vs_edit", base, None, current)
|
||||||
|
if current is missing:
|
||||||
|
if local == base:
|
||||||
|
return ThreeWayMergeResult(value=missing)
|
||||||
|
return _conflict(path, "edit_vs_delete", base, local, None)
|
||||||
|
return _merge_value(
|
||||||
|
base,
|
||||||
|
local,
|
||||||
|
current,
|
||||||
|
path=path,
|
||||||
|
protected_paths=protected_paths,
|
||||||
|
stable_id_fields=stable_id_fields,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_list(
|
||||||
|
base: list[Any],
|
||||||
|
local: list[Any],
|
||||||
|
current: list[Any],
|
||||||
|
*,
|
||||||
|
path: str,
|
||||||
|
protected_paths: Sequence[ProtectedPath],
|
||||||
|
stable_id_fields: Sequence[str],
|
||||||
|
) -> ThreeWayMergeResult:
|
||||||
|
identity_field = _stable_identity_field(
|
||||||
|
(base, local, current),
|
||||||
|
stable_id_fields,
|
||||||
|
)
|
||||||
|
if identity_field is None:
|
||||||
|
return _conflict(path, "unkeyed_collection", base, local, current)
|
||||||
|
|
||||||
|
base_by_id = {str(item[identity_field]): item for item in base}
|
||||||
|
local_by_id = {str(item[identity_field]): item for item in local}
|
||||||
|
current_by_id = {str(item[identity_field]): item for item in current}
|
||||||
|
base_order = list(base_by_id)
|
||||||
|
local_order = list(local_by_id)
|
||||||
|
current_order = list(current_by_id)
|
||||||
|
|
||||||
|
local_reordered = _common_order(local_order, base_order) != _common_order(
|
||||||
|
base_order,
|
||||||
|
local_order,
|
||||||
|
)
|
||||||
|
current_reordered = _common_order(current_order, base_order) != _common_order(
|
||||||
|
base_order,
|
||||||
|
current_order,
|
||||||
|
)
|
||||||
|
if local_reordered and current_reordered and local_order != current_order:
|
||||||
|
return _conflict(path, "collection_reorder", base_order, local_order, current_order)
|
||||||
|
|
||||||
|
result_by_id: dict[str, Any] = {}
|
||||||
|
conflicts: list[MergeConflict] = []
|
||||||
|
applied_paths: list[str] = []
|
||||||
|
identities = list(dict.fromkeys((*current_order, *local_order, *base_order)))
|
||||||
|
missing = object()
|
||||||
|
for identity in identities:
|
||||||
|
item_path = _join_path(path, f"{identity_field}={identity}")
|
||||||
|
merged = _merge_presence(
|
||||||
|
base_by_id.get(identity, missing),
|
||||||
|
local_by_id.get(identity, missing),
|
||||||
|
current_by_id.get(identity, missing),
|
||||||
|
missing=missing,
|
||||||
|
path=item_path,
|
||||||
|
protected_paths=protected_paths,
|
||||||
|
stable_id_fields=stable_id_fields,
|
||||||
|
)
|
||||||
|
conflicts.extend(merged.conflicts)
|
||||||
|
applied_paths.extend(merged.applied_paths)
|
||||||
|
if merged.value is not missing:
|
||||||
|
result_by_id[identity] = merged.value
|
||||||
|
|
||||||
|
order_source = local_order if local_reordered and not current_reordered else current_order
|
||||||
|
merged_order = [identity for identity in order_source if identity in result_by_id]
|
||||||
|
for identity in identities:
|
||||||
|
if identity in result_by_id and identity not in merged_order:
|
||||||
|
merged_order.append(identity)
|
||||||
|
return ThreeWayMergeResult(
|
||||||
|
value=[result_by_id[identity] for identity in merged_order],
|
||||||
|
conflicts=conflicts,
|
||||||
|
applied_paths=applied_paths,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stable_identity_field(
|
||||||
|
values: Sequence[list[Any]],
|
||||||
|
candidates: Sequence[str],
|
||||||
|
) -> str | None:
|
||||||
|
all_items = [item for value in values for item in value]
|
||||||
|
if not all_items or not all(isinstance(item, Mapping) for item in all_items):
|
||||||
|
return None
|
||||||
|
for candidate in candidates:
|
||||||
|
valid = True
|
||||||
|
for value in values:
|
||||||
|
identities = [
|
||||||
|
str(item.get(candidate, "")).strip()
|
||||||
|
for item in value
|
||||||
|
if isinstance(item, Mapping)
|
||||||
|
]
|
||||||
|
if any(not identity for identity in identities) or len(identities) != len(
|
||||||
|
set(identities)
|
||||||
|
):
|
||||||
|
valid = False
|
||||||
|
break
|
||||||
|
if valid:
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _common_order(left: Sequence[str], right: Sequence[str]) -> list[str]:
|
||||||
|
right_set = set(right)
|
||||||
|
return [item for item in left if item in right_set]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_protected(path: str, protected_paths: Sequence[ProtectedPath]) -> bool:
|
||||||
|
normalized = path or "/"
|
||||||
|
for protected in protected_paths:
|
||||||
|
if callable(protected):
|
||||||
|
if protected(normalized):
|
||||||
|
return True
|
||||||
|
continue
|
||||||
|
prefix = protected.rstrip("/") or "/"
|
||||||
|
if normalized == prefix or normalized.startswith(f"{prefix}/"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _join_path(parent: str, segment: str) -> str:
|
||||||
|
escaped = segment.replace("~", "~0").replace("/", "~1")
|
||||||
|
return f"{parent}/{escaped}" if parent else f"/{escaped}"
|
||||||
|
|
||||||
|
|
||||||
|
def _conflict(
|
||||||
|
path: str,
|
||||||
|
kind: str,
|
||||||
|
base: Any,
|
||||||
|
local: Any,
|
||||||
|
current: Any,
|
||||||
|
) -> ThreeWayMergeResult:
|
||||||
|
return ThreeWayMergeResult(
|
||||||
|
value=current,
|
||||||
|
conflicts=[
|
||||||
|
MergeConflict(
|
||||||
|
path=path or "/",
|
||||||
|
kind=kind,
|
||||||
|
base_value=base,
|
||||||
|
local_value=local,
|
||||||
|
current_value=current,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_json_value(value: Any, *, depth: int = 0) -> Any:
|
||||||
|
if depth >= 4:
|
||||||
|
return {"summary": type(value).__name__}
|
||||||
|
if value is None or isinstance(value, (bool, int, float)):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value[:500]
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
result = {
|
||||||
|
str(key)[:100]: _bounded_json_value(item, depth=depth + 1)
|
||||||
|
for key, item in list(value.items())[:20]
|
||||||
|
}
|
||||||
|
if len(value) > 20:
|
||||||
|
result["_truncated_items"] = len(value) - 20
|
||||||
|
return result
|
||||||
|
if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray)):
|
||||||
|
result = [
|
||||||
|
_bounded_json_value(item, depth=depth + 1)
|
||||||
|
for item in list(value)[:20]
|
||||||
|
]
|
||||||
|
if len(value) > 20:
|
||||||
|
result.append({"_truncated_items": len(value) - 20})
|
||||||
|
return result
|
||||||
|
try:
|
||||||
|
return json.loads(json.dumps(value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {"summary": type(value).__name__}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ConcurrencyError",
|
||||||
|
"MergeConflict",
|
||||||
|
"MissingPreconditionError",
|
||||||
|
"RevisionConflictError",
|
||||||
|
"ThreeWayMergeResult",
|
||||||
|
"assert_revision_precondition",
|
||||||
|
"claim_revision",
|
||||||
|
"if_match_matches",
|
||||||
|
"strong_resource_etag",
|
||||||
|
"three_way_merge",
|
||||||
|
]
|
||||||
@@ -8,8 +8,6 @@ from pathlib import Path
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import Any, Literal, Protocol, runtime_checkable
|
from typing import Any, Literal, Protocol, runtime_checkable
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
from govoplan_core.core.module_package_catalog import (
|
from govoplan_core.core.module_package_catalog import (
|
||||||
_canonical_catalog_bytes,
|
_canonical_catalog_bytes,
|
||||||
@@ -23,6 +21,7 @@ from govoplan_core.core.module_package_catalog import (
|
|||||||
_load_private_key,
|
_load_private_key,
|
||||||
_parse_trusted_keys,
|
_parse_trusted_keys,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.security.http_fetch import fetch_http_text
|
||||||
|
|
||||||
|
|
||||||
CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider"
|
CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider"
|
||||||
@@ -31,6 +30,20 @@ DiagnosticSeverity = Literal["blocker", "warning", "info"]
|
|||||||
PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"]
|
PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _ConfigurationCatalogValidationState:
|
||||||
|
packages: tuple[dict[str, object], ...]
|
||||||
|
channel: str | None
|
||||||
|
sequence: int | None
|
||||||
|
generated_at: str | None
|
||||||
|
not_before: str | None
|
||||||
|
expires_at: str | None
|
||||||
|
signature_state: dict[str, object]
|
||||||
|
freshness: dict[str, object]
|
||||||
|
replay: dict[str, object]
|
||||||
|
read_state: dict[str, object]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ConfigurationModuleRequirement:
|
class ConfigurationModuleRequirement:
|
||||||
module_id: str
|
module_id: str
|
||||||
@@ -461,55 +474,148 @@ def validate_configuration_package_catalog(
|
|||||||
configured = source is not None and _catalog_source_exists(source)
|
configured = source is not None and _catalog_source_exists(source)
|
||||||
if source is not None and not _catalog_source_exists(source):
|
if source is not None and not _catalog_source_exists(source):
|
||||||
return _validation_result(source, configured=True, packages=(), error=f"Configuration package catalog does not exist: {source}")
|
return _validation_result(source, configured=True, packages=(), error=f"Configuration package catalog does not exist: {source}")
|
||||||
read_state = {"cache_used": False, "cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() else None}
|
read_state = _configuration_catalog_default_read_state()
|
||||||
try:
|
try:
|
||||||
payload, read_state = _read_catalog_payload_with_metadata(source)
|
|
||||||
packages = _normalize_catalog_packages(payload)
|
|
||||||
channel = _catalog_channel(payload)
|
|
||||||
sequence = _catalog_sequence(payload)
|
|
||||||
generated_at = _catalog_optional_text(payload, "generated_at")
|
|
||||||
not_before = _catalog_optional_text(payload, "not_before")
|
|
||||||
expires_at = _catalog_optional_text(payload, "expires_at")
|
|
||||||
effective_require_trusted = _configured_require_signature() if require_trusted is None else require_trusted
|
effective_require_trusted = _configured_require_signature() if require_trusted is None else require_trusted
|
||||||
effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels
|
effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels
|
||||||
effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys()
|
effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys()
|
||||||
signature_state = _catalog_signature_state(payload, trusted_keys=effective_trusted_keys)
|
state = _configuration_catalog_validation_state(source, trusted_keys=effective_trusted_keys)
|
||||||
freshness = _catalog_freshness_state(payload)
|
read_state = state.read_state
|
||||||
replay = _catalog_replay_state(channel=channel, sequence=sequence)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), error=str(exc))
|
return _validation_result(source, configured=configured, read_state=read_state, packages=(), error=str(exc))
|
||||||
if signature_state.get("fatal"):
|
policy_error = _configuration_catalog_policy_error(
|
||||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(signature_state["error"]))
|
source,
|
||||||
if effective_approved_channels and channel not in effective_approved_channels:
|
configured=configured,
|
||||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=f"Configuration package catalog channel {channel!r} is not approved. Approved channels: {', '.join(effective_approved_channels)}.")
|
state=state,
|
||||||
if effective_require_trusted and not signature_state["trusted"]:
|
require_trusted=effective_require_trusted,
|
||||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(signature_state["error"] or "Configuration package catalog must be signed by a trusted key."))
|
approved_channels=effective_approved_channels,
|
||||||
if not freshness["valid"]:
|
)
|
||||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(freshness["error"]))
|
if policy_error is not None:
|
||||||
if not replay["valid"]:
|
return policy_error
|
||||||
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(replay["error"]))
|
|
||||||
warnings = [str(item) for item in freshness.get("warnings", ()) if item]
|
|
||||||
warnings.extend(str(item) for item in replay.get("warnings", ()) if item)
|
|
||||||
if not signature_state["signed"]:
|
|
||||||
warnings.append("Configuration package catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
|
|
||||||
elif not signature_state["trusted"]:
|
|
||||||
warnings.append(str(signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
|
|
||||||
return _validation_result(
|
return _validation_result(
|
||||||
source,
|
source,
|
||||||
valid=True,
|
valid=True,
|
||||||
configured=configured,
|
configured=configured,
|
||||||
read_state=read_state,
|
read_state=state.read_state,
|
||||||
packages=packages,
|
packages=state.packages,
|
||||||
|
channel=state.channel,
|
||||||
|
sequence=state.sequence,
|
||||||
|
generated_at=state.generated_at,
|
||||||
|
not_before=state.not_before,
|
||||||
|
expires_at=state.expires_at,
|
||||||
|
signature_state=state.signature_state,
|
||||||
|
warnings=_configuration_catalog_warnings(state),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_catalog_validation_state(
|
||||||
|
source: Path | str | None,
|
||||||
|
*,
|
||||||
|
trusted_keys: dict[str, str],
|
||||||
|
) -> _ConfigurationCatalogValidationState:
|
||||||
|
payload, read_state = _read_catalog_payload_with_metadata(source)
|
||||||
|
channel = _catalog_channel(payload)
|
||||||
|
sequence = _catalog_sequence(payload)
|
||||||
|
return _ConfigurationCatalogValidationState(
|
||||||
|
packages=_normalize_catalog_packages(payload),
|
||||||
channel=channel,
|
channel=channel,
|
||||||
sequence=sequence,
|
sequence=sequence,
|
||||||
generated_at=generated_at,
|
generated_at=_catalog_optional_text(payload, "generated_at"),
|
||||||
not_before=not_before,
|
not_before=_catalog_optional_text(payload, "not_before"),
|
||||||
expires_at=expires_at,
|
expires_at=_catalog_optional_text(payload, "expires_at"),
|
||||||
signature_state=signature_state,
|
signature_state=_catalog_signature_state(payload, trusted_keys=trusted_keys),
|
||||||
warnings=warnings,
|
freshness=_catalog_freshness_state(payload),
|
||||||
|
replay=_catalog_replay_state(channel=channel, sequence=sequence),
|
||||||
|
read_state=read_state,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_catalog_policy_error(
|
||||||
|
source: Path | str | None,
|
||||||
|
*,
|
||||||
|
configured: bool,
|
||||||
|
state: _ConfigurationCatalogValidationState,
|
||||||
|
require_trusted: bool,
|
||||||
|
approved_channels: tuple[str, ...],
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
if state.signature_state.get("fatal"):
|
||||||
|
return _configuration_catalog_invalid_result(
|
||||||
|
source,
|
||||||
|
configured=configured,
|
||||||
|
state=state,
|
||||||
|
error=str(state.signature_state["error"]),
|
||||||
|
)
|
||||||
|
if approved_channels and state.channel not in approved_channels:
|
||||||
|
return _configuration_catalog_invalid_result(
|
||||||
|
source,
|
||||||
|
configured=configured,
|
||||||
|
state=state,
|
||||||
|
error=(
|
||||||
|
f"Configuration package catalog channel {state.channel!r} is not approved. "
|
||||||
|
f"Approved channels: {', '.join(approved_channels)}."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if require_trusted and not state.signature_state["trusted"]:
|
||||||
|
return _configuration_catalog_invalid_result(
|
||||||
|
source,
|
||||||
|
configured=configured,
|
||||||
|
state=state,
|
||||||
|
error=str(state.signature_state["error"] or "Configuration package catalog must be signed by a trusted key."),
|
||||||
|
)
|
||||||
|
if not state.freshness["valid"]:
|
||||||
|
return _configuration_catalog_invalid_result(
|
||||||
|
source,
|
||||||
|
configured=configured,
|
||||||
|
state=state,
|
||||||
|
error=str(state.freshness["error"]),
|
||||||
|
)
|
||||||
|
if not state.replay["valid"]:
|
||||||
|
return _configuration_catalog_invalid_result(
|
||||||
|
source,
|
||||||
|
configured=configured,
|
||||||
|
state=state,
|
||||||
|
error=str(state.replay["error"]),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_catalog_invalid_result(
|
||||||
|
source: Path | str | None,
|
||||||
|
*,
|
||||||
|
configured: bool,
|
||||||
|
state: _ConfigurationCatalogValidationState,
|
||||||
|
error: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return _validation_result(
|
||||||
|
source,
|
||||||
|
configured=configured,
|
||||||
|
read_state=state.read_state,
|
||||||
|
packages=(),
|
||||||
|
channel=state.channel,
|
||||||
|
sequence=state.sequence,
|
||||||
|
generated_at=state.generated_at,
|
||||||
|
not_before=state.not_before,
|
||||||
|
expires_at=state.expires_at,
|
||||||
|
signature_state=state.signature_state,
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_catalog_warnings(state: _ConfigurationCatalogValidationState) -> list[str]:
|
||||||
|
warnings = [str(item) for item in state.freshness.get("warnings", ()) if item]
|
||||||
|
warnings.extend(str(item) for item in state.replay.get("warnings", ()) if item)
|
||||||
|
if not state.signature_state["signed"]:
|
||||||
|
warnings.append("Configuration package catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
|
||||||
|
elif not state.signature_state["trusted"]:
|
||||||
|
warnings.append(str(state.signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_catalog_default_read_state() -> dict[str, object]:
|
||||||
|
cache_path = _configured_catalog_cache_path()
|
||||||
|
return {"cache_used": False, "cache_path": str(cache_path) if cache_path else None}
|
||||||
|
|
||||||
|
|
||||||
def sign_configuration_package_catalog(*, path: Path, key_id: str, private_key_path: Path, output_path: Path | None = None) -> Path:
|
def sign_configuration_package_catalog(*, path: Path, key_id: str, private_key_path: Path, output_path: Path | None = None) -> Path:
|
||||||
payload = _read_catalog_payload(path)
|
payload = _read_catalog_payload(path)
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
@@ -686,17 +792,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
|
|||||||
|
|
||||||
|
|
||||||
def _read_trusted_keys_url(url: str) -> str:
|
def _read_trusted_keys_url(url: str) -> str:
|
||||||
if not _is_http_url(url):
|
|
||||||
raise ValueError("Trusted configuration catalog key URL must use http:// or https://.")
|
|
||||||
cache_path = _configured_trusted_keys_cache_path()
|
cache_path = _configured_trusted_keys_cache_path()
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(url, timeout=15) as response:
|
body = fetch_http_text(url, timeout=15, label="Trusted configuration catalog key URL")
|
||||||
body = response.read().decode("utf-8")
|
|
||||||
if cache_path is not None:
|
if cache_path is not None:
|
||||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
cache_path.write_text(body, encoding="utf-8")
|
cache_path.write_text(body, encoding="utf-8")
|
||||||
return body
|
return body
|
||||||
except (OSError, urllib.error.URLError):
|
except OSError:
|
||||||
if cache_path is not None and cache_path.exists():
|
if cache_path is not None and cache_path.exists():
|
||||||
return cache_path.read_text(encoding="utf-8")
|
return cache_path.read_text(encoding="utf-8")
|
||||||
raise
|
raise
|
||||||
@@ -714,13 +817,12 @@ def _read_catalog_payload_with_metadata(source: Path | str | None) -> tuple[obje
|
|||||||
return {"packages": []}, metadata
|
return {"packages": []}, metadata
|
||||||
if isinstance(source, str) and _is_http_url(source):
|
if isinstance(source, str) and _is_http_url(source):
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(source, timeout=15) as response:
|
body = fetch_http_text(source, timeout=15, label="Configuration package catalog URL")
|
||||||
body = response.read().decode("utf-8")
|
|
||||||
if cache_path is not None:
|
if cache_path is not None:
|
||||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
cache_path.write_text(body, encoding="utf-8")
|
cache_path.write_text(body, encoding="utf-8")
|
||||||
return json.loads(body), metadata
|
return json.loads(body), metadata
|
||||||
except (OSError, urllib.error.URLError):
|
except OSError:
|
||||||
if cache_path is not None and cache_path.exists():
|
if cache_path is not None and cache_path.exists():
|
||||||
metadata["cache_used"] = True
|
metadata["cache_used"] = True
|
||||||
return json.loads(cache_path.read_text(encoding="utf-8")), metadata
|
return json.loads(cache_path.read_text(encoding="utf-8")), metadata
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from collections.abc import Mapping
|
from typing import Literal
|
||||||
from typing import Any, Literal
|
|
||||||
|
|
||||||
from govoplan_core.security.permissions import scopes_grant
|
from govoplan_core.security.permissions import scopes_grant
|
||||||
from govoplan_core.security.redaction import contains_plain_secret
|
from govoplan_core.security.redaction import contains_plain_secret
|
||||||
@@ -22,7 +21,7 @@ class ConfigurationFieldSafety:
|
|||||||
storage: str
|
storage: str
|
||||||
ui_managed: bool
|
ui_managed: bool
|
||||||
risk: ConfigurationRisk
|
risk: ConfigurationRisk
|
||||||
secret_handling: SecretHandling = "none"
|
secret_handling: SecretHandling = "none" # noqa: S105 - policy vocabulary.
|
||||||
required_scopes: tuple[str, ...] = ()
|
required_scopes: tuple[str, ...] = ()
|
||||||
dry_run_required: bool = False
|
dry_run_required: bool = False
|
||||||
validation_required: bool = True
|
validation_required: bool = True
|
||||||
@@ -69,7 +68,7 @@ class ConfigurationChangeSafetyPlan:
|
|||||||
maintenance_required: bool = False
|
maintenance_required: bool = False
|
||||||
maintenance_satisfied: bool = False
|
maintenance_satisfied: bool = False
|
||||||
rollback_history_required: bool = False
|
rollback_history_required: bool = False
|
||||||
secret_handling: SecretHandling = "none"
|
secret_handling: SecretHandling = "none" # noqa: S105 - policy vocabulary.
|
||||||
audit_event: str | None = None
|
audit_event: str | None = None
|
||||||
policy_explanation: str | None = None
|
policy_explanation: str | None = None
|
||||||
blockers: tuple[str, ...] = ()
|
blockers: tuple[str, ...] = ()
|
||||||
@@ -97,6 +96,16 @@ class ConfigurationChangeSafetyPlan:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _ConfigurationChangeSafetyState:
|
||||||
|
field: ConfigurationFieldSafety
|
||||||
|
missing_scopes: tuple[str, ...]
|
||||||
|
blockers: tuple[str, ...]
|
||||||
|
warnings: tuple[str, ...]
|
||||||
|
approval_required: bool
|
||||||
|
approval_satisfied: bool
|
||||||
|
|
||||||
|
|
||||||
_CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
_CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||||
ConfigurationFieldSafety(
|
ConfigurationFieldSafety(
|
||||||
key="module_management.desired_enabled",
|
key="module_management.desired_enabled",
|
||||||
@@ -230,7 +239,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
|||||||
storage="module_settings",
|
storage="module_settings",
|
||||||
ui_managed=True,
|
ui_managed=True,
|
||||||
risk="high",
|
risk="high",
|
||||||
secret_handling="reference_only",
|
secret_handling="reference_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
required_scopes=("mail_servers:manage_credentials",),
|
required_scopes=("mail_servers:manage_credentials",),
|
||||||
validation_required=True,
|
validation_required=True,
|
||||||
audit_event="mail_server_profile.credential_updated",
|
audit_event="mail_server_profile.credential_updated",
|
||||||
@@ -246,14 +255,14 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
|||||||
storage="module_settings",
|
storage="module_settings",
|
||||||
ui_managed=True,
|
ui_managed=True,
|
||||||
risk="high",
|
risk="high",
|
||||||
secret_handling="reference_only",
|
secret_handling="reference_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
required_scopes=("files:file:admin",),
|
required_scopes=("files:file:admin",),
|
||||||
dry_run_required=True,
|
dry_run_required=True,
|
||||||
policy_explanation_required=True,
|
policy_explanation_required=True,
|
||||||
audit_event="files.connector_profile.updated",
|
audit_event="files.connector_profile.updated",
|
||||||
two_person_approval_required=True,
|
two_person_approval_required=True,
|
||||||
rollback_history_required=True,
|
rollback_history_required=True,
|
||||||
notes="Connector endpoints are UI-manageable, but passwords/tokens remain env or secret refs.",
|
notes="Connector endpoints are UI-manageable. API-managed credentials are encrypted or use scoped secret refs; process-environment references remain deployment-owned.",
|
||||||
),
|
),
|
||||||
ConfigurationFieldSafety(
|
ConfigurationFieldSafety(
|
||||||
key="DATABASE_URL",
|
key="DATABASE_URL",
|
||||||
@@ -263,7 +272,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
|||||||
storage="environment",
|
storage="environment",
|
||||||
ui_managed=False,
|
ui_managed=False,
|
||||||
risk="destructive",
|
risk="destructive",
|
||||||
secret_handling="env_only",
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
maintenance_required=True,
|
maintenance_required=True,
|
||||||
notes="Database connectivity remains deployment-managed and must not be changed from the running UI.",
|
notes="Database connectivity remains deployment-managed and must not be changed from the running UI.",
|
||||||
),
|
),
|
||||||
@@ -275,11 +284,179 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
|||||||
storage="environment",
|
storage="environment",
|
||||||
ui_managed=False,
|
ui_managed=False,
|
||||||
risk="destructive",
|
risk="destructive",
|
||||||
secret_handling="env_only",
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
maintenance_required=True,
|
maintenance_required=True,
|
||||||
two_person_approval_required=True,
|
two_person_approval_required=True,
|
||||||
notes="Encryption roots remain out of band; UI may only report missing/rotated state.",
|
notes="Encryption roots remain out of band; UI may only report missing/rotated state.",
|
||||||
),
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
|
||||||
|
label="Private-network connector access",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="This deployment-wide egress boundary remains out of band and applies to every connector worker.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
|
||||||
|
label="Structured connector response limit",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="A deployment-wide memory-safety limit applied consistently by API and worker processes.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES",
|
||||||
|
label="Connector file-transfer limit",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="A deployment-wide hard ceiling; individual module upload policies may impose lower limits.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
|
||||||
|
label="Connector secret environment allowlist",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="destructive",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Exact environment names available only to deployment-owned connector profiles; tenant/API profiles cannot select process variables.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST",
|
||||||
|
label="Connector CA bundle allowlist",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Exact absolute CA bundle paths approved by the deployment and mounted consistently on every connector worker.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES",
|
||||||
|
label="HTTP request-body limit",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="A deployment-wide hard ceiling; endpoint-specific upload policies may impose lower limits.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="GOVOPLAN_HTTP_HSTS_SECONDS",
|
||||||
|
label="HTTP Strict Transport Security duration",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Deployment-managed browser transport policy; use only after the public service is HTTPS-only.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="AUTH_LOGIN_THROTTLE_ENABLED",
|
||||||
|
label="Interactive login throttling",
|
||||||
|
owner_module="access",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Disabling the deployment login throttle weakens protection against password guessing.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT",
|
||||||
|
label="Login failures per identity",
|
||||||
|
owner_module="access",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Shared through Redis when available, with a bounded process-local fallback.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="AUTH_LOGIN_THROTTLE_CLIENT_LIMIT",
|
||||||
|
label="Login failures per client",
|
||||||
|
owner_module="access",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Counts the direct peer after the deployment's trusted-proxy boundary is applied.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="AUTH_LOGIN_THROTTLE_WINDOW_SECONDS",
|
||||||
|
label="Login throttle window",
|
||||||
|
owner_module="access",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Fixed counter window shared by identity and direct-client limits.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS",
|
||||||
|
label="Login throttle Redis retry interval",
|
||||||
|
owner_module="access",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="medium",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Controls how quickly a worker retries the distributed counter after falling back locally.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="GOVOPLAN_TRUSTED_HOSTS",
|
||||||
|
label="Trusted HTTP hosts",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Host-header validation is deployment-managed and must cover every public API host.",
|
||||||
|
),
|
||||||
|
ConfigurationFieldSafety(
|
||||||
|
key="FORWARDED_ALLOW_IPS",
|
||||||
|
label="Trusted reverse proxy addresses",
|
||||||
|
owner_module="core",
|
||||||
|
scope="system",
|
||||||
|
storage="environment",
|
||||||
|
ui_managed=False,
|
||||||
|
risk="high",
|
||||||
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
|
maintenance_required=True,
|
||||||
|
notes="Uvicorn trusts forwarded client and scheme data only from this deployment-managed boundary.",
|
||||||
|
),
|
||||||
ConfigurationFieldSafety(
|
ConfigurationFieldSafety(
|
||||||
key="GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS",
|
key="GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS",
|
||||||
label="Module package catalog trusted keys",
|
label="Module package catalog trusted keys",
|
||||||
@@ -288,7 +465,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
|||||||
storage="environment",
|
storage="environment",
|
||||||
ui_managed=False,
|
ui_managed=False,
|
||||||
risk="high",
|
risk="high",
|
||||||
secret_handling="env_only",
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
notes="Trust roots are deployment-managed; UI can validate catalogs but should not edit key material.",
|
notes="Trust roots are deployment-managed; UI can validate catalogs but should not edit key material.",
|
||||||
),
|
),
|
||||||
ConfigurationFieldSafety(
|
ConfigurationFieldSafety(
|
||||||
@@ -299,7 +476,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
|||||||
storage="environment",
|
storage="environment",
|
||||||
ui_managed=False,
|
ui_managed=False,
|
||||||
risk="high",
|
risk="high",
|
||||||
secret_handling="env_only",
|
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||||
notes="Configuration package trust roots are deployment-managed.",
|
notes="Configuration package trust roots are deployment-managed.",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -339,6 +516,26 @@ def plan_configuration_change(
|
|||||||
) -> ConfigurationChangeSafetyPlan:
|
) -> ConfigurationChangeSafetyPlan:
|
||||||
field = classify_configuration_field(key)
|
field = classify_configuration_field(key)
|
||||||
if field is None:
|
if field is None:
|
||||||
|
return _unknown_configuration_plan(key)
|
||||||
|
if not include_env_only and not field.ui_managed:
|
||||||
|
return _deployment_managed_configuration_plan(key, field)
|
||||||
|
state = _configuration_change_safety_state(
|
||||||
|
field,
|
||||||
|
actor_scopes=actor_scopes,
|
||||||
|
value=value,
|
||||||
|
dry_run=dry_run,
|
||||||
|
maintenance_mode=maintenance_mode,
|
||||||
|
approval_count=approval_count,
|
||||||
|
)
|
||||||
|
return _configuration_change_safety_plan(
|
||||||
|
field,
|
||||||
|
state=state,
|
||||||
|
dry_run=dry_run,
|
||||||
|
maintenance_mode=maintenance_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unknown_configuration_plan(key: str) -> ConfigurationChangeSafetyPlan:
|
||||||
return ConfigurationChangeSafetyPlan(
|
return ConfigurationChangeSafetyPlan(
|
||||||
key=key,
|
key=key,
|
||||||
allowed=False,
|
allowed=False,
|
||||||
@@ -346,7 +543,9 @@ def plan_configuration_change(
|
|||||||
blockers=("unknown_configuration_field",),
|
blockers=("unknown_configuration_field",),
|
||||||
policy_explanation="This setting is not in the configuration safety catalog.",
|
policy_explanation="This setting is not in the configuration safety catalog.",
|
||||||
)
|
)
|
||||||
if not include_env_only and not field.ui_managed:
|
|
||||||
|
|
||||||
|
def _deployment_managed_configuration_plan(key: str, field: ConfigurationFieldSafety) -> ConfigurationChangeSafetyPlan:
|
||||||
return ConfigurationChangeSafetyPlan(
|
return ConfigurationChangeSafetyPlan(
|
||||||
key=key,
|
key=key,
|
||||||
allowed=False,
|
allowed=False,
|
||||||
@@ -356,6 +555,17 @@ def plan_configuration_change(
|
|||||||
blockers=("deployment_managed",),
|
blockers=("deployment_managed",),
|
||||||
policy_explanation="This setting is deployment-managed and cannot be edited through the UI.",
|
policy_explanation="This setting is deployment-managed and cannot be edited through the UI.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_change_safety_state(
|
||||||
|
field: ConfigurationFieldSafety,
|
||||||
|
*,
|
||||||
|
actor_scopes: tuple[str, ...] | list[str],
|
||||||
|
value: object,
|
||||||
|
dry_run: bool,
|
||||||
|
maintenance_mode: bool,
|
||||||
|
approval_count: int,
|
||||||
|
) -> _ConfigurationChangeSafetyState:
|
||||||
blockers: list[str] = []
|
blockers: list[str] = []
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
missing_scopes = tuple(scope for scope in field.required_scopes if not scopes_grant(actor_scopes, scope))
|
missing_scopes = tuple(scope for scope in field.required_scopes if not scopes_grant(actor_scopes, scope))
|
||||||
@@ -371,30 +581,47 @@ def plan_configuration_change(
|
|||||||
approval_satisfied = not approval_required or approval_count >= 2
|
approval_satisfied = not approval_required or approval_count >= 2
|
||||||
if approval_required and not approval_satisfied:
|
if approval_required and not approval_satisfied:
|
||||||
blockers.append("two_person_approval_required")
|
blockers.append("two_person_approval_required")
|
||||||
if field.secret_handling == "reference_only" and _contains_plain_secret(value):
|
if field.secret_handling == "reference_only" and _contains_plain_secret(value): # noqa: S105 # nosec B105 - policy vocabulary.
|
||||||
blockers.append("secret_reference_required")
|
blockers.append("secret_reference_required")
|
||||||
if field.secret_handling == "env_only" and value is not None:
|
if field.secret_handling == "env_only" and value is not None: # noqa: S105 # nosec B105 - policy vocabulary.
|
||||||
blockers.append("env_only_secret")
|
blockers.append("env_only_secret")
|
||||||
if field.rollback_history_required:
|
if field.rollback_history_required:
|
||||||
warnings.append("rollback_history_required")
|
warnings.append("rollback_history_required")
|
||||||
return ConfigurationChangeSafetyPlan(
|
return _ConfigurationChangeSafetyState(
|
||||||
key=field.key,
|
|
||||||
allowed=not blockers,
|
|
||||||
field=field,
|
field=field,
|
||||||
risk=field.risk,
|
|
||||||
missing_scopes=missing_scopes,
|
missing_scopes=missing_scopes,
|
||||||
dry_run_required=field.dry_run_required,
|
blockers=tuple(dict.fromkeys(blockers)),
|
||||||
dry_run_satisfied=not field.dry_run_required or dry_run,
|
warnings=tuple(dict.fromkeys(warnings)),
|
||||||
approval_required=approval_required,
|
approval_required=approval_required,
|
||||||
approval_satisfied=approval_satisfied,
|
approval_satisfied=approval_satisfied,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_change_safety_plan(
|
||||||
|
field: ConfigurationFieldSafety,
|
||||||
|
*,
|
||||||
|
state: _ConfigurationChangeSafetyState,
|
||||||
|
dry_run: bool,
|
||||||
|
maintenance_mode: bool,
|
||||||
|
) -> ConfigurationChangeSafetyPlan:
|
||||||
|
return ConfigurationChangeSafetyPlan(
|
||||||
|
key=field.key,
|
||||||
|
allowed=not state.blockers,
|
||||||
|
field=field,
|
||||||
|
risk=field.risk,
|
||||||
|
missing_scopes=state.missing_scopes,
|
||||||
|
dry_run_required=field.dry_run_required,
|
||||||
|
dry_run_satisfied=not field.dry_run_required or dry_run,
|
||||||
|
approval_required=state.approval_required,
|
||||||
|
approval_satisfied=state.approval_satisfied,
|
||||||
maintenance_required=field.maintenance_required,
|
maintenance_required=field.maintenance_required,
|
||||||
maintenance_satisfied=not field.maintenance_required or maintenance_mode,
|
maintenance_satisfied=not field.maintenance_required or maintenance_mode,
|
||||||
rollback_history_required=field.rollback_history_required,
|
rollback_history_required=field.rollback_history_required,
|
||||||
secret_handling=field.secret_handling,
|
secret_handling=field.secret_handling,
|
||||||
audit_event=field.audit_event,
|
audit_event=field.audit_event,
|
||||||
policy_explanation=_policy_explanation(field),
|
policy_explanation=_policy_explanation(field),
|
||||||
blockers=tuple(dict.fromkeys(blockers)),
|
blockers=state.blockers,
|
||||||
warnings=tuple(dict.fromkeys(warnings)),
|
warnings=state.warnings,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -406,7 +633,7 @@ def _policy_explanation(field: ConfigurationFieldSafety) -> str:
|
|||||||
parts.append("requires two-person approval")
|
parts.append("requires two-person approval")
|
||||||
if field.maintenance_required:
|
if field.maintenance_required:
|
||||||
parts.append("requires maintenance mode")
|
parts.append("requires maintenance mode")
|
||||||
if field.secret_handling != "none":
|
if field.secret_handling != "none": # noqa: S105 # nosec B105 - policy vocabulary.
|
||||||
parts.append(f"uses {field.secret_handling} secret handling")
|
parts.append(f"uses {field.secret_handling} secret handling")
|
||||||
return "; ".join(parts) + "."
|
return "; ".join(parts) + "."
|
||||||
|
|
||||||
|
|||||||
215
src/govoplan_core/core/dataflows.py
Normal file
215
src/govoplan_core/core/dataflows.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
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 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 _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",
|
||||||
|
"DataflowPublicationTarget",
|
||||||
|
"DataflowRunConflictError",
|
||||||
|
"DataflowRunDescriptor",
|
||||||
|
"DataflowRunError",
|
||||||
|
"DataflowRunLifecycleProvider",
|
||||||
|
"DataflowRunNotFoundError",
|
||||||
|
"DataflowRunRequest",
|
||||||
|
"DataflowRunUnavailableError",
|
||||||
|
"DataflowRunWorker",
|
||||||
|
"DataflowTriggerDispatcher",
|
||||||
|
"dataflow_run_lifecycle",
|
||||||
|
"dataflow_run_worker",
|
||||||
|
"dataflow_trigger_dispatcher",
|
||||||
|
]
|
||||||
438
src/govoplan_core/core/datasources.py
Normal file
438
src/govoplan_core/core/datasources.py
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_DATASOURCE_CATALOGUE = "datasources.catalogue"
|
||||||
|
CAPABILITY_DATASOURCE_LIFECYCLE = "datasources.lifecycle"
|
||||||
|
CAPABILITY_DATASOURCE_PUBLICATION = "datasources.publication"
|
||||||
|
CAPABILITY_DATASOURCE_ORIGINS = "connectors.datasourceOrigins"
|
||||||
|
|
||||||
|
DatasourceMode = Literal["live", "cached", "static"]
|
||||||
|
DatasourceKind = Literal[
|
||||||
|
"upload",
|
||||||
|
"database",
|
||||||
|
"http",
|
||||||
|
"rest",
|
||||||
|
"directory",
|
||||||
|
"file",
|
||||||
|
"feed",
|
||||||
|
"custom",
|
||||||
|
]
|
||||||
|
DatasourceShape = Literal["tabular", "document", "binary", "directory", "stream"]
|
||||||
|
DatasourceConsistency = Literal["current", "live", "frozen"]
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceError(ValueError):
|
||||||
|
"""Stable base error for provider-neutral datasource operations."""
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceNotFoundError(DatasourceError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceAccessError(DatasourceError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceValidationError(DatasourceError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceUnavailableError(DatasourceError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceField:
|
||||||
|
name: str
|
||||||
|
data_type: str
|
||||||
|
nullable: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceDescriptor:
|
||||||
|
ref: str
|
||||||
|
source_name: str
|
||||||
|
name: str
|
||||||
|
kind: DatasourceKind
|
||||||
|
mode: DatasourceMode
|
||||||
|
shape: DatasourceShape
|
||||||
|
status: str = "active"
|
||||||
|
description: str | None = None
|
||||||
|
provider: str | None = None
|
||||||
|
provider_ref: str | None = None
|
||||||
|
schema: tuple[DatasourceField, ...] = ()
|
||||||
|
schema_version: str = "1"
|
||||||
|
fingerprint: str = ""
|
||||||
|
current_materialization_ref: str | None = None
|
||||||
|
row_count: int | None = None
|
||||||
|
byte_count: int | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
capabilities: tuple[str, ...] = ("read",)
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceMaterialization:
|
||||||
|
ref: str
|
||||||
|
datasource_ref: str
|
||||||
|
revision: int
|
||||||
|
state: str
|
||||||
|
fingerprint: str
|
||||||
|
schema: tuple[DatasourceField, ...] = ()
|
||||||
|
row_count: int | None = None
|
||||||
|
byte_count: int | None = None
|
||||||
|
frozen_at: datetime | None = None
|
||||||
|
frozen_label: str | None = None
|
||||||
|
source_timestamp: datetime | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceStage:
|
||||||
|
ref: str
|
||||||
|
name: str
|
||||||
|
source_name: str
|
||||||
|
kind: DatasourceKind
|
||||||
|
mode: DatasourceMode
|
||||||
|
shape: DatasourceShape
|
||||||
|
state: str
|
||||||
|
target_datasource_ref: str | None = None
|
||||||
|
fingerprint: str = ""
|
||||||
|
schema: tuple[DatasourceField, ...] = ()
|
||||||
|
row_count: int | None = None
|
||||||
|
byte_count: int | None = None
|
||||||
|
validation: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
created_at: datetime | None = None
|
||||||
|
promoted_at: datetime | None = None
|
||||||
|
promoted_materialization_ref: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceReadRequest:
|
||||||
|
datasource_ref: str
|
||||||
|
materialization_ref: str | None = None
|
||||||
|
consistency: DatasourceConsistency = "current"
|
||||||
|
limit: int = 250
|
||||||
|
offset: int = 0
|
||||||
|
columns: tuple[str, ...] = ()
|
||||||
|
expected_fingerprint: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceReadResult:
|
||||||
|
datasource: DatasourceDescriptor
|
||||||
|
rows: tuple[Mapping[str, object], ...]
|
||||||
|
total_rows: int
|
||||||
|
truncated: bool
|
||||||
|
materialization: DatasourceMaterialization | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceStageInput:
|
||||||
|
name: str
|
||||||
|
source_name: str
|
||||||
|
kind: DatasourceKind
|
||||||
|
mode: DatasourceMode
|
||||||
|
shape: DatasourceShape
|
||||||
|
rows: tuple[Mapping[str, object], ...]
|
||||||
|
description: str | None = None
|
||||||
|
target_datasource_ref: str | None = None
|
||||||
|
provider: str | None = None
|
||||||
|
provider_ref: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourcePublicationRequest:
|
||||||
|
producer_module: str
|
||||||
|
producer_run_ref: str
|
||||||
|
idempotency_key: str
|
||||||
|
rows: tuple[Mapping[str, object], ...]
|
||||||
|
target_datasource_ref: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
source_name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
freeze: bool = False
|
||||||
|
frozen_label: str | None = None
|
||||||
|
set_current: bool = True
|
||||||
|
source_timestamp: datetime | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourcePublicationResult:
|
||||||
|
ref: str
|
||||||
|
status: str
|
||||||
|
datasource: DatasourceDescriptor
|
||||||
|
materialization: DatasourceMaterialization
|
||||||
|
replayed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceOrigin:
|
||||||
|
ref: str
|
||||||
|
source_name: str
|
||||||
|
name: str
|
||||||
|
kind: DatasourceKind
|
||||||
|
shape: DatasourceShape
|
||||||
|
supported_modes: tuple[DatasourceMode, ...]
|
||||||
|
provider: str
|
||||||
|
description: str | None = None
|
||||||
|
schema: tuple[DatasourceField, ...] = ()
|
||||||
|
schema_version: str = "1"
|
||||||
|
fingerprint: str = ""
|
||||||
|
row_count: int | None = None
|
||||||
|
byte_count: int | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
capabilities: tuple[str, ...] = ("read",)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceOriginReadRequest:
|
||||||
|
origin_ref: str
|
||||||
|
limit: int = 250
|
||||||
|
offset: int = 0
|
||||||
|
columns: tuple[str, ...] = ()
|
||||||
|
expected_fingerprint: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceOriginReadResult:
|
||||||
|
origin: DatasourceOrigin
|
||||||
|
rows: tuple[Mapping[str, object], ...]
|
||||||
|
total_rows: int
|
||||||
|
truncated: bool
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DatasourceCatalogueProvider(Protocol):
|
||||||
|
def list_datasources(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: str = "",
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[DatasourceDescriptor]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_datasource(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
datasource_ref: str,
|
||||||
|
) -> DatasourceDescriptor | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def read_datasource(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: DatasourceReadRequest,
|
||||||
|
) -> DatasourceReadResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
def list_materializations(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
datasource_ref: str,
|
||||||
|
) -> Sequence[DatasourceMaterialization]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DatasourceLifecycleProvider(Protocol):
|
||||||
|
def list_stages(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[DatasourceStage]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def create_stage(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
stage: DatasourceStageInput,
|
||||||
|
) -> DatasourceStage:
|
||||||
|
...
|
||||||
|
|
||||||
|
def promote_stage(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
stage_ref: str,
|
||||||
|
freeze: bool = False,
|
||||||
|
frozen_label: str | None = None,
|
||||||
|
) -> tuple[DatasourceDescriptor, DatasourceMaterialization]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def register_origin(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
origin_ref: str,
|
||||||
|
name: str,
|
||||||
|
source_name: str,
|
||||||
|
mode: DatasourceMode,
|
||||||
|
description: str | None = None,
|
||||||
|
) -> DatasourceDescriptor:
|
||||||
|
...
|
||||||
|
|
||||||
|
def refresh_datasource(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
datasource_ref: str,
|
||||||
|
) -> tuple[DatasourceDescriptor, DatasourceMaterialization]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def freeze_datasource(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
datasource_ref: str,
|
||||||
|
label: str | None = None,
|
||||||
|
) -> DatasourceMaterialization:
|
||||||
|
...
|
||||||
|
|
||||||
|
def retire_datasource(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
datasource_ref: str,
|
||||||
|
) -> DatasourceDescriptor:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DatasourcePublicationProvider(Protocol):
|
||||||
|
def publish_rows(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: DatasourcePublicationRequest,
|
||||||
|
) -> DatasourcePublicationResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DatasourceOriginProvider(Protocol):
|
||||||
|
def list_origins(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: str = "",
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[DatasourceOrigin]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_origin(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
origin_ref: str,
|
||||||
|
) -> DatasourceOrigin | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def read_origin(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: DatasourceOriginReadRequest,
|
||||||
|
) -> DatasourceOriginReadResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def datasource_catalogue(registry: object | None) -> DatasourceCatalogueProvider | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_DATASOURCE_CATALOGUE)
|
||||||
|
return capability if isinstance(capability, DatasourceCatalogueProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def datasource_lifecycle(registry: object | None) -> DatasourceLifecycleProvider | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_DATASOURCE_LIFECYCLE)
|
||||||
|
return capability if isinstance(capability, DatasourceLifecycleProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def datasource_publication(
|
||||||
|
registry: object | None,
|
||||||
|
) -> DatasourcePublicationProvider | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_DATASOURCE_PUBLICATION)
|
||||||
|
return capability if isinstance(capability, DatasourcePublicationProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def datasource_origins(registry: object | None) -> DatasourceOriginProvider | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_DATASOURCE_ORIGINS)
|
||||||
|
return capability if isinstance(capability, DatasourceOriginProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _capability(registry: object | None, name: str) -> object | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(name)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return registry.capability(name)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_DATASOURCE_CATALOGUE",
|
||||||
|
"CAPABILITY_DATASOURCE_LIFECYCLE",
|
||||||
|
"CAPABILITY_DATASOURCE_ORIGINS",
|
||||||
|
"DatasourceAccessError",
|
||||||
|
"DatasourceCatalogueProvider",
|
||||||
|
"DatasourceConsistency",
|
||||||
|
"DatasourceDescriptor",
|
||||||
|
"DatasourceError",
|
||||||
|
"DatasourceField",
|
||||||
|
"DatasourceKind",
|
||||||
|
"DatasourceLifecycleProvider",
|
||||||
|
"DatasourceMaterialization",
|
||||||
|
"DatasourceMode",
|
||||||
|
"DatasourceNotFoundError",
|
||||||
|
"DatasourceOrigin",
|
||||||
|
"DatasourceOriginProvider",
|
||||||
|
"DatasourceOriginReadRequest",
|
||||||
|
"DatasourceOriginReadResult",
|
||||||
|
"DatasourceReadRequest",
|
||||||
|
"DatasourceReadResult",
|
||||||
|
"DatasourceShape",
|
||||||
|
"DatasourceStage",
|
||||||
|
"DatasourceStageInput",
|
||||||
|
"DatasourceUnavailableError",
|
||||||
|
"DatasourceValidationError",
|
||||||
|
"datasource_catalogue",
|
||||||
|
"datasource_lifecycle",
|
||||||
|
"datasource_origins",
|
||||||
|
]
|
||||||
455
src/govoplan_core/core/definition_graphs.py
Normal file
455
src/govoplan_core/core/definition_graphs.py
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionPort:
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
required: bool = True
|
||||||
|
multiple: bool = False
|
||||||
|
minimum_connections: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionConfigField:
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
kind: str
|
||||||
|
required: bool = False
|
||||||
|
description: str | None = None
|
||||||
|
options: tuple[tuple[str, str], ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionNodeType:
|
||||||
|
type: str
|
||||||
|
category: str
|
||||||
|
label: str
|
||||||
|
description: str
|
||||||
|
icon: str
|
||||||
|
input_ports: tuple[DefinitionPort, ...] = ()
|
||||||
|
output_ports: tuple[DefinitionPort, ...] = (
|
||||||
|
DefinitionPort(id="output", label="Output"),
|
||||||
|
)
|
||||||
|
config_fields: tuple[DefinitionConfigField, ...] = ()
|
||||||
|
default_config: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionNode:
|
||||||
|
id: str
|
||||||
|
type: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionEdge:
|
||||||
|
id: str
|
||||||
|
source: str
|
||||||
|
target: str
|
||||||
|
source_port: str = "output"
|
||||||
|
target_port: str = "input"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionNodeCountConstraint:
|
||||||
|
code: str
|
||||||
|
label: str
|
||||||
|
minimum: int = 0
|
||||||
|
maximum: int | None = None
|
||||||
|
node_types: tuple[str, ...] = ()
|
||||||
|
type_prefixes: tuple[str, ...] = ()
|
||||||
|
categories: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
def matches(self, node: DefinitionNode, node_type: DefinitionNodeType | None) -> bool:
|
||||||
|
return (
|
||||||
|
node.type in self.node_types
|
||||||
|
or any(node.type.startswith(prefix) for prefix in self.type_prefixes)
|
||||||
|
or node_type is not None
|
||||||
|
and node_type.category in self.categories
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionGraphConstraints:
|
||||||
|
max_nodes: int = 100
|
||||||
|
max_edges: int = 200
|
||||||
|
allow_cycles: bool = False
|
||||||
|
require_connected: bool = True
|
||||||
|
node_counts: tuple[DefinitionNodeCountConstraint, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionGraphLibrary:
|
||||||
|
id: str
|
||||||
|
version: str
|
||||||
|
category_labels: Mapping[str, str]
|
||||||
|
node_types: tuple[DefinitionNodeType, ...]
|
||||||
|
constraints: DefinitionGraphConstraints = field(default_factory=DefinitionGraphConstraints)
|
||||||
|
|
||||||
|
def node_type(self, type_id: str) -> DefinitionNodeType | None:
|
||||||
|
return next((item for item in self.node_types if item.type == type_id), None)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionDiagnostic:
|
||||||
|
severity: str
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
node_id: str | None = None
|
||||||
|
field: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_definition_graph(
|
||||||
|
library: DefinitionGraphLibrary,
|
||||||
|
*,
|
||||||
|
nodes: Sequence[DefinitionNode],
|
||||||
|
edges: Sequence[DefinitionEdge],
|
||||||
|
) -> tuple[DefinitionDiagnostic, ...]:
|
||||||
|
if not nodes:
|
||||||
|
return (_error("graph.empty", "Add nodes before saving the definition."),)
|
||||||
|
constraints = library.constraints
|
||||||
|
diagnostics = _graph_size_diagnostics(
|
||||||
|
node_count=len(nodes),
|
||||||
|
edge_count=len(edges),
|
||||||
|
constraints=constraints,
|
||||||
|
)
|
||||||
|
node_by_id = {node.id: node for node in nodes}
|
||||||
|
definitions: dict[str, DefinitionNodeType] = {}
|
||||||
|
for item in library.node_types:
|
||||||
|
definitions.setdefault(item.type, item)
|
||||||
|
diagnostics.extend(_identifier_diagnostics(nodes=nodes, edges=edges))
|
||||||
|
incoming, undirected, topology_edges, edge_diagnostics = (
|
||||||
|
_validate_definition_edges(
|
||||||
|
edges=edges,
|
||||||
|
node_by_id=node_by_id,
|
||||||
|
definitions=definitions,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
diagnostics.extend(edge_diagnostics)
|
||||||
|
diagnostics.extend(
|
||||||
|
_node_port_diagnostics(
|
||||||
|
nodes=nodes,
|
||||||
|
definitions=definitions,
|
||||||
|
incoming=incoming,
|
||||||
|
library_id=library.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
diagnostics.extend(
|
||||||
|
_node_count_diagnostics(
|
||||||
|
nodes=nodes,
|
||||||
|
definitions=definitions,
|
||||||
|
constraints=constraints,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
diagnostics.extend(
|
||||||
|
_topology_diagnostics(
|
||||||
|
nodes=nodes,
|
||||||
|
node_by_id=node_by_id,
|
||||||
|
topology_edges=topology_edges,
|
||||||
|
undirected=undirected,
|
||||||
|
constraints=constraints,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(diagnostics)
|
||||||
|
|
||||||
|
|
||||||
|
def _graph_size_diagnostics(
|
||||||
|
*,
|
||||||
|
node_count: int,
|
||||||
|
edge_count: int,
|
||||||
|
constraints: DefinitionGraphConstraints,
|
||||||
|
) -> list[DefinitionDiagnostic]:
|
||||||
|
diagnostics: list[DefinitionDiagnostic] = []
|
||||||
|
if node_count > constraints.max_nodes:
|
||||||
|
diagnostics.append(
|
||||||
|
_error(
|
||||||
|
"graph.node_limit",
|
||||||
|
f"Definitions are limited to {constraints.max_nodes:,} nodes.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if edge_count > constraints.max_edges:
|
||||||
|
diagnostics.append(
|
||||||
|
_error(
|
||||||
|
"graph.edge_limit",
|
||||||
|
f"Definitions are limited to {constraints.max_edges:,} edges.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _identifier_diagnostics(
|
||||||
|
*,
|
||||||
|
nodes: Sequence[DefinitionNode],
|
||||||
|
edges: Sequence[DefinitionEdge],
|
||||||
|
) -> list[DefinitionDiagnostic]:
|
||||||
|
diagnostics: list[DefinitionDiagnostic] = []
|
||||||
|
if len({node.id for node in nodes}) != len(nodes):
|
||||||
|
diagnostics.append(_error("graph.duplicate_node", "Node identifiers must be unique."))
|
||||||
|
if len({edge.id for edge in edges}) != len(edges):
|
||||||
|
diagnostics.append(_error("graph.duplicate_edge", "Edge identifiers must be unique."))
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_definition_edges(
|
||||||
|
*,
|
||||||
|
edges: Sequence[DefinitionEdge],
|
||||||
|
node_by_id: Mapping[str, DefinitionNode],
|
||||||
|
definitions: Mapping[str, DefinitionNodeType],
|
||||||
|
) -> tuple[
|
||||||
|
dict[str, list[DefinitionEdge]],
|
||||||
|
dict[str, set[str]],
|
||||||
|
list[DefinitionEdge],
|
||||||
|
list[DefinitionDiagnostic],
|
||||||
|
]:
|
||||||
|
incoming: dict[str, list[DefinitionEdge]] = {node_id: [] for node_id in node_by_id}
|
||||||
|
undirected: dict[str, set[str]] = {node_id: set() for node_id in node_by_id}
|
||||||
|
topology_edges: list[DefinitionEdge] = []
|
||||||
|
diagnostics: list[DefinitionDiagnostic] = []
|
||||||
|
for edge in edges:
|
||||||
|
structural_error = _edge_structure_diagnostic(edge, node_by_id)
|
||||||
|
if structural_error is not None:
|
||||||
|
diagnostics.append(structural_error)
|
||||||
|
continue
|
||||||
|
topology_edges.append(edge)
|
||||||
|
port_error = _edge_port_diagnostic(
|
||||||
|
edge,
|
||||||
|
source_definition=definitions.get(node_by_id[edge.source].type),
|
||||||
|
target_definition=definitions.get(node_by_id[edge.target].type),
|
||||||
|
)
|
||||||
|
if port_error is not None:
|
||||||
|
diagnostics.append(port_error)
|
||||||
|
continue
|
||||||
|
incoming[edge.target].append(edge)
|
||||||
|
undirected[edge.source].add(edge.target)
|
||||||
|
undirected[edge.target].add(edge.source)
|
||||||
|
return incoming, undirected, topology_edges, diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _edge_structure_diagnostic(
|
||||||
|
edge: DefinitionEdge,
|
||||||
|
node_by_id: Mapping[str, DefinitionNode],
|
||||||
|
) -> DefinitionDiagnostic | None:
|
||||||
|
if edge.source not in node_by_id:
|
||||||
|
return _error(
|
||||||
|
"edge.unknown_source",
|
||||||
|
f"Edge {edge.id!r} references an unknown source node.",
|
||||||
|
)
|
||||||
|
if edge.target not in node_by_id:
|
||||||
|
return _error(
|
||||||
|
"edge.unknown_target",
|
||||||
|
f"Edge {edge.id!r} references an unknown target node.",
|
||||||
|
)
|
||||||
|
if edge.source == edge.target:
|
||||||
|
return _error(
|
||||||
|
"edge.self_reference",
|
||||||
|
"A node cannot connect to itself.",
|
||||||
|
node_id=edge.source,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _edge_port_diagnostic(
|
||||||
|
edge: DefinitionEdge,
|
||||||
|
*,
|
||||||
|
source_definition: DefinitionNodeType | None,
|
||||||
|
target_definition: DefinitionNodeType | None,
|
||||||
|
) -> DefinitionDiagnostic | None:
|
||||||
|
if source_definition is not None and edge.source_port not in {
|
||||||
|
port.id for port in source_definition.output_ports
|
||||||
|
}:
|
||||||
|
return _error(
|
||||||
|
"edge.unknown_source_port",
|
||||||
|
f"Node {edge.source!r} has no output port {edge.source_port!r}.",
|
||||||
|
node_id=edge.source,
|
||||||
|
)
|
||||||
|
if target_definition is not None and edge.target_port not in {
|
||||||
|
port.id for port in target_definition.input_ports
|
||||||
|
}:
|
||||||
|
return _error(
|
||||||
|
"edge.unknown_target_port",
|
||||||
|
f"Node {edge.target!r} has no input port {edge.target_port!r}.",
|
||||||
|
node_id=edge.target,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _node_port_diagnostics(
|
||||||
|
*,
|
||||||
|
nodes: Sequence[DefinitionNode],
|
||||||
|
definitions: Mapping[str, DefinitionNodeType],
|
||||||
|
incoming: Mapping[str, Sequence[DefinitionEdge]],
|
||||||
|
library_id: str,
|
||||||
|
) -> list[DefinitionDiagnostic]:
|
||||||
|
diagnostics: list[DefinitionDiagnostic] = []
|
||||||
|
for node in nodes:
|
||||||
|
definition = definitions.get(node.type)
|
||||||
|
if definition is None:
|
||||||
|
diagnostics.append(
|
||||||
|
_error(
|
||||||
|
"node.unsupported_type",
|
||||||
|
f"Node type {node.type!r} is not in the {library_id!r} library.",
|
||||||
|
node_id=node.id,
|
||||||
|
field="type",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
for port in definition.input_ports:
|
||||||
|
connections = [
|
||||||
|
edge for edge in incoming.get(node.id, ()) if edge.target_port == port.id
|
||||||
|
]
|
||||||
|
minimum = port.minimum_connections if port.required else 0
|
||||||
|
if len(connections) < minimum:
|
||||||
|
diagnostics.append(
|
||||||
|
_error(
|
||||||
|
"node.input_required",
|
||||||
|
(
|
||||||
|
f"{definition.label} requires {minimum} "
|
||||||
|
f"{port.label.lower()} connection"
|
||||||
|
f"{'' if minimum == 1 else 's'}."
|
||||||
|
),
|
||||||
|
node_id=node.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not port.multiple and len(connections) > 1:
|
||||||
|
diagnostics.append(
|
||||||
|
_error(
|
||||||
|
"node.input_multiple",
|
||||||
|
f"{port.label} accepts only one connection.",
|
||||||
|
node_id=node.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _node_count_diagnostics(
|
||||||
|
*,
|
||||||
|
nodes: Sequence[DefinitionNode],
|
||||||
|
definitions: Mapping[str, DefinitionNodeType],
|
||||||
|
constraints: DefinitionGraphConstraints,
|
||||||
|
) -> list[DefinitionDiagnostic]:
|
||||||
|
diagnostics: list[DefinitionDiagnostic] = []
|
||||||
|
for count_constraint in constraints.node_counts:
|
||||||
|
count = sum(
|
||||||
|
count_constraint.matches(node, definitions.get(node.type))
|
||||||
|
for node in nodes
|
||||||
|
)
|
||||||
|
if count < count_constraint.minimum:
|
||||||
|
diagnostics.append(
|
||||||
|
_error(
|
||||||
|
count_constraint.code,
|
||||||
|
(
|
||||||
|
f"A definition needs at least {count_constraint.minimum} "
|
||||||
|
f"{count_constraint.label} node"
|
||||||
|
f"{'' if count_constraint.minimum == 1 else 's'}."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if count_constraint.maximum is not None and count > count_constraint.maximum:
|
||||||
|
diagnostics.append(
|
||||||
|
_error(
|
||||||
|
count_constraint.code,
|
||||||
|
(
|
||||||
|
f"A definition allows at most {count_constraint.maximum} "
|
||||||
|
f"{count_constraint.label} node"
|
||||||
|
f"{'' if count_constraint.maximum == 1 else 's'}."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _topology_diagnostics(
|
||||||
|
*,
|
||||||
|
nodes: Sequence[DefinitionNode],
|
||||||
|
node_by_id: Mapping[str, DefinitionNode],
|
||||||
|
topology_edges: Sequence[DefinitionEdge],
|
||||||
|
undirected: Mapping[str, set[str]],
|
||||||
|
constraints: DefinitionGraphConstraints,
|
||||||
|
) -> list[DefinitionDiagnostic]:
|
||||||
|
diagnostics: list[DefinitionDiagnostic] = []
|
||||||
|
_, cyclic = definition_topological_order(nodes, topology_edges)
|
||||||
|
if cyclic and not constraints.allow_cycles:
|
||||||
|
diagnostics.append(_error("graph.cycle", "Definition edges must form an acyclic graph."))
|
||||||
|
if constraints.require_connected and len(node_by_id) > 1:
|
||||||
|
connected = _connected_nodes(next(iter(node_by_id)), undirected)
|
||||||
|
if len(connected) != len(node_by_id):
|
||||||
|
diagnostics.append(
|
||||||
|
_error("graph.disconnected", "Every node must belong to one connected definition.")
|
||||||
|
)
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def definition_topological_order(
|
||||||
|
nodes: Sequence[DefinitionNode],
|
||||||
|
edges: Sequence[DefinitionEdge],
|
||||||
|
) -> tuple[tuple[str, ...], bool]:
|
||||||
|
node_ids = [node.id for node in nodes]
|
||||||
|
incoming_count = {node_id: 0 for node_id in node_ids}
|
||||||
|
outgoing: dict[str, list[str]] = {node_id: [] for node_id in node_ids}
|
||||||
|
for edge in edges:
|
||||||
|
if (
|
||||||
|
edge.source in outgoing
|
||||||
|
and edge.target in incoming_count
|
||||||
|
and edge.source != edge.target
|
||||||
|
):
|
||||||
|
outgoing[edge.source].append(edge.target)
|
||||||
|
incoming_count[edge.target] += 1
|
||||||
|
ready = deque(node_id for node_id in node_ids if incoming_count[node_id] == 0)
|
||||||
|
ordered: list[str] = []
|
||||||
|
while ready:
|
||||||
|
node_id = ready.popleft()
|
||||||
|
ordered.append(node_id)
|
||||||
|
for target in outgoing[node_id]:
|
||||||
|
incoming_count[target] -= 1
|
||||||
|
if incoming_count[target] == 0:
|
||||||
|
ready.append(target)
|
||||||
|
return tuple(ordered), len(ordered) != len(node_ids)
|
||||||
|
|
||||||
|
|
||||||
|
def _connected_nodes(start: str, adjacency: Mapping[str, set[str]]) -> set[str]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
pending = [start]
|
||||||
|
while pending:
|
||||||
|
node_id = pending.pop()
|
||||||
|
if node_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(node_id)
|
||||||
|
pending.extend(adjacency.get(node_id, ()))
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def _error(
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
node_id: str | None = None,
|
||||||
|
field: str | None = None,
|
||||||
|
) -> DefinitionDiagnostic:
|
||||||
|
return DefinitionDiagnostic(
|
||||||
|
severity="error",
|
||||||
|
code=code,
|
||||||
|
message=message,
|
||||||
|
node_id=node_id,
|
||||||
|
field=field,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DefinitionConfigField",
|
||||||
|
"DefinitionDiagnostic",
|
||||||
|
"DefinitionEdge",
|
||||||
|
"DefinitionGraphConstraints",
|
||||||
|
"DefinitionGraphLibrary",
|
||||||
|
"DefinitionNode",
|
||||||
|
"DefinitionNodeCountConstraint",
|
||||||
|
"DefinitionNodeType",
|
||||||
|
"DefinitionPort",
|
||||||
|
"definition_topological_order",
|
||||||
|
"validate_definition_graph",
|
||||||
|
]
|
||||||
@@ -1,17 +1,23 @@
|
|||||||
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
|
||||||
|
|
||||||
|
|
||||||
_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:
|
||||||
@@ -100,6 +106,105 @@ class PlatformEvent:
|
|||||||
|
|
||||||
|
|
||||||
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:
|
||||||
@@ -189,5 +294,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}
|
||||||
|
|||||||
137
src/govoplan_core/core/external_references.py
Normal file
137
src/govoplan_core/core/external_references.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
IntegrationMaturity = Literal[
|
||||||
|
"discover",
|
||||||
|
"link",
|
||||||
|
"search",
|
||||||
|
"read",
|
||||||
|
"publish",
|
||||||
|
"synchronize",
|
||||||
|
"migrate",
|
||||||
|
"replace",
|
||||||
|
]
|
||||||
|
|
||||||
|
INTEGRATION_MATURITY_ORDER: tuple[IntegrationMaturity, ...] = (
|
||||||
|
"discover",
|
||||||
|
"link",
|
||||||
|
"search",
|
||||||
|
"read",
|
||||||
|
"publish",
|
||||||
|
"synchronize",
|
||||||
|
"migrate",
|
||||||
|
"replace",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalReferenceValidationError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ExternalObjectReference:
|
||||||
|
"""Stable identity and provenance for an object owned by another system."""
|
||||||
|
|
||||||
|
system: str
|
||||||
|
object_type: str
|
||||||
|
object_id: str
|
||||||
|
maturity: IntegrationMaturity = "link"
|
||||||
|
connector_id: str | None = None
|
||||||
|
canonical_url: str | None = None
|
||||||
|
version: str | None = None
|
||||||
|
etag: str | None = None
|
||||||
|
observed_at: datetime | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for field_name in ("system", "object_type", "object_id"):
|
||||||
|
value = str(getattr(self, field_name) or "").strip()
|
||||||
|
if not value:
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
f"External reference {field_name} is required."
|
||||||
|
)
|
||||||
|
if len(value) > 255:
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
f"External reference {field_name} is limited to 255 characters."
|
||||||
|
)
|
||||||
|
object.__setattr__(self, field_name, value)
|
||||||
|
if self.maturity not in INTEGRATION_MATURITY_ORDER:
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
f"Unsupported integration maturity: {self.maturity!r}."
|
||||||
|
)
|
||||||
|
if self.connector_id is not None:
|
||||||
|
connector_id = self.connector_id.strip()
|
||||||
|
if not connector_id:
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
"External reference connector_id cannot be blank."
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "connector_id", connector_id)
|
||||||
|
if self.canonical_url is not None:
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"canonical_url",
|
||||||
|
_validated_reference_url(self.canonical_url),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identity_key(self) -> str:
|
||||||
|
return f"{self.system}:{self.object_type}:{self.object_id}"
|
||||||
|
|
||||||
|
def supports(self, maturity: IntegrationMaturity) -> bool:
|
||||||
|
return integration_maturity_rank(self.maturity) >= integration_maturity_rank(
|
||||||
|
maturity
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"system": self.system,
|
||||||
|
"object_type": self.object_type,
|
||||||
|
"object_id": self.object_id,
|
||||||
|
"maturity": self.maturity,
|
||||||
|
"connector_id": self.connector_id,
|
||||||
|
"canonical_url": self.canonical_url,
|
||||||
|
"version": self.version,
|
||||||
|
"etag": self.etag,
|
||||||
|
"observed_at": (
|
||||||
|
self.observed_at.isoformat() if self.observed_at is not None else None
|
||||||
|
),
|
||||||
|
"metadata": dict(self.metadata),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def integration_maturity_rank(maturity: IntegrationMaturity) -> int:
|
||||||
|
try:
|
||||||
|
return INTEGRATION_MATURITY_ORDER.index(maturity)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
f"Unsupported integration maturity: {maturity!r}."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_reference_url(value: str) -> str:
|
||||||
|
normalized = value.strip()
|
||||||
|
parsed = urlsplit(normalized)
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
"External reference URLs must use HTTP or HTTPS."
|
||||||
|
)
|
||||||
|
if parsed.username is not None or parsed.password is not None:
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
"External reference URLs must not contain credentials."
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ExternalObjectReference",
|
||||||
|
"ExternalReferenceValidationError",
|
||||||
|
"INTEGRATION_MATURITY_ORDER",
|
||||||
|
"IntegrationMaturity",
|
||||||
|
"integration_maturity_rank",
|
||||||
|
]
|
||||||
@@ -7,6 +7,7 @@ from typing import Literal, Protocol, runtime_checkable
|
|||||||
|
|
||||||
IDENTITY_MODULE_ID = "identity"
|
IDENTITY_MODULE_ID = "identity"
|
||||||
CAPABILITY_IDENTITY_DIRECTORY = "identity.directory"
|
CAPABILITY_IDENTITY_DIRECTORY = "identity.directory"
|
||||||
|
CAPABILITY_IDENTITY_SEARCH = "identity.search"
|
||||||
|
|
||||||
IdentityStatus = Literal["active", "inactive", "suspended"]
|
IdentityStatus = Literal["active", "inactive", "suspended"]
|
||||||
|
|
||||||
@@ -44,3 +45,15 @@ class IdentityDirectory(Protocol):
|
|||||||
|
|
||||||
def accounts_for_identity(self, identity_id: str) -> Sequence[IdentityAccountLinkRef]:
|
def accounts_for_identity(self, identity_id: str) -> Sequence[IdentityAccountLinkRef]:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class IdentitySearchProvider(Protocol):
|
||||||
|
def search_identities(
|
||||||
|
self,
|
||||||
|
query: str | None = None,
|
||||||
|
*,
|
||||||
|
include_inactive: bool = False,
|
||||||
|
limit: int = 25,
|
||||||
|
) -> Sequence[IdentityRef]:
|
||||||
|
...
|
||||||
|
|||||||
@@ -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,7 @@ 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"
|
||||||
|
|
||||||
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 +31,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 +53,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 +62,47 @@ 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]:
|
||||||
|
...
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Literal
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
from sqlalchemy.engine import make_url
|
from sqlalchemy.engine import make_url
|
||||||
@@ -72,6 +74,25 @@ class ConfigValidationResult:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _RuntimeProfile:
|
||||||
|
name: str
|
||||||
|
production: bool
|
||||||
|
production_like: bool
|
||||||
|
local: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _ConfigIssueCollector:
|
||||||
|
strict: bool
|
||||||
|
issues: list[ConfigIssue]
|
||||||
|
|
||||||
|
def add(self, level: ConfigIssueLevel, key: str, message: str, action: str) -> None:
|
||||||
|
if self.strict and level == "warning":
|
||||||
|
level = "error"
|
||||||
|
self.issues.append(ConfigIssue(level=level, key=key, message=message, action=action))
|
||||||
|
|
||||||
|
|
||||||
_LOCAL_PROFILES = {"dev", "local", "local-dev", "test"}
|
_LOCAL_PROFILES = {"dev", "local", "local-dev", "test"}
|
||||||
_PRODUCTION_PROFILES = {"prod", "production", "self-hosted"}
|
_PRODUCTION_PROFILES = {"prod", "production", "self-hosted"}
|
||||||
_PRODUCTION_LIKE_PROFILES = {"staging", "production-like", "production-like-dev"}
|
_PRODUCTION_LIKE_PROFILES = {"staging", "production-like", "production-like-dev"}
|
||||||
@@ -114,95 +135,192 @@ def validate_runtime_configuration(
|
|||||||
strict: bool = False,
|
strict: bool = False,
|
||||||
) -> ConfigValidationResult:
|
) -> ConfigValidationResult:
|
||||||
env = dict(os.environ if environ is None else environ)
|
env = dict(os.environ if environ is None else environ)
|
||||||
clean_profile = normalize_install_profile(profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV"))
|
runtime = _runtime_profile(env, profile=profile)
|
||||||
issues: list[ConfigIssue] = []
|
collector = _ConfigIssueCollector(strict=strict, issues=[])
|
||||||
|
_validate_app_env(env, runtime, collector)
|
||||||
|
_validate_database_settings(env, runtime, collector)
|
||||||
|
_validate_master_key(env, runtime, collector)
|
||||||
|
_validate_enabled_modules(env, runtime, collector)
|
||||||
|
_validate_async_and_auth_settings(env, runtime, collector)
|
||||||
|
_validate_cors_settings(env, runtime, collector)
|
||||||
|
_validate_file_storage_settings(env, runtime, collector)
|
||||||
|
_validate_outbound_connector_policy(env, runtime, collector)
|
||||||
|
_validate_module_catalog_trust(env, runtime, collector)
|
||||||
|
return ConfigValidationResult(profile=runtime.name, issues=tuple(collector.issues))
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_profile(env: Mapping[str, str], *, profile: str | None) -> _RuntimeProfile:
|
||||||
|
clean_profile = normalize_install_profile(profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV"))
|
||||||
production = clean_profile in _PRODUCTION_PROFILES or env.get("APP_ENV", "").strip().lower() in {"prod", "production"}
|
production = clean_profile in _PRODUCTION_PROFILES or env.get("APP_ENV", "").strip().lower() in {"prod", "production"}
|
||||||
production_like = production or clean_profile in _PRODUCTION_LIKE_PROFILES
|
production_like = production or clean_profile in _PRODUCTION_LIKE_PROFILES
|
||||||
local = clean_profile in _LOCAL_PROFILES and not production_like
|
return _RuntimeProfile(
|
||||||
|
name=clean_profile,
|
||||||
|
production=production,
|
||||||
|
production_like=production_like,
|
||||||
|
local=clean_profile in _LOCAL_PROFILES and not production_like,
|
||||||
|
)
|
||||||
|
|
||||||
def issue(level: ConfigIssueLevel, key: str, message: str, action: str) -> None:
|
|
||||||
if strict and level == "warning":
|
|
||||||
level = "error"
|
|
||||||
issues.append(ConfigIssue(level=level, key=key, message=message, action=action))
|
|
||||||
|
|
||||||
|
def _validate_app_env(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||||
app_env = _clean(env.get("APP_ENV"))
|
app_env = _clean(env.get("APP_ENV"))
|
||||||
if not app_env and production_like:
|
if not app_env and runtime.production_like:
|
||||||
issue("error", "APP_ENV", "APP_ENV is missing for a production-like install.", "Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.")
|
collector.add("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 production_like:
|
elif app_env.lower() in {"dev", "test", "local"} and runtime.production_like:
|
||||||
issue("error", "APP_ENV", f"APP_ENV={app_env!r} is not valid for profile {clean_profile!r}.", "Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.")
|
collector.add("error", "APP_ENV", f"APP_ENV={app_env!r} is not valid for profile {runtime.name!r}.", "Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_database_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||||
database_url = _clean(env.get("DATABASE_URL"))
|
database_url = _clean(env.get("DATABASE_URL"))
|
||||||
if not database_url:
|
if not database_url:
|
||||||
issue("error", "DATABASE_URL", "DATABASE_URL is missing.", "Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.")
|
collector.add("error", "DATABASE_URL", "DATABASE_URL is missing.", "Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.")
|
||||||
else:
|
return
|
||||||
backend = _database_backend(database_url)
|
backend = _database_backend(database_url)
|
||||||
if backend is None:
|
if backend is None:
|
||||||
issue("error", "DATABASE_URL", "DATABASE_URL is not a valid SQLAlchemy URL.", "Use a value like postgresql+psycopg://user:password@host:5432/database.")
|
collector.add("error", "DATABASE_URL", "DATABASE_URL is not a valid SQLAlchemy URL.", "Use a value like postgresql+psycopg://user:password@host:5432/database.")
|
||||||
elif backend == "sqlite" and production_like:
|
return
|
||||||
issue("error", "DATABASE_URL", "SQLite is only supported for disposable local development.", "Use PostgreSQL for production-like and self-hosted installs.")
|
if backend == "sqlite" and runtime.production_like:
|
||||||
elif backend != "postgresql" and production:
|
collector.add("error", "DATABASE_URL", "SQLite is only supported for disposable local development.", "Use PostgreSQL for production-like and self-hosted installs.")
|
||||||
issue("warning", "DATABASE_URL", f"Database backend {backend!r} is not the preferred production target.", "Use PostgreSQL unless this deployment has an explicit support decision.")
|
elif backend != "postgresql" and runtime.production:
|
||||||
|
collector.add("warning", "DATABASE_URL", f"Database backend {backend!r} is not the preferred production target.", "Use PostgreSQL unless this deployment has an explicit support decision.")
|
||||||
if backend == "postgresql" and not _clean(env.get("GOVOPLAN_DATABASE_URL_PGTOOLS")):
|
if backend == "postgresql" and not _clean(env.get("GOVOPLAN_DATABASE_URL_PGTOOLS")):
|
||||||
issue("warning", "GOVOPLAN_DATABASE_URL_PGTOOLS", "PostgreSQL backup/restore tools URL is missing.", "Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.")
|
collector.add("warning", "GOVOPLAN_DATABASE_URL_PGTOOLS", "PostgreSQL backup/restore tools URL is missing.", "Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_master_key(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||||
master_key = _clean(env.get("MASTER_KEY_B64"))
|
master_key = _clean(env.get("MASTER_KEY_B64"))
|
||||||
if not master_key and not local:
|
if not master_key and not runtime.local:
|
||||||
issue("error", "MASTER_KEY_B64", "MASTER_KEY_B64 is required outside local dev/test.", "Generate a Fernet key with `govoplan-config env-template --generate-secrets` or `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` and store it in deployment secrets.")
|
collector.add("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.")
|
||||||
elif master_key:
|
return
|
||||||
|
if not master_key:
|
||||||
|
return
|
||||||
error = _master_key_error(master_key)
|
error = _master_key_error(master_key)
|
||||||
if error:
|
if error:
|
||||||
issue("error", "MASTER_KEY_B64", error, "Replace MASTER_KEY_B64 with a Fernet key or base64-encoded 32-byte key.")
|
collector.add("error", "MASTER_KEY_B64", error, "Replace MASTER_KEY_B64 with a Fernet key or base64-encoded 32-byte key.")
|
||||||
elif "change-me" in master_key.lower() or "generate" in master_key.lower():
|
elif "change-me" in master_key.lower() or "generate" in master_key.lower():
|
||||||
issue("error", "MASTER_KEY_B64", "MASTER_KEY_B64 still looks like a placeholder.", "Generate a real deployment key and store it outside git.")
|
collector.add("error", "MASTER_KEY_B64", "MASTER_KEY_B64 still looks like a placeholder.", "Generate a real deployment key and store it outside git.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_enabled_modules(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||||
enabled_modules = _csv(env.get("ENABLED_MODULES"))
|
enabled_modules = _csv(env.get("ENABLED_MODULES"))
|
||||||
if not enabled_modules and production_like:
|
if not enabled_modules and runtime.production_like:
|
||||||
issue("error", "ENABLED_MODULES", "ENABLED_MODULES is missing.", "Set ENABLED_MODULES explicitly so startup module composition is intentional.")
|
collector.add("error", "ENABLED_MODULES", "ENABLED_MODULES is missing.", "Set ENABLED_MODULES explicitly so startup module composition is intentional.")
|
||||||
elif "access" not in enabled_modules and production_like:
|
elif "access" not in enabled_modules and runtime.production_like:
|
||||||
issue("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 production_like:
|
elif enabled_modules and "admin" not in enabled_modules and runtime.production_like:
|
||||||
issue("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.")
|
||||||
|
|
||||||
celery_enabled = _truthy(env.get("CELERY_ENABLED"))
|
|
||||||
if celery_enabled and not _clean(env.get("REDIS_URL")):
|
|
||||||
issue("error", "REDIS_URL", "CELERY_ENABLED=true but REDIS_URL is missing.", "Set REDIS_URL to the Redis broker/result backend used by workers.")
|
|
||||||
|
|
||||||
if production and _truthy(env.get("DEV_BOOTSTRAP_ENABLED")):
|
def _validate_async_and_auth_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||||
issue("error", "DEV_BOOTSTRAP_ENABLED", "Development bootstrap is enabled in production.", "Set DEV_BOOTSTRAP_ENABLED=false and create first administrators through the controlled bootstrap path.")
|
if _truthy(env.get("CELERY_ENABLED")) and not _clean(env.get("REDIS_URL")):
|
||||||
|
collector.add("error", "REDIS_URL", "CELERY_ENABLED=true but REDIS_URL is missing.", "Set REDIS_URL to the Redis broker/result backend used by workers.")
|
||||||
|
if runtime.production and _truthy(env.get("DEV_BOOTSTRAP_ENABLED")):
|
||||||
|
collector.add("error", "DEV_BOOTSTRAP_ENABLED", "Development bootstrap is enabled in production.", "Set DEV_BOOTSTRAP_ENABLED=false and create first administrators through the controlled bootstrap path.")
|
||||||
|
if runtime.production and not _truthy(env.get("AUTH_COOKIE_SECURE")):
|
||||||
|
collector.add("error", "AUTH_COOKIE_SECURE", "Secure auth cookies are disabled for production.", "Set AUTH_COOKIE_SECURE=true behind HTTPS.")
|
||||||
|
|
||||||
if production and not _truthy(env.get("AUTH_COOKIE_SECURE")):
|
|
||||||
issue("error", "AUTH_COOKIE_SECURE", "Secure auth cookies are disabled for production.", "Set AUTH_COOKIE_SECURE=true behind HTTPS.")
|
|
||||||
|
|
||||||
|
def _validate_cors_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||||
cors_origins = _csv(env.get("CORS_ORIGINS"))
|
cors_origins = _csv(env.get("CORS_ORIGINS"))
|
||||||
if production_like and not cors_origins:
|
if runtime.production_like and not cors_origins:
|
||||||
issue("error", "CORS_ORIGINS", "CORS_ORIGINS is missing.", "Set CORS_ORIGINS to the exact WebUI origin or origins.")
|
collector.add("error", "CORS_ORIGINS", "CORS_ORIGINS is missing.", "Set CORS_ORIGINS to the exact WebUI origin or origins.")
|
||||||
elif "*" in cors_origins and production_like:
|
elif "*" in cors_origins and runtime.production_like:
|
||||||
issue("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 production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
|
elif runtime.production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
|
||||||
issue("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"))
|
||||||
|
if runtime.production_like and not trusted_hosts:
|
||||||
|
collector.add("error", "GOVOPLAN_TRUSTED_HOSTS", "Trusted HTTP hosts are not configured.", "Set GOVOPLAN_TRUSTED_HOSTS to the exact API host names accepted by this deployment.")
|
||||||
|
elif "*" in trusted_hosts and runtime.production_like:
|
||||||
|
collector.add("error", "GOVOPLAN_TRUSTED_HOSTS", "Wildcard trusted hosts are not allowed for production-like installs.", "Replace `*` with exact host names or narrowly scoped `*.example.org` entries.")
|
||||||
|
forwarded_allow_ips = _csv(env.get("FORWARDED_ALLOW_IPS"))
|
||||||
|
if runtime.production_like and "*" in forwarded_allow_ips:
|
||||||
|
collector.add("error", "FORWARDED_ALLOW_IPS", "Proxy headers must not be trusted from every address.", "Set FORWARDED_ALLOW_IPS to the reverse proxy address or network passed to Uvicorn.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||||
storage_backend = _clean(env.get("FILE_STORAGE_BACKEND")) or "local"
|
storage_backend = _clean(env.get("FILE_STORAGE_BACKEND")) or "local"
|
||||||
if storage_backend == "local":
|
if storage_backend == "local":
|
||||||
if not _clean(env.get("FILE_STORAGE_LOCAL_ROOT")) and production_like:
|
_validate_local_file_storage(env, runtime, collector)
|
||||||
issue("error", "FILE_STORAGE_LOCAL_ROOT", "Local file storage root is missing.", "Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.")
|
|
||||||
elif production:
|
|
||||||
issue("warning", "FILE_STORAGE_BACKEND", "Production is configured for local file storage.", "Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.")
|
|
||||||
elif storage_backend == "s3":
|
elif storage_backend == "s3":
|
||||||
|
_validate_s3_file_storage(env, collector)
|
||||||
|
else:
|
||||||
|
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:
|
||||||
|
if not _clean(env.get("FILE_STORAGE_LOCAL_ROOT")) and runtime.production_like:
|
||||||
|
collector.add("error", "FILE_STORAGE_LOCAL_ROOT", "Local file storage root is missing.", "Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.")
|
||||||
|
elif runtime.production:
|
||||||
|
collector.add("warning", "FILE_STORAGE_BACKEND", "Production is configured for local file storage.", "Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_s3_file_storage(env: Mapping[str, str], collector: _ConfigIssueCollector) -> 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"):
|
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)):
|
||||||
issue("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.")
|
||||||
else:
|
|
||||||
issue("error", "FILE_STORAGE_BACKEND", f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.", "Use `local` or `s3`.")
|
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_outbound_connector_policy(
|
||||||
|
env: Mapping[str, str],
|
||||||
|
runtime: _RuntimeProfile,
|
||||||
|
collector: _ConfigIssueCollector,
|
||||||
|
) -> None:
|
||||||
|
private_networks = _clean(env.get("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS")).lower()
|
||||||
|
if runtime.production_like and private_networks not in {"true", "false", "1", "0", "yes", "no", "on", "off"}:
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
|
||||||
|
"Private-network connector access must be an explicit deployment decision.",
|
||||||
|
"Set GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false for public-only egress, or true when this deployment must reach internal services.",
|
||||||
|
)
|
||||||
|
for key in (
|
||||||
|
"GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
|
||||||
|
"GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES",
|
||||||
|
"GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES",
|
||||||
|
):
|
||||||
|
value = _clean(env.get(key))
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except ValueError:
|
||||||
|
parsed = 0
|
||||||
|
if parsed <= 0:
|
||||||
|
collector.add("error", key, f"{key} must be a positive byte count.", "Use a positive integer byte limit.")
|
||||||
|
secret_env_names = [
|
||||||
|
item.strip()
|
||||||
|
for item in env.get("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST", "").split(",")
|
||||||
|
if item.strip()
|
||||||
|
]
|
||||||
|
if any(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", item) is None for item in secret_env_names):
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
|
||||||
|
"Connector secret environment allowlist contains an invalid variable name.",
|
||||||
|
"Use a comma-separated list of exact environment variable names, or leave the setting empty.",
|
||||||
|
)
|
||||||
|
ca_bundle_paths = [
|
||||||
|
item.strip()
|
||||||
|
for item in env.get("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST", "").split(",")
|
||||||
|
if item.strip()
|
||||||
|
]
|
||||||
|
if any(not Path(item).is_absolute() for item in ca_bundle_paths):
|
||||||
|
collector.add(
|
||||||
|
"error",
|
||||||
|
"GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST",
|
||||||
|
"Connector CA bundle allowlist contains a non-absolute path.",
|
||||||
|
"Use comma-separated absolute paths mounted on every API and connector worker, or leave the setting empty.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_module_catalog_trust(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||||
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG"))
|
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG"))
|
||||||
if production and catalog_source:
|
if not runtime.production or not catalog_source:
|
||||||
|
return
|
||||||
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE")):
|
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE")):
|
||||||
issue("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "A module catalog source is configured without a trusted keyring file.", "Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.")
|
collector.add("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "A module catalog source is configured without a trusted keyring file.", "Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.")
|
||||||
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL")):
|
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL")):
|
||||||
issue("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL", "A module catalog source is configured without an approved release channel.", "Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.")
|
collector.add("error", "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.")
|
||||||
|
|
||||||
return ConfigValidationResult(profile=clean_profile, issues=tuple(issues))
|
|
||||||
|
|
||||||
|
|
||||||
def _self_hosted_env_template(master_key: str) -> str:
|
def _self_hosted_env_template(master_key: str) -> str:
|
||||||
@@ -220,9 +338,34 @@ ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,aud
|
|||||||
|
|
||||||
CELERY_ENABLED=true
|
CELERY_ENABLED=true
|
||||||
REDIS_URL=redis://127.0.0.1:6379/0
|
REDIS_URL=redis://127.0.0.1:6379/0
|
||||||
CELERY_QUEUES=send_email,append_sent,default
|
CELERY_QUEUES=send_email,append_sent,notifications,calendar,dataflow,events,default
|
||||||
|
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||||
|
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
|
||||||
|
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||||
|
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
|
||||||
|
|
||||||
|
# Deployment-wide connector egress policy. Enable private networks only when
|
||||||
|
# this installation intentionally integrates with internal services.
|
||||||
|
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false
|
||||||
|
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
|
||||||
|
GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES=536870912
|
||||||
|
# Exact names/paths only. Keep empty until a deployment-owned connector needs them.
|
||||||
|
GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=
|
||||||
|
GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=
|
||||||
|
GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES=536870912
|
||||||
|
GOVOPLAN_HTTP_HSTS_SECONDS=31536000
|
||||||
|
|
||||||
|
AUTH_LOGIN_THROTTLE_ENABLED=true
|
||||||
|
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
|
||||||
|
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
|
||||||
|
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
|
||||||
|
AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS=30
|
||||||
|
|
||||||
CORS_ORIGINS=https://govoplan.example.org
|
CORS_ORIGINS=https://govoplan.example.org
|
||||||
|
GOVOPLAN_TRUSTED_HOSTS=govoplan.example.org
|
||||||
|
# Uvicorn reads FORWARDED_ALLOW_IPS when proxy headers are enabled. Keep this
|
||||||
|
# restricted to the actual reverse proxy address or network.
|
||||||
|
FORWARDED_ALLOW_IPS=127.0.0.1
|
||||||
AUTH_COOKIE_SECURE=true
|
AUTH_COOKIE_SECURE=true
|
||||||
AUTH_COOKIE_SAMESITE=lax
|
AUTH_COOKIE_SAMESITE=lax
|
||||||
AUTH_COOKIE_DOMAIN=
|
AUTH_COOKIE_DOMAIN=
|
||||||
@@ -263,10 +406,30 @@ DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
|||||||
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
||||||
REDIS_URL=redis://127.0.0.1:56379/0
|
REDIS_URL=redis://127.0.0.1:56379/0
|
||||||
CELERY_ENABLED=true
|
CELERY_ENABLED=true
|
||||||
CELERY_QUEUES=send_email,append_sent,default
|
CELERY_QUEUES=send_email,append_sent,notifications,calendar,dataflow,events,default
|
||||||
|
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||||
|
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
|
||||||
|
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||||
|
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
|
||||||
|
|
||||||
|
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
|
||||||
|
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
|
||||||
|
GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES=536870912
|
||||||
|
GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=
|
||||||
|
GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=
|
||||||
|
GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES=536870912
|
||||||
|
GOVOPLAN_HTTP_HSTS_SECONDS=0
|
||||||
|
|
||||||
|
AUTH_LOGIN_THROTTLE_ENABLED=true
|
||||||
|
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
|
||||||
|
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
|
||||||
|
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
|
||||||
|
AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS=30
|
||||||
|
|
||||||
ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops
|
ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops
|
||||||
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
||||||
|
GOVOPLAN_TRUSTED_HOSTS=127.0.0.1,localhost,testserver
|
||||||
|
FORWARDED_ALLOW_IPS=127.0.0.1
|
||||||
AUTH_COOKIE_SECURE=false
|
AUTH_COOKIE_SECURE=false
|
||||||
FILE_STORAGE_BACKEND=local
|
FILE_STORAGE_BACKEND=local
|
||||||
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
|
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
|
||||||
@@ -299,8 +462,8 @@ def _master_key_error(value: str) -> str | None:
|
|||||||
try:
|
try:
|
||||||
Fernet(candidate)
|
Fernet(candidate)
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except (TypeError, ValueError):
|
||||||
pass
|
raw = None
|
||||||
try:
|
try:
|
||||||
raw = base64.b64decode(candidate)
|
raw = base64.b64decode(candidate)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -309,6 +472,6 @@ def _master_key_error(value: str) -> str | None:
|
|||||||
return "MASTER_KEY_B64 must decode to exactly 32 bytes."
|
return "MASTER_KEY_B64 must decode to exactly 32 bytes."
|
||||||
try:
|
try:
|
||||||
Fernet(base64.urlsafe_b64encode(raw))
|
Fernet(base64.urlsafe_b64encode(raw))
|
||||||
except Exception:
|
except (TypeError, ValueError):
|
||||||
return "MASTER_KEY_B64 is not usable as a Fernet key."
|
return "MASTER_KEY_B64 is not usable as a Fernet key."
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -67,6 +67,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],
|
||||||
|
|||||||
78
src/govoplan_core/core/mail.py
Normal file
78
src/govoplan_core/core/mail.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_MAIL_DELIVERY_OUTBOX = "mail.delivery_outbox"
|
||||||
|
CAPABILITY_MAIL_NOTIFICATION_DELIVERY = "mail.notificationDelivery"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class NotificationMailDeliveryRequest:
|
||||||
|
tenant_id: str
|
||||||
|
notification_id: str
|
||||||
|
recipient: str
|
||||||
|
subject: str
|
||||||
|
body_text: str
|
||||||
|
body_html: str | None = None
|
||||||
|
action_url: str | None = None
|
||||||
|
mail_profile_id: str | None = None
|
||||||
|
from_address: str | None = None
|
||||||
|
smtp_server_id: str | None = None
|
||||||
|
smtp_credential_id: str | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class NotificationMailDeliveryProvider(Protocol):
|
||||||
|
"""Mail-owned durable submission boundary for notification email."""
|
||||||
|
|
||||||
|
def submit_notification_mail(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
request: NotificationMailDeliveryRequest,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class MailDeliveryOutboxProvider(Protocol):
|
||||||
|
"""Stable worker boundary for Mail-owned external delivery effects."""
|
||||||
|
|
||||||
|
def dispatch_due(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
limit: int = 25,
|
||||||
|
worker_id: str | None = None,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def purge_expired(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
limit: int = 250,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def notification_mail_delivery_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> NotificationMailDeliveryProvider | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_MAIL_NOTIFICATION_DELIVERY)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
provider = registry.require_capability(CAPABILITY_MAIL_NOTIFICATION_DELIVERY)
|
||||||
|
if not isinstance(provider, NotificationMailDeliveryProvider):
|
||||||
|
raise TypeError(
|
||||||
|
"mail.notificationDelivery provider does not implement "
|
||||||
|
"NotificationMailDeliveryProvider"
|
||||||
|
)
|
||||||
|
return provider
|
||||||
@@ -79,15 +79,19 @@ def drop_table_retirement_provider(
|
|||||||
warnings.append("Tables not present and therefore skipped: " + ", ".join(missing_names))
|
warnings.append("Tables not present and therefore skipped: " + ", ".join(missing_names))
|
||||||
|
|
||||||
def executor(execute_session: object, _module_id: str) -> None:
|
def executor(execute_session: object, _module_id: str) -> None:
|
||||||
if not hasattr(execute_session, "get_bind"):
|
if not hasattr(execute_session, "connection"):
|
||||||
raise RuntimeError("No database session is available for destructive table retirement.")
|
raise RuntimeError("No database session is available for destructive table retirement.")
|
||||||
execute_bind = execute_session.get_bind() # type: ignore[attr-defined]
|
# Enlist schema retirement in the caller's active transaction. An
|
||||||
live_inspector = inspect(execute_bind)
|
# Engine returned by Session.get_bind() may acquire a second
|
||||||
|
# connection, separating the DROP from module-specific secret
|
||||||
|
# scrubbing/audit writes and deadlocking on their uncommitted locks.
|
||||||
|
execute_connection = execute_session.connection() # type: ignore[attr-defined]
|
||||||
|
live_inspector = inspect(execute_connection)
|
||||||
live_tables = [table for table in tables if live_inspector.has_table(table.name)]
|
live_tables = [table for table in tables if live_inspector.has_table(table.name)]
|
||||||
if not live_tables:
|
if not live_tables:
|
||||||
return
|
return
|
||||||
metadata = live_tables[0].metadata
|
metadata = live_tables[0].metadata
|
||||||
metadata.drop_all(bind=execute_bind, tables=live_tables, checkfirst=True)
|
metadata.drop_all(bind=execute_connection, tables=live_tables, checkfirst=True)
|
||||||
|
|
||||||
return MigrationRetirementPlan(
|
return MigrationRetirementPlan(
|
||||||
supported=True,
|
supported=True,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
126
src/govoplan_core/core/module_installer_notifications.py
Normal file
126
src/govoplan_core/core/module_installer_notifications.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
||||||
|
from govoplan_core.core.modules import ModuleContext
|
||||||
|
from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider
|
||||||
|
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
||||||
|
|
||||||
|
|
||||||
|
INSTALLER_NOTIFICATION_ACTION_URL = "/admin?section=modules"
|
||||||
|
|
||||||
|
|
||||||
|
def emit_module_installer_notification(
|
||||||
|
*,
|
||||||
|
session: object,
|
||||||
|
registry: object | None,
|
||||||
|
tenant_id: str | None,
|
||||||
|
request: Mapping[str, object],
|
||||||
|
event_kind: str,
|
||||||
|
subject: str,
|
||||||
|
body_text: str,
|
||||||
|
recipient_id: str | None = None,
|
||||||
|
priority: int = 5,
|
||||||
|
) -> bool:
|
||||||
|
if not tenant_id:
|
||||||
|
return False
|
||||||
|
provider = notification_dispatch_provider(registry)
|
||||||
|
if provider is None:
|
||||||
|
return False
|
||||||
|
request_id = str(request.get("request_id") or "")
|
||||||
|
if not request_id:
|
||||||
|
return False
|
||||||
|
provider.enqueue_notification(
|
||||||
|
session,
|
||||||
|
NotificationDispatchRequest(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_module="core",
|
||||||
|
source_resource_type="module_install_request",
|
||||||
|
source_resource_id=request_id,
|
||||||
|
event_kind=event_kind,
|
||||||
|
channel="inbox",
|
||||||
|
recipient_type="user" if recipient_id else None,
|
||||||
|
recipient_id=recipient_id,
|
||||||
|
subject=subject,
|
||||||
|
body_text=body_text,
|
||||||
|
action_url=INSTALLER_NOTIFICATION_ACTION_URL,
|
||||||
|
priority=priority,
|
||||||
|
payload={
|
||||||
|
"request_id": request_id,
|
||||||
|
"status": request.get("status"),
|
||||||
|
"run_id": _result_run_id(request),
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"trace": request.get("trace") if isinstance(request.get("trace"), Mapping) else None,
|
||||||
|
"retry_of": request.get("retry_of"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
enqueue_delivery=False,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def build_runtime_notification_registry(settings: object) -> object | None:
|
||||||
|
try:
|
||||||
|
raw_enabled_modules = load_startup_enabled_modules(str(getattr(settings, "enabled_modules", "") or ""))
|
||||||
|
candidate_modules = startup_candidate_module_ids(str(getattr(settings, "enabled_modules", "") or ""), raw_enabled_modules)
|
||||||
|
available_modules = available_module_manifests(enabled_modules=candidate_modules, ignore_load_errors=True)
|
||||||
|
enabled_modules = load_startup_enabled_modules(str(getattr(settings, "enabled_modules", "") or ""), available=available_modules)
|
||||||
|
if "notifications" not in enabled_modules:
|
||||||
|
return None
|
||||||
|
registry = build_platform_registry(enabled_modules)
|
||||||
|
registry.configure_capability_context(ModuleContext(registry=registry, settings=settings))
|
||||||
|
return registry
|
||||||
|
except Exception: # noqa: BLE001 - notification bridge must not block installer work.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def installer_notification_subject(event_kind: str, request: Mapping[str, object]) -> str:
|
||||||
|
request_id = str(request.get("request_id") or "unknown")
|
||||||
|
status = str(request.get("status") or event_kind.rsplit(".", 1)[-1])
|
||||||
|
prefixes = {
|
||||||
|
"queued": "Module installer request queued",
|
||||||
|
"running": "Module installer request started",
|
||||||
|
"completed": "Module installer request completed",
|
||||||
|
"failed": "Module installer request failed",
|
||||||
|
"cancelled": "Module installer request cancelled",
|
||||||
|
}
|
||||||
|
prefix = prefixes.get(status, "Module installer request updated")
|
||||||
|
return ": ".join((prefix, request_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _installer_status_sentence(request_id: str, status: str) -> str:
|
||||||
|
return " ".join(("Installer request", request_id, "is", status))
|
||||||
|
|
||||||
|
|
||||||
|
def installer_notification_body(event_kind: str, request: Mapping[str, object]) -> str:
|
||||||
|
status = str(request.get("status") or event_kind.rsplit(".", 1)[-1])
|
||||||
|
request_id = str(request.get("request_id") or "unknown")
|
||||||
|
result = request.get("result") if isinstance(request.get("result"), Mapping) else {}
|
||||||
|
error = str(request.get("error") or result.get("error") or "").strip()
|
||||||
|
sentence = _installer_status_sentence(request_id, status)
|
||||||
|
if error:
|
||||||
|
return ". Error: ".join((sentence, error))
|
||||||
|
run_id = _result_run_id(request)
|
||||||
|
if run_id:
|
||||||
|
return ". Run: ".join((sentence, run_id))
|
||||||
|
# This helper returns plain notification text, not an HTTP/HTML response.
|
||||||
|
return sentence + "." # nosemgrep: python.flask.security.audit.directly-returned-format-string.directly-returned-format-string
|
||||||
|
|
||||||
|
|
||||||
|
def installer_notification_priority(status: str) -> int:
|
||||||
|
if status == "failed":
|
||||||
|
return 20
|
||||||
|
if status in {"completed", "cancelled"}:
|
||||||
|
return 8
|
||||||
|
if status == "running":
|
||||||
|
return 4
|
||||||
|
return 5
|
||||||
|
|
||||||
|
|
||||||
|
def _result_run_id(request: Mapping[str, object]) -> str | None:
|
||||||
|
result = request.get("result") if isinstance(request.get("result"), Mapping) else {}
|
||||||
|
run_id: Any = result.get("run_id") if isinstance(result, Mapping) else None
|
||||||
|
return str(run_id) if run_id else None
|
||||||
@@ -6,13 +6,11 @@ from datetime import UTC, datetime
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
from cryptography.exceptions import InvalidSignature
|
from cryptography.exceptions import InvalidSignature
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
||||||
|
from govoplan_core.security.http_fetch import fetch_http_text
|
||||||
|
|
||||||
|
|
||||||
def module_license_decision(required_features: list[str] | tuple[str, ...]) -> dict[str, object]:
|
def module_license_decision(required_features: list[str] | tuple[str, ...]) -> dict[str, object]:
|
||||||
@@ -257,17 +255,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
|
|||||||
|
|
||||||
|
|
||||||
def _read_trusted_keys_url(url: str) -> str:
|
def _read_trusted_keys_url(url: str) -> str:
|
||||||
if not url.startswith(("https://", "http://")):
|
|
||||||
raise ValueError("Trusted license key URL must use http:// or https://.")
|
|
||||||
cache_path = _configured_trusted_keys_cache_path()
|
cache_path = _configured_trusted_keys_cache_path()
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(url, timeout=15) as response:
|
body = fetch_http_text(url, timeout=15, label="Trusted license key URL")
|
||||||
body = response.read().decode("utf-8")
|
|
||||||
if cache_path is not None:
|
if cache_path is not None:
|
||||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
cache_path.write_text(body, encoding="utf-8")
|
cache_path.write_text(body, encoding="utf-8")
|
||||||
return body
|
return body
|
||||||
except (OSError, urllib.error.URLError):
|
except OSError:
|
||||||
if cache_path is not None and cache_path.exists():
|
if cache_path is not None and cache_path.exists():
|
||||||
return cache_path.read_text(encoding="utf-8")
|
return cache_path.read_text(encoding="utf-8")
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from govoplan_core.admin.models import SystemSettings
|
from govoplan_core.admin.models import SystemSettings
|
||||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID, get_system_settings
|
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID, get_system_settings
|
||||||
|
from govoplan_core.core.access import DEFAULT_CAPABILITY_PROVIDERS
|
||||||
from govoplan_core.core.discovery import iter_module_entry_points
|
from govoplan_core.core.discovery import iter_module_entry_points
|
||||||
from govoplan_core.core.modules import ModuleManifest
|
from govoplan_core.core.modules import ModuleManifest
|
||||||
from govoplan_core.db.session import get_database
|
from govoplan_core.db.session import get_database
|
||||||
@@ -17,8 +18,8 @@ from govoplan_core.server.registry import parse_enabled_modules
|
|||||||
|
|
||||||
MODULE_SETTINGS_KEY = "module_management"
|
MODULE_SETTINGS_KEY = "module_management"
|
||||||
INSTALL_PLAN_KEY = "install_plan"
|
INSTALL_PLAN_KEY = "install_plan"
|
||||||
REQUIRED_PLATFORM_MODULES = ("access",)
|
REQUIRED_PLATFORM_MODULES: tuple[str, ...] = ()
|
||||||
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin")
|
PROTECTED_MODULES = ("admin",)
|
||||||
INSTALL_PLAN_ACTIONS = ("install", "update", "uninstall")
|
INSTALL_PLAN_ACTIONS = ("install", "update", "uninstall")
|
||||||
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
|
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
|
||||||
INSTALL_PLAN_SOURCES = ("manual", "catalog")
|
INSTALL_PLAN_SOURCES = ("manual", "catalog")
|
||||||
@@ -73,6 +74,23 @@ class ModuleInstallPlanItem:
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _NormalizedModuleInstallPlanItem:
|
||||||
|
module_id: str
|
||||||
|
action: str
|
||||||
|
source: str
|
||||||
|
catalog: Mapping[str, object] | None
|
||||||
|
status: str
|
||||||
|
python_package: str | None
|
||||||
|
python_ref: str | None
|
||||||
|
webui_package: str | None
|
||||||
|
webui_ref: str | None
|
||||||
|
artifact_integrity: Mapping[str, object] | None
|
||||||
|
data_safety_acknowledged: bool
|
||||||
|
destroy_data: bool
|
||||||
|
notes: str | None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ModuleInstallPlan:
|
class ModuleInstallPlan:
|
||||||
items: tuple[ModuleInstallPlanItem, ...] = ()
|
items: tuple[ModuleInstallPlanItem, ...] = ()
|
||||||
@@ -92,6 +110,7 @@ def startup_candidate_module_ids(
|
|||||||
if desired is not None:
|
if desired is not None:
|
||||||
candidates.extend(str(item).strip() for item in desired if str(item).strip())
|
candidates.extend(str(item).strip() for item in desired if str(item).strip())
|
||||||
candidates.extend(REQUIRED_PLATFORM_MODULES)
|
candidates.extend(REQUIRED_PLATFORM_MODULES)
|
||||||
|
candidates.extend(DEFAULT_CAPABILITY_PROVIDERS.values())
|
||||||
if "admin" in fallback:
|
if "admin" in fallback:
|
||||||
candidates.append("admin")
|
candidates.append("admin")
|
||||||
return tuple(dict.fromkeys(candidates))
|
return tuple(dict.fromkeys(candidates))
|
||||||
@@ -291,6 +310,28 @@ def desired_modules_after_package_plan(
|
|||||||
def normalize_module_install_plan_item(
|
def normalize_module_install_plan_item(
|
||||||
item: Mapping[str, object] | ModuleInstallPlanItem,
|
item: Mapping[str, object] | ModuleInstallPlanItem,
|
||||||
) -> ModuleInstallPlanItem:
|
) -> ModuleInstallPlanItem:
|
||||||
|
normalized = _normalized_module_install_plan_item(item)
|
||||||
|
_validate_module_install_plan_item(normalized)
|
||||||
|
return ModuleInstallPlanItem(
|
||||||
|
module_id=normalized.module_id,
|
||||||
|
action=normalized.action,
|
||||||
|
source=normalized.source,
|
||||||
|
catalog=normalized.catalog,
|
||||||
|
python_package=normalized.python_package,
|
||||||
|
python_ref=normalized.python_ref,
|
||||||
|
webui_package=normalized.webui_package,
|
||||||
|
webui_ref=normalized.webui_ref,
|
||||||
|
artifact_integrity=normalized.artifact_integrity,
|
||||||
|
data_safety_acknowledged=normalized.data_safety_acknowledged,
|
||||||
|
destroy_data=normalized.destroy_data,
|
||||||
|
status=normalized.status,
|
||||||
|
notes=normalized.notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_module_install_plan_item(
|
||||||
|
item: Mapping[str, object] | ModuleInstallPlanItem,
|
||||||
|
) -> _NormalizedModuleInstallPlanItem:
|
||||||
if isinstance(item, ModuleInstallPlanItem):
|
if isinstance(item, ModuleInstallPlanItem):
|
||||||
raw = item.as_dict()
|
raw = item.as_dict()
|
||||||
elif isinstance(item, Mapping):
|
elif isinstance(item, Mapping):
|
||||||
@@ -311,33 +352,12 @@ def normalize_module_install_plan_item(
|
|||||||
data_safety_acknowledged = _clean_bool(raw.get("data_safety_acknowledged"))
|
data_safety_acknowledged = _clean_bool(raw.get("data_safety_acknowledged"))
|
||||||
destroy_data = _clean_bool(raw.get("destroy_data"))
|
destroy_data = _clean_bool(raw.get("destroy_data"))
|
||||||
notes = _clean_optional_string(raw.get("notes"))
|
notes = _clean_optional_string(raw.get("notes"))
|
||||||
|
return _NormalizedModuleInstallPlanItem(
|
||||||
if action not in INSTALL_PLAN_ACTIONS:
|
|
||||||
raise ModuleManagementError(f"Unsupported install plan action for {module_id!r}: {action!r}.")
|
|
||||||
if status not in INSTALL_PLAN_STATUSES:
|
|
||||||
raise ModuleManagementError(f"Unsupported install plan status for {module_id!r}: {status!r}.")
|
|
||||||
if source not in INSTALL_PLAN_SOURCES:
|
|
||||||
raise ModuleManagementError(f"Unsupported install plan source for {module_id!r}: {source!r}.")
|
|
||||||
if action in {"install", "update"} and not python_ref:
|
|
||||||
raise ModuleManagementError(f"Install plan item {module_id!r} needs a Python package reference.")
|
|
||||||
if action == "uninstall" and not python_package:
|
|
||||||
raise ModuleManagementError(f"Uninstall plan item {module_id!r} needs a Python package name.")
|
|
||||||
if action != "uninstall" and destroy_data:
|
|
||||||
raise ModuleManagementError(f"Install plan item {module_id!r} can only destroy data during uninstall.")
|
|
||||||
if action in {"install", "update"} and bool(webui_package) != bool(webui_ref):
|
|
||||||
raise ModuleManagementError(f"Install plan item {module_id!r} needs both WebUI package and WebUI reference, or neither.")
|
|
||||||
if action == "uninstall" and webui_ref and not webui_package:
|
|
||||||
raise ModuleManagementError(f"Uninstall plan item {module_id!r} has a WebUI reference but no WebUI package.")
|
|
||||||
if python_ref:
|
|
||||||
_validate_dependency_ref(python_ref, field="python_ref", module_id=module_id)
|
|
||||||
if webui_ref:
|
|
||||||
_validate_dependency_ref(webui_ref, field="webui_ref", module_id=module_id)
|
|
||||||
|
|
||||||
return ModuleInstallPlanItem(
|
|
||||||
module_id=module_id,
|
module_id=module_id,
|
||||||
action=action,
|
action=action,
|
||||||
source=source,
|
source=source,
|
||||||
catalog=catalog,
|
catalog=catalog,
|
||||||
|
status=status,
|
||||||
python_package=python_package,
|
python_package=python_package,
|
||||||
python_ref=python_ref,
|
python_ref=python_ref,
|
||||||
webui_package=webui_package,
|
webui_package=webui_package,
|
||||||
@@ -345,30 +365,159 @@ def normalize_module_install_plan_item(
|
|||||||
artifact_integrity=artifact_integrity,
|
artifact_integrity=artifact_integrity,
|
||||||
data_safety_acknowledged=data_safety_acknowledged,
|
data_safety_acknowledged=data_safety_acknowledged,
|
||||||
destroy_data=destroy_data,
|
destroy_data=destroy_data,
|
||||||
status=status,
|
|
||||||
notes=notes,
|
notes=notes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_module_install_plan_item(item: _NormalizedModuleInstallPlanItem) -> None:
|
||||||
|
if item.action not in INSTALL_PLAN_ACTIONS:
|
||||||
|
raise ModuleManagementError(f"Unsupported install plan action for {item.module_id!r}: {item.action!r}.")
|
||||||
|
if item.status not in INSTALL_PLAN_STATUSES:
|
||||||
|
raise ModuleManagementError(f"Unsupported install plan status for {item.module_id!r}: {item.status!r}.")
|
||||||
|
if item.source not in INSTALL_PLAN_SOURCES:
|
||||||
|
raise ModuleManagementError(f"Unsupported install plan source for {item.module_id!r}: {item.source!r}.")
|
||||||
|
_validate_module_install_plan_item_requirements(item)
|
||||||
|
_validate_module_install_plan_item_refs(item)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_module_install_plan_item_requirements(item: _NormalizedModuleInstallPlanItem) -> None:
|
||||||
|
if item.action in {"install", "update"} and not item.python_ref:
|
||||||
|
raise ModuleManagementError(f"Install plan item {item.module_id!r} needs a Python package reference.")
|
||||||
|
if item.action == "uninstall" and not item.python_package:
|
||||||
|
raise ModuleManagementError(f"Uninstall plan item {item.module_id!r} needs a Python package name.")
|
||||||
|
if item.action != "uninstall" and item.destroy_data:
|
||||||
|
raise ModuleManagementError(f"Install plan item {item.module_id!r} can only destroy data during uninstall.")
|
||||||
|
if item.action in {"install", "update"} and bool(item.webui_package) != bool(item.webui_ref):
|
||||||
|
raise ModuleManagementError(f"Install plan item {item.module_id!r} needs both WebUI package and WebUI reference, or neither.")
|
||||||
|
if item.action == "uninstall" and item.webui_ref and not item.webui_package:
|
||||||
|
raise ModuleManagementError(f"Uninstall plan item {item.module_id!r} has a WebUI reference but no WebUI package.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_module_install_plan_item_refs(item: _NormalizedModuleInstallPlanItem) -> None:
|
||||||
|
if item.python_ref:
|
||||||
|
_validate_dependency_ref(item.python_ref, field="python_ref", module_id=item.module_id)
|
||||||
|
if item.webui_ref:
|
||||||
|
_validate_dependency_ref(item.webui_ref, field="webui_ref", module_id=item.module_id)
|
||||||
|
|
||||||
|
|
||||||
def plan_desired_enabled_modules(
|
def plan_desired_enabled_modules(
|
||||||
requested_enabled: Iterable[str],
|
requested_enabled: Iterable[str],
|
||||||
available: Mapping[str, ModuleManifest],
|
available: Mapping[str, ModuleManifest],
|
||||||
*,
|
*,
|
||||||
protected_modules: Iterable[str] = PROTECTED_MODULES,
|
protected_modules: Iterable[str] = PROTECTED_MODULES,
|
||||||
) -> ModuleStatePlan:
|
) -> ModuleStatePlan:
|
||||||
requested = {str(item).strip() for item in requested_enabled if str(item).strip()}
|
requested = _normalized_module_ids(requested_enabled)
|
||||||
protected = {str(item).strip() for item in protected_modules if str(item).strip()}
|
requested.update(_normalized_module_ids(protected_modules))
|
||||||
requested.update(protected)
|
|
||||||
missing = sorted(module_id for module_id in requested if module_id not in available)
|
missing = sorted(module_id for module_id in requested if module_id not in available)
|
||||||
if missing:
|
if missing:
|
||||||
raise ModuleManagementError("Unknown or uninstalled modules: " + ", ".join(missing))
|
raise ModuleManagementError("Unknown or uninstalled modules: " + ", ".join(missing))
|
||||||
|
|
||||||
added_dependencies: set[str] = set()
|
added_dependencies = _expand_enabled_module_closure(requested, available)
|
||||||
|
ordered = _order_enabled_modules(requested, available, added_dependencies)
|
||||||
|
return ModuleStatePlan(
|
||||||
|
enabled_modules=tuple(dict.fromkeys(ordered)),
|
||||||
|
added_dependencies=tuple(sorted(added_dependencies)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_module_ids(values: Iterable[str]) -> set[str]:
|
||||||
|
return {str(item).strip() for item in values if str(item).strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_enabled_module_closure(
|
||||||
|
requested: set[str],
|
||||||
|
available: Mapping[str, ModuleManifest],
|
||||||
|
) -> set[str]:
|
||||||
|
added: set[str] = set()
|
||||||
|
while True:
|
||||||
|
dependencies = {
|
||||||
|
dependency
|
||||||
|
for module_id in requested
|
||||||
|
for dependency in available[module_id].dependencies
|
||||||
|
}
|
||||||
|
_require_installed_modules(
|
||||||
|
dependencies,
|
||||||
|
available,
|
||||||
|
message="Required modules are not installed: ",
|
||||||
|
)
|
||||||
|
dependency_additions = dependencies - requested
|
||||||
|
added.update(dependency_additions)
|
||||||
|
requested.update(dependency_additions)
|
||||||
|
|
||||||
|
providers = _missing_capability_providers(requested, available)
|
||||||
|
_require_installed_modules(
|
||||||
|
providers,
|
||||||
|
available,
|
||||||
|
message="Required capability providers are not installed: ",
|
||||||
|
)
|
||||||
|
additions = providers - requested
|
||||||
|
if not additions:
|
||||||
|
return added
|
||||||
|
added.update(additions)
|
||||||
|
requested.update(additions)
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_capability_providers(
|
||||||
|
requested: set[str],
|
||||||
|
available: Mapping[str, ModuleManifest],
|
||||||
|
) -> set[str]:
|
||||||
|
provided = {
|
||||||
|
capability
|
||||||
|
for module_id in requested
|
||||||
|
for capability in available[module_id].capability_factories
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
provider_id
|
||||||
|
for module_id in requested
|
||||||
|
for capability in available[module_id].required_capabilities
|
||||||
|
if capability not in provided
|
||||||
|
for provider_id in (DEFAULT_CAPABILITY_PROVIDERS.get(capability),)
|
||||||
|
if provider_id is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _require_installed_modules(
|
||||||
|
module_ids: set[str],
|
||||||
|
available: Mapping[str, ModuleManifest],
|
||||||
|
*,
|
||||||
|
message: str,
|
||||||
|
) -> None:
|
||||||
|
missing = sorted(module_id for module_id in module_ids if module_id not in available)
|
||||||
|
if missing:
|
||||||
|
raise ModuleManagementError(message + ", ".join(missing))
|
||||||
|
|
||||||
|
|
||||||
|
def _order_enabled_modules(
|
||||||
|
requested: set[str],
|
||||||
|
available: Mapping[str, ModuleManifest],
|
||||||
|
added_dependencies: set[str],
|
||||||
|
) -> list[str]:
|
||||||
visiting: set[str] = set()
|
visiting: set[str] = set()
|
||||||
visited: set[str] = set()
|
visited: set[str] = set()
|
||||||
ordered: list[str] = []
|
ordered: list[str] = []
|
||||||
|
for module_id in sorted(requested):
|
||||||
|
_visit_enabled_module(
|
||||||
|
module_id,
|
||||||
|
requested=requested,
|
||||||
|
available=available,
|
||||||
|
added_dependencies=added_dependencies,
|
||||||
|
visiting=visiting,
|
||||||
|
visited=visited,
|
||||||
|
ordered=ordered,
|
||||||
|
)
|
||||||
|
return ordered
|
||||||
|
|
||||||
def visit(module_id: str) -> None:
|
|
||||||
|
def _visit_enabled_module(
|
||||||
|
module_id: str,
|
||||||
|
*,
|
||||||
|
requested: set[str],
|
||||||
|
available: Mapping[str, ModuleManifest],
|
||||||
|
added_dependencies: set[str],
|
||||||
|
visiting: set[str],
|
||||||
|
visited: set[str],
|
||||||
|
ordered: list[str],
|
||||||
|
) -> None:
|
||||||
if module_id in visited:
|
if module_id in visited:
|
||||||
return
|
return
|
||||||
if module_id in visiting:
|
if module_id in visiting:
|
||||||
@@ -379,22 +528,25 @@ def plan_desired_enabled_modules(
|
|||||||
visiting.add(module_id)
|
visiting.add(module_id)
|
||||||
for dependency_id in manifest.dependencies:
|
for dependency_id in manifest.dependencies:
|
||||||
if dependency_id not in available:
|
if dependency_id not in available:
|
||||||
raise ModuleManagementError(f"Module {module_id!r} depends on uninstalled module {dependency_id!r}.")
|
raise ModuleManagementError(
|
||||||
|
f"Module {module_id!r} depends on uninstalled module {dependency_id!r}."
|
||||||
|
)
|
||||||
if dependency_id not in requested:
|
if dependency_id not in requested:
|
||||||
added_dependencies.add(dependency_id)
|
added_dependencies.add(dependency_id)
|
||||||
requested.add(dependency_id)
|
requested.add(dependency_id)
|
||||||
visit(dependency_id)
|
_visit_enabled_module(
|
||||||
|
dependency_id,
|
||||||
|
requested=requested,
|
||||||
|
available=available,
|
||||||
|
added_dependencies=added_dependencies,
|
||||||
|
visiting=visiting,
|
||||||
|
visited=visited,
|
||||||
|
ordered=ordered,
|
||||||
|
)
|
||||||
visiting.remove(module_id)
|
visiting.remove(module_id)
|
||||||
visited.add(module_id)
|
visited.add(module_id)
|
||||||
ordered.append(module_id)
|
ordered.append(module_id)
|
||||||
|
|
||||||
for module_id in sorted(requested):
|
|
||||||
visit(module_id)
|
|
||||||
return ModuleStatePlan(
|
|
||||||
enabled_modules=tuple(dict.fromkeys(ordered)),
|
|
||||||
added_dependencies=tuple(sorted(added_dependencies)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def module_dependents(available: Mapping[str, ModuleManifest]) -> dict[str, tuple[str, ...]]:
|
def module_dependents(available: Mapping[str, ModuleManifest]) -> dict[str, tuple[str, ...]]:
|
||||||
dependents: dict[str, list[str]] = {module_id: [] for module_id in available}
|
dependents: dict[str, list[str]] = {module_id: [] for module_id in available}
|
||||||
|
|||||||
@@ -3,20 +3,20 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
from cryptography.exceptions import InvalidSignature
|
from cryptography.exceptions import InvalidSignature
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
||||||
|
|
||||||
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
||||||
|
from govoplan_core.security.http_fetch import fetch_http_text, is_http_url
|
||||||
|
|
||||||
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
|
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
|
||||||
CATALOG_MIGRATION_SAFETY = ("automatic", "requires_review", "forward_only", "destructive")
|
CATALOG_MIGRATION_SAFETY = ("automatic", "requires_review", "forward_only", "destructive")
|
||||||
@@ -28,6 +28,20 @@ CATALOG_MIGRATION_TASK_PHASES = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _CatalogValidationState:
|
||||||
|
modules: tuple[dict[str, object], ...]
|
||||||
|
channel: str | None
|
||||||
|
sequence: int | None
|
||||||
|
generated_at: str | None
|
||||||
|
not_before: str | None
|
||||||
|
expires_at: str | None
|
||||||
|
signature_state: dict[str, object]
|
||||||
|
freshness: dict[str, object]
|
||||||
|
replay: dict[str, object]
|
||||||
|
read_state: dict[str, object]
|
||||||
|
|
||||||
|
|
||||||
def module_package_catalog(
|
def module_package_catalog(
|
||||||
path: Path | str | None = None,
|
path: Path | str | None = None,
|
||||||
*,
|
*,
|
||||||
@@ -61,178 +75,143 @@ def validate_module_package_catalog(
|
|||||||
effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels
|
effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels
|
||||||
effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys()
|
effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys()
|
||||||
if catalog_source is not None and not _catalog_source_exists(catalog_source):
|
if catalog_source is not None and not _catalog_source_exists(catalog_source):
|
||||||
return {
|
return _catalog_error_result(catalog_source, error=f"Module package catalog does not exist: {catalog_source}")
|
||||||
"valid": False,
|
|
||||||
"configured": True,
|
|
||||||
"path": str(catalog_source),
|
|
||||||
"source": str(catalog_source),
|
|
||||||
"source_type": _catalog_source_type(catalog_source),
|
|
||||||
"cache_used": False,
|
|
||||||
"cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() is not None else None,
|
|
||||||
"modules": [],
|
|
||||||
"channel": None,
|
|
||||||
"sequence": None,
|
|
||||||
"generated_at": None,
|
|
||||||
"not_before": None,
|
|
||||||
"expires_at": None,
|
|
||||||
"signed": False,
|
|
||||||
"trusted": False,
|
|
||||||
"key_id": None,
|
|
||||||
"warnings": [],
|
|
||||||
"error": f"Module package catalog does not exist: {catalog_source}",
|
|
||||||
}
|
|
||||||
warnings: list[str] = []
|
|
||||||
read_state = {"cache_used": False, "cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() is not None else None}
|
|
||||||
try:
|
try:
|
||||||
payload, read_state = _read_catalog_payload_with_metadata(catalog_source)
|
state = _catalog_validation_state(catalog_source, trusted_keys=effective_trusted_keys)
|
||||||
|
except Exception as exc:
|
||||||
|
return _catalog_error_result(catalog_source, error=str(exc))
|
||||||
|
policy_error = _catalog_policy_error(
|
||||||
|
catalog_source,
|
||||||
|
state,
|
||||||
|
require_trusted=effective_require_trusted,
|
||||||
|
approved_channels=effective_approved_channels,
|
||||||
|
)
|
||||||
|
if policy_error is not None:
|
||||||
|
return policy_error
|
||||||
|
return _valid_catalog_result(catalog_source, state)
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_validation_state(
|
||||||
|
source: Path | str | None,
|
||||||
|
*,
|
||||||
|
trusted_keys: dict[str, str],
|
||||||
|
) -> _CatalogValidationState:
|
||||||
|
payload, read_state = _read_catalog_payload_with_metadata(source)
|
||||||
modules = _normalize_catalog_modules(payload)
|
modules = _normalize_catalog_modules(payload)
|
||||||
channel = _catalog_channel(payload)
|
channel = _catalog_channel(payload)
|
||||||
sequence = _catalog_sequence(payload)
|
sequence = _catalog_sequence(payload)
|
||||||
generated_at = _catalog_optional_text(payload, "generated_at")
|
return _CatalogValidationState(
|
||||||
not_before = _catalog_optional_text(payload, "not_before")
|
modules=modules,
|
||||||
expires_at = _catalog_optional_text(payload, "expires_at")
|
|
||||||
signature_state = _catalog_signature_state(payload, trusted_keys=effective_trusted_keys)
|
|
||||||
freshness = _catalog_freshness_state(payload)
|
|
||||||
replay = _catalog_replay_state(channel=channel, sequence=sequence)
|
|
||||||
except Exception as exc:
|
|
||||||
return {
|
|
||||||
"valid": False,
|
|
||||||
"configured": catalog_source is not None,
|
|
||||||
"path": str(catalog_source) if catalog_source is not None else None,
|
|
||||||
"source": str(catalog_source) if catalog_source is not None else None,
|
|
||||||
"source_type": _catalog_source_type(catalog_source),
|
|
||||||
"cache_used": bool(read_state.get("cache_used")),
|
|
||||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
|
||||||
"modules": [],
|
|
||||||
"channel": None,
|
|
||||||
"sequence": None,
|
|
||||||
"generated_at": None,
|
|
||||||
"not_before": None,
|
|
||||||
"expires_at": None,
|
|
||||||
"signed": False,
|
|
||||||
"trusted": False,
|
|
||||||
"key_id": None,
|
|
||||||
"warnings": [],
|
|
||||||
"error": str(exc),
|
|
||||||
}
|
|
||||||
if signature_state.get("fatal"):
|
|
||||||
return {
|
|
||||||
"valid": False,
|
|
||||||
"configured": catalog_source is not None,
|
|
||||||
"path": str(catalog_source) if catalog_source is not None else None,
|
|
||||||
"source": str(catalog_source) if catalog_source is not None else None,
|
|
||||||
"source_type": _catalog_source_type(catalog_source),
|
|
||||||
"cache_used": bool(read_state.get("cache_used")),
|
|
||||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
|
||||||
"modules": [],
|
|
||||||
"channel": channel,
|
|
||||||
"sequence": sequence,
|
|
||||||
"generated_at": generated_at,
|
|
||||||
"not_before": not_before,
|
|
||||||
"expires_at": expires_at,
|
|
||||||
"signed": signature_state["signed"],
|
|
||||||
"trusted": signature_state["trusted"],
|
|
||||||
"key_id": signature_state["key_id"],
|
|
||||||
"warnings": [],
|
|
||||||
"error": str(signature_state["error"] or "Module package catalog signature is invalid."),
|
|
||||||
}
|
|
||||||
if effective_approved_channels and channel not in effective_approved_channels:
|
|
||||||
return {
|
|
||||||
"valid": False,
|
|
||||||
"configured": catalog_source is not None,
|
|
||||||
"path": str(catalog_source) if catalog_source is not None else None,
|
|
||||||
"source": str(catalog_source) if catalog_source is not None else None,
|
|
||||||
"source_type": _catalog_source_type(catalog_source),
|
|
||||||
"cache_used": bool(read_state.get("cache_used")),
|
|
||||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
|
||||||
"modules": [],
|
|
||||||
"channel": channel,
|
|
||||||
"sequence": sequence,
|
|
||||||
"generated_at": generated_at,
|
|
||||||
"not_before": not_before,
|
|
||||||
"expires_at": expires_at,
|
|
||||||
"signed": signature_state["signed"],
|
|
||||||
"trusted": signature_state["trusted"],
|
|
||||||
"key_id": signature_state["key_id"],
|
|
||||||
"warnings": [],
|
|
||||||
"error": f"Module package catalog channel {channel!r} is not approved. Approved channels: {', '.join(effective_approved_channels)}.",
|
|
||||||
}
|
|
||||||
if effective_require_trusted and not signature_state["trusted"]:
|
|
||||||
return {
|
|
||||||
"valid": False,
|
|
||||||
"configured": catalog_source is not None,
|
|
||||||
"path": str(catalog_source) if catalog_source is not None else None,
|
|
||||||
"source": str(catalog_source) if catalog_source is not None else None,
|
|
||||||
"source_type": _catalog_source_type(catalog_source),
|
|
||||||
"cache_used": bool(read_state.get("cache_used")),
|
|
||||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
|
||||||
"modules": [],
|
|
||||||
"channel": channel,
|
|
||||||
"sequence": sequence,
|
|
||||||
"generated_at": generated_at,
|
|
||||||
"not_before": not_before,
|
|
||||||
"expires_at": expires_at,
|
|
||||||
"signed": signature_state["signed"],
|
|
||||||
"trusted": False,
|
|
||||||
"key_id": signature_state["key_id"],
|
|
||||||
"warnings": [],
|
|
||||||
"error": str(signature_state["error"] or "Module package catalog must be signed by a trusted key."),
|
|
||||||
}
|
|
||||||
if not freshness["valid"]:
|
|
||||||
return _invalid_catalog_result(
|
|
||||||
catalog_source,
|
|
||||||
modules=(),
|
|
||||||
channel=channel,
|
channel=channel,
|
||||||
sequence=sequence,
|
sequence=sequence,
|
||||||
generated_at=generated_at,
|
generated_at=_catalog_optional_text(payload, "generated_at"),
|
||||||
not_before=not_before,
|
not_before=_catalog_optional_text(payload, "not_before"),
|
||||||
expires_at=expires_at,
|
expires_at=_catalog_optional_text(payload, "expires_at"),
|
||||||
signature_state=signature_state,
|
signature_state=_catalog_signature_state(payload, trusted_keys=trusted_keys),
|
||||||
|
freshness=_catalog_freshness_state(payload),
|
||||||
|
replay=_catalog_replay_state(channel=channel, sequence=sequence),
|
||||||
read_state=read_state,
|
read_state=read_state,
|
||||||
error=str(freshness["error"]),
|
|
||||||
)
|
)
|
||||||
if not replay["valid"]:
|
|
||||||
|
|
||||||
|
def _catalog_policy_error(
|
||||||
|
source: Path | str | None,
|
||||||
|
state: _CatalogValidationState,
|
||||||
|
*,
|
||||||
|
require_trusted: bool,
|
||||||
|
approved_channels: tuple[str, ...],
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
if state.signature_state.get("fatal"):
|
||||||
|
return _invalid_catalog_state_result(source, state, error=str(state.signature_state["error"] or "Module package catalog signature is invalid."))
|
||||||
|
if approved_channels and state.channel not in approved_channels:
|
||||||
|
return _invalid_catalog_state_result(
|
||||||
|
source,
|
||||||
|
state,
|
||||||
|
error=f"Module package catalog channel {state.channel!r} is not approved. Approved channels: {', '.join(approved_channels)}.",
|
||||||
|
)
|
||||||
|
if require_trusted and not state.signature_state["trusted"]:
|
||||||
|
return _invalid_catalog_state_result(source, state, error=str(state.signature_state["error"] or "Module package catalog must be signed by a trusted key."))
|
||||||
|
if not state.freshness["valid"]:
|
||||||
|
return _invalid_catalog_state_result(source, state, error=str(state.freshness["error"]))
|
||||||
|
if not state.replay["valid"]:
|
||||||
|
return _invalid_catalog_state_result(source, state, error=str(state.replay["error"]))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_error_result(source: Path | str | None, *, error: str) -> dict[str, object]:
|
||||||
return _invalid_catalog_result(
|
return _invalid_catalog_result(
|
||||||
catalog_source,
|
source,
|
||||||
modules=(),
|
modules=(),
|
||||||
channel=channel,
|
channel=None,
|
||||||
sequence=sequence,
|
sequence=None,
|
||||||
generated_at=generated_at,
|
generated_at=None,
|
||||||
not_before=not_before,
|
not_before=None,
|
||||||
expires_at=expires_at,
|
expires_at=None,
|
||||||
signature_state=signature_state,
|
signature_state=_unsigned_catalog_signature_state(),
|
||||||
read_state=read_state,
|
read_state=_default_catalog_read_state(),
|
||||||
error=str(replay["error"]),
|
error=error,
|
||||||
)
|
)
|
||||||
warnings.extend(str(item) for item in freshness.get("warnings", ()) if item)
|
|
||||||
warnings.extend(str(item) for item in replay.get("warnings", ()) if item)
|
|
||||||
warnings.extend(_catalog_interface_warnings(modules))
|
def _invalid_catalog_state_result(source: Path | str | None, state: _CatalogValidationState, *, error: str) -> dict[str, object]:
|
||||||
if not signature_state["signed"]:
|
return _invalid_catalog_result(
|
||||||
warnings.append("Catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
|
source,
|
||||||
elif not signature_state["trusted"]:
|
modules=(),
|
||||||
warnings.append(str(signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
|
channel=state.channel,
|
||||||
|
sequence=state.sequence,
|
||||||
|
generated_at=state.generated_at,
|
||||||
|
not_before=state.not_before,
|
||||||
|
expires_at=state.expires_at,
|
||||||
|
signature_state=state.signature_state,
|
||||||
|
read_state=state.read_state,
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_catalog_result(source: Path | str | None, state: _CatalogValidationState) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"valid": True,
|
"valid": True,
|
||||||
"configured": catalog_source is not None and _catalog_source_exists(catalog_source),
|
"configured": source is not None and _catalog_source_exists(source),
|
||||||
"path": str(catalog_source) if catalog_source is not None else None,
|
"path": str(source) if source is not None else None,
|
||||||
"source": str(catalog_source) if catalog_source is not None else None,
|
"source": str(source) if source is not None else None,
|
||||||
"source_type": _catalog_source_type(catalog_source),
|
"source_type": _catalog_source_type(source),
|
||||||
"cache_used": bool(read_state.get("cache_used")),
|
"cache_used": bool(state.read_state.get("cache_used")),
|
||||||
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
|
"cache_path": state.read_state.get("cache_path") if isinstance(state.read_state.get("cache_path"), str) else None,
|
||||||
"modules": list(modules),
|
"modules": list(state.modules),
|
||||||
"channel": channel,
|
"channel": state.channel,
|
||||||
"sequence": sequence,
|
"sequence": state.sequence,
|
||||||
"generated_at": generated_at,
|
"generated_at": state.generated_at,
|
||||||
"not_before": not_before,
|
"not_before": state.not_before,
|
||||||
"expires_at": expires_at,
|
"expires_at": state.expires_at,
|
||||||
"signed": signature_state["signed"],
|
"signed": state.signature_state["signed"],
|
||||||
"trusted": signature_state["trusted"],
|
"trusted": state.signature_state["trusted"],
|
||||||
"key_id": signature_state["key_id"],
|
"key_id": state.signature_state["key_id"],
|
||||||
"warnings": warnings,
|
"warnings": _catalog_validation_warnings(state),
|
||||||
"error": None,
|
"error": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_validation_warnings(state: _CatalogValidationState) -> list[str]:
|
||||||
|
warnings: list[str] = []
|
||||||
|
warnings.extend(str(item) for item in state.freshness.get("warnings", ()) if item)
|
||||||
|
warnings.extend(str(item) for item in state.replay.get("warnings", ()) if item)
|
||||||
|
warnings.extend(_catalog_interface_warnings(state.modules))
|
||||||
|
if not state.signature_state["signed"]:
|
||||||
|
warnings.append("Catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
|
||||||
|
elif not state.signature_state["trusted"]:
|
||||||
|
warnings.append(str(state.signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
|
def _default_catalog_read_state() -> dict[str, object]:
|
||||||
|
cache_path = _configured_catalog_cache_path()
|
||||||
|
return {"cache_used": False, "cache_path": str(cache_path) if cache_path is not None else None}
|
||||||
|
|
||||||
|
|
||||||
|
def _unsigned_catalog_signature_state() -> dict[str, object]:
|
||||||
|
return {"signed": False, "trusted": False, "key_id": None}
|
||||||
|
|
||||||
|
|
||||||
def sign_module_package_catalog(
|
def sign_module_package_catalog(
|
||||||
*,
|
*,
|
||||||
path: Path,
|
path: Path,
|
||||||
@@ -350,17 +329,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
|
|||||||
|
|
||||||
|
|
||||||
def _read_trusted_keys_url(url: str) -> str:
|
def _read_trusted_keys_url(url: str) -> str:
|
||||||
if not _is_http_url(url):
|
|
||||||
raise ValueError("Trusted catalog key URL must use http:// or https://.")
|
|
||||||
cache_path = _configured_trusted_keys_cache_path()
|
cache_path = _configured_trusted_keys_cache_path()
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(url, timeout=15) as response:
|
body = fetch_http_text(url, timeout=15, label="Trusted catalog key URL")
|
||||||
body = response.read().decode("utf-8")
|
|
||||||
if cache_path is not None:
|
if cache_path is not None:
|
||||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
cache_path.write_text(body, encoding="utf-8")
|
cache_path.write_text(body, encoding="utf-8")
|
||||||
return body
|
return body
|
||||||
except (OSError, urllib.error.URLError):
|
except OSError:
|
||||||
if cache_path is not None and cache_path.exists():
|
if cache_path is not None and cache_path.exists():
|
||||||
return cache_path.read_text(encoding="utf-8")
|
return cache_path.read_text(encoding="utf-8")
|
||||||
raise
|
raise
|
||||||
@@ -421,13 +397,12 @@ def _read_catalog_url(url: str) -> str:
|
|||||||
def _read_catalog_url_with_metadata(url: str) -> tuple[str, bool]:
|
def _read_catalog_url_with_metadata(url: str) -> tuple[str, bool]:
|
||||||
cache_path = _configured_catalog_cache_path()
|
cache_path = _configured_catalog_cache_path()
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(url, timeout=15) as response:
|
body = fetch_http_text(url, timeout=15, label="Module package catalog URL")
|
||||||
body = response.read().decode("utf-8")
|
|
||||||
if cache_path is not None:
|
if cache_path is not None:
|
||||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
cache_path.write_text(body, encoding="utf-8")
|
cache_path.write_text(body, encoding="utf-8")
|
||||||
return body, False
|
return body, False
|
||||||
except (OSError, urllib.error.URLError):
|
except OSError:
|
||||||
if cache_path is not None and cache_path.exists():
|
if cache_path is not None and cache_path.exists():
|
||||||
return cache_path.read_text(encoding="utf-8"), True
|
return cache_path.read_text(encoding="utf-8"), True
|
||||||
raise
|
raise
|
||||||
@@ -935,7 +910,7 @@ def _catalog_source_type(source: Path | str | None) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def _is_http_url(value: str) -> bool:
|
def _is_http_url(value: str) -> bool:
|
||||||
return value.startswith(("https://", "http://"))
|
return is_http_url(value)
|
||||||
|
|
||||||
|
|
||||||
def _invalid_catalog_result(
|
def _invalid_catalog_result(
|
||||||
|
|||||||
@@ -4,8 +4,15 @@ 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.views import ViewSurface
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
from govoplan_core.core.search import (
|
||||||
|
SearchProviderRegistration,
|
||||||
|
SearchSourceProviderRegistration,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
|
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
|
||||||
@@ -45,6 +52,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 +64,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 +76,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 +98,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 +216,17 @@ 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",
|
||||||
|
"configuration_package",
|
||||||
|
"wiki",
|
||||||
|
"repository",
|
||||||
|
]
|
||||||
|
DocumentationSourceState = Literal["configured", "disabled", "unavailable"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -236,6 +268,35 @@ class DocumentationTopic:
|
|||||||
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 +308,40 @@ 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"
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
class ResourceAclProvider(Protocol):
|
class ResourceAclProvider(Protocol):
|
||||||
resource_type: str
|
resource_type: str
|
||||||
|
|
||||||
@@ -261,6 +356,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 +406,21 @@ 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)
|
||||||
|
search_providers: tuple["SearchProviderRegistration", ...] = ()
|
||||||
|
search_sources: tuple["SearchSourceProviderRegistration", ...] = ()
|
||||||
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, ...] = ()
|
||||||
|
|||||||
64
src/govoplan_core/core/notifications.py
Normal file
64
src/govoplan_core/core/notifications.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_NOTIFICATIONS_DISPATCH = "notifications.dispatch"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class NotificationDispatchRequest:
|
||||||
|
tenant_id: str
|
||||||
|
source_module: str
|
||||||
|
source_resource_type: str
|
||||||
|
source_resource_id: str | None
|
||||||
|
event_kind: str
|
||||||
|
channel: str = "inbox"
|
||||||
|
recipient: str | None = None
|
||||||
|
recipient_type: str | None = None
|
||||||
|
recipient_id: str | None = None
|
||||||
|
recipient_label: str | None = None
|
||||||
|
subject: str | None = None
|
||||||
|
body_text: str | None = None
|
||||||
|
body_html: str | None = None
|
||||||
|
action_url: str | None = None
|
||||||
|
priority: int = 0
|
||||||
|
not_before_at: datetime | None = None
|
||||||
|
payload: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class NotificationDispatchProvider(Protocol):
|
||||||
|
def enqueue_notification(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
request: NotificationDispatchRequest,
|
||||||
|
*,
|
||||||
|
enqueue_delivery: bool = True,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def deliver_notification(self, session: object, *, notification_id: str) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def deliver_pending(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def notification_dispatch_provider(registry: object | None) -> NotificationDispatchProvider | None:
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_NOTIFICATIONS_DISPATCH):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_NOTIFICATIONS_DISPATCH)
|
||||||
|
return capability if isinstance(capability, NotificationDispatchProvider) else None
|
||||||
@@ -2,13 +2,57 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
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)
|
||||||
@@ -37,6 +81,141 @@ class OrganizationFunctionRef:
|
|||||||
status: OrganizationStatus = "active"
|
status: OrganizationStatus = "active"
|
||||||
|
|
||||||
|
|
||||||
|
@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"
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
class OrganizationDirectory(Protocol):
|
class OrganizationDirectory(Protocol):
|
||||||
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
|
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
|
||||||
@@ -55,3 +234,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",
|
||||||
|
]
|
||||||
|
|||||||
965
src/govoplan_core/core/ownership.py
Normal file
965
src/govoplan_core/core/ownership.py
Normal file
@@ -0,0 +1,965 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from enum import StrEnum
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import Any, Protocol, runtime_checkable
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Index, Integer, JSON, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||||
|
|
||||||
|
from govoplan_core.db.base import Base, TimestampMixin, utcnow
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipTransferKind(StrEnum):
|
||||||
|
OWNER_INITIATED = "owner_initiated"
|
||||||
|
TARGET_REQUESTED = "target_requested"
|
||||||
|
ADMINISTRATIVE_RECOVERY = "administrative_recovery"
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipTransferStatus(StrEnum):
|
||||||
|
AWAITING_OWNER_APPROVAL = "awaiting_owner_approval"
|
||||||
|
AWAITING_TARGET_ACCEPTANCE = "awaiting_target_acceptance"
|
||||||
|
AWAITING_RECOVERY_APPROVALS = "awaiting_recovery_approvals"
|
||||||
|
RECOVERY_SCHEDULED = "recovery_scheduled"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
DECLINED = "declined"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
|
||||||
|
|
||||||
|
TERMINAL_OWNERSHIP_TRANSFER_STATUSES = frozenset(
|
||||||
|
{
|
||||||
|
OwnershipTransferStatus.COMPLETED.value,
|
||||||
|
OwnershipTransferStatus.DECLINED.value,
|
||||||
|
OwnershipTransferStatus.CANCELLED.value,
|
||||||
|
OwnershipTransferStatus.EXPIRED.value,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipTransferError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipAuthorizationError(PermissionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipIdempotencyConflict(OwnershipTransferError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipTransferExpired(OwnershipTransferError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OwnershipSubjectRef:
|
||||||
|
type: str
|
||||||
|
id: str
|
||||||
|
label: str | None = None
|
||||||
|
scopes: frozenset[str] = frozenset()
|
||||||
|
group_ids: frozenset[str] = frozenset()
|
||||||
|
recently_authenticated: bool = False
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.type.strip() or not self.id.strip():
|
||||||
|
raise ValueError("Ownership subjects require a type and id")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OwnershipResourceRef:
|
||||||
|
module_id: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.module_id.strip() or not self.resource_type.strip() or not self.resource_id.strip():
|
||||||
|
raise ValueError("Ownership resources require module, type, and id")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OwnershipActionDecision:
|
||||||
|
allowed: bool
|
||||||
|
reason: str | None = None
|
||||||
|
requirements: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OwnershipTransferPolicy:
|
||||||
|
default_expiry_days: int = 7
|
||||||
|
min_expiry_days: int = 1
|
||||||
|
max_expiry_days: int = 30
|
||||||
|
recovery_assurance_profile: str = "standard"
|
||||||
|
recovery_required_approvals: int = 2
|
||||||
|
recovery_delay_hours: int = 24
|
||||||
|
recent_authentication_required: bool = True
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not 1 <= self.min_expiry_days <= self.default_expiry_days <= self.max_expiry_days:
|
||||||
|
raise ValueError("Ownership transfer expiry policy is inconsistent")
|
||||||
|
if self.recovery_required_approvals < 1:
|
||||||
|
raise ValueError("Ownership recovery requires at least one approval")
|
||||||
|
if self.recovery_delay_hours < 0:
|
||||||
|
raise ValueError("Ownership recovery delay cannot be negative")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def development(cls) -> OwnershipTransferPolicy:
|
||||||
|
return cls(
|
||||||
|
recovery_assurance_profile="development-single-admin",
|
||||||
|
recovery_required_approvals=1,
|
||||||
|
recovery_delay_hours=0,
|
||||||
|
recent_authentication_required=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ResourceOwnershipProvider(Protocol):
|
||||||
|
def current_owner(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
resource_id: str,
|
||||||
|
) -> OwnershipSubjectRef | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def authorize_ownership_action(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
resource_id: str,
|
||||||
|
action: str,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
current_owner: OwnershipSubjectRef,
|
||||||
|
target_owner: OwnershipSubjectRef,
|
||||||
|
) -> OwnershipActionDecision:
|
||||||
|
...
|
||||||
|
|
||||||
|
def apply_owner(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
resource_id: str,
|
||||||
|
expected_owner: OwnershipSubjectRef,
|
||||||
|
target_owner: OwnershipSubjectRef,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
reason: str | None,
|
||||||
|
) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OwnershipProviderRegistration:
|
||||||
|
resource_type: str
|
||||||
|
provider: ResourceOwnershipProvider
|
||||||
|
module_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipTransfer(Base, TimestampMixin):
|
||||||
|
__tablename__ = "core_ownership_transfers"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"resource_module",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_core_ownership_transfer_idempotency",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_core_ownership_transfer_resource",
|
||||||
|
"tenant_id",
|
||||||
|
"resource_module",
|
||||||
|
"resource_type",
|
||||||
|
"resource_id",
|
||||||
|
"status",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_core_ownership_transfer_expiry",
|
||||||
|
"status",
|
||||||
|
"expires_at",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
primary_key=True,
|
||||||
|
default=lambda: str(uuid.uuid4()),
|
||||||
|
)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
resource_module: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
resource_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
resource_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
current_owner_type: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||||
|
current_owner_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
target_owner_type: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||||
|
target_owner_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
initiated_by_type: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||||
|
initiated_by_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
owner_approved_by_type: Mapped[str | None] = mapped_column(String(40))
|
||||||
|
owner_approved_by_id: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
target_accepted_by_type: Mapped[str | None] = mapped_column(String(40))
|
||||||
|
target_accepted_by_id: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
reason: Mapped[str | None] = mapped_column(Text)
|
||||||
|
assurance_profile: Mapped[str | None] = mapped_column(String(80))
|
||||||
|
required_approvals: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
approvals: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
decisions: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
idempotency_key: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
canonical_request_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
execute_after: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
declined_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
expired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
metadata_: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
"metadata",
|
||||||
|
JSON,
|
||||||
|
default=dict,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def start_owner_initiated_transfer(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
resource: OwnershipResourceRef,
|
||||||
|
provider: ResourceOwnershipProvider,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
target_owner: OwnershipSubjectRef,
|
||||||
|
idempotency_key: str,
|
||||||
|
reason: str | None = None,
|
||||||
|
expiry_days: int | None = None,
|
||||||
|
policy: OwnershipTransferPolicy = OwnershipTransferPolicy(),
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> OwnershipTransfer:
|
||||||
|
return _start_transfer(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
resource=resource,
|
||||||
|
provider=provider,
|
||||||
|
actor=actor,
|
||||||
|
target_owner=target_owner,
|
||||||
|
kind=OwnershipTransferKind.OWNER_INITIATED,
|
||||||
|
initial_status=OwnershipTransferStatus.AWAITING_TARGET_ACCEPTANCE,
|
||||||
|
authorization_action="propose_transfer",
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
reason=reason,
|
||||||
|
expiry_days=expiry_days,
|
||||||
|
policy=policy,
|
||||||
|
now=now,
|
||||||
|
assurance_profile=None,
|
||||||
|
required_approvals=1,
|
||||||
|
execute_after=None,
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def request_ownership(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
resource: OwnershipResourceRef,
|
||||||
|
provider: ResourceOwnershipProvider,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
target_owner: OwnershipSubjectRef | None = None,
|
||||||
|
idempotency_key: str,
|
||||||
|
reason: str | None = None,
|
||||||
|
expiry_days: int | None = None,
|
||||||
|
policy: OwnershipTransferPolicy = OwnershipTransferPolicy(),
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> OwnershipTransfer:
|
||||||
|
requested_owner = target_owner or actor
|
||||||
|
return _start_transfer(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
resource=resource,
|
||||||
|
provider=provider,
|
||||||
|
actor=actor,
|
||||||
|
target_owner=requested_owner,
|
||||||
|
kind=OwnershipTransferKind.TARGET_REQUESTED,
|
||||||
|
initial_status=OwnershipTransferStatus.AWAITING_OWNER_APPROVAL,
|
||||||
|
authorization_action="request_ownership",
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
reason=reason,
|
||||||
|
expiry_days=expiry_days,
|
||||||
|
policy=policy,
|
||||||
|
now=now,
|
||||||
|
assurance_profile=None,
|
||||||
|
required_approvals=1,
|
||||||
|
execute_after=None,
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def approve_ownership_request(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
transfer: OwnershipTransfer,
|
||||||
|
provider: ResourceOwnershipProvider,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> OwnershipTransfer:
|
||||||
|
effective_now = _utc(now)
|
||||||
|
_require_active(session, transfer, effective_now)
|
||||||
|
if (
|
||||||
|
transfer.kind != OwnershipTransferKind.TARGET_REQUESTED.value
|
||||||
|
or transfer.status != OwnershipTransferStatus.AWAITING_OWNER_APPROVAL.value
|
||||||
|
):
|
||||||
|
raise OwnershipTransferError("Ownership request is not awaiting owner approval")
|
||||||
|
_authorize(provider, session, transfer, actor, "approve_requested_transfer")
|
||||||
|
transfer.owner_approved_by_type = actor.type
|
||||||
|
transfer.owner_approved_by_id = actor.id
|
||||||
|
transfer.status = OwnershipTransferStatus.AWAITING_TARGET_ACCEPTANCE.value
|
||||||
|
_touch(transfer)
|
||||||
|
_record_decision(
|
||||||
|
transfer,
|
||||||
|
action="owner_approved",
|
||||||
|
actor=actor,
|
||||||
|
decided_at=effective_now,
|
||||||
|
)
|
||||||
|
_emit_transfer_event(session, transfer, "owner_approved", actor)
|
||||||
|
session.flush()
|
||||||
|
return transfer
|
||||||
|
|
||||||
|
|
||||||
|
def accept_ownership_transfer(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
transfer: OwnershipTransfer,
|
||||||
|
provider: ResourceOwnershipProvider,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> OwnershipTransfer:
|
||||||
|
effective_now = _utc(now)
|
||||||
|
_require_active(session, transfer, effective_now)
|
||||||
|
if transfer.status != OwnershipTransferStatus.AWAITING_TARGET_ACCEPTANCE.value:
|
||||||
|
raise OwnershipTransferError("Ownership transfer is not awaiting target acceptance")
|
||||||
|
action = (
|
||||||
|
"accept_group_transfer"
|
||||||
|
if transfer.target_owner_type == "group"
|
||||||
|
else "accept_transfer"
|
||||||
|
)
|
||||||
|
_authorize(provider, session, transfer, actor, action)
|
||||||
|
current_owner = _owner_ref(transfer, target=False)
|
||||||
|
target_owner = _owner_ref(transfer, target=True)
|
||||||
|
provider.apply_owner(
|
||||||
|
session,
|
||||||
|
tenant_id=transfer.tenant_id,
|
||||||
|
resource_id=transfer.resource_id,
|
||||||
|
expected_owner=current_owner,
|
||||||
|
target_owner=target_owner,
|
||||||
|
actor=actor,
|
||||||
|
reason=transfer.reason,
|
||||||
|
)
|
||||||
|
transfer.target_accepted_by_type = actor.type
|
||||||
|
transfer.target_accepted_by_id = actor.id
|
||||||
|
transfer.status = OwnershipTransferStatus.COMPLETED.value
|
||||||
|
transfer.completed_at = effective_now
|
||||||
|
_touch(transfer)
|
||||||
|
_record_decision(
|
||||||
|
transfer,
|
||||||
|
action="accepted",
|
||||||
|
actor=actor,
|
||||||
|
decided_at=effective_now,
|
||||||
|
)
|
||||||
|
_emit_transfer_event(session, transfer, "completed", actor)
|
||||||
|
session.flush()
|
||||||
|
return transfer
|
||||||
|
|
||||||
|
|
||||||
|
def decline_ownership_transfer(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
transfer: OwnershipTransfer,
|
||||||
|
provider: ResourceOwnershipProvider,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> OwnershipTransfer:
|
||||||
|
effective_now = _utc(now)
|
||||||
|
_require_active(session, transfer, effective_now)
|
||||||
|
_authorize(provider, session, transfer, actor, "decline_transfer")
|
||||||
|
transfer.status = OwnershipTransferStatus.DECLINED.value
|
||||||
|
transfer.declined_at = effective_now
|
||||||
|
_touch(transfer)
|
||||||
|
_record_decision(
|
||||||
|
transfer,
|
||||||
|
action="declined",
|
||||||
|
actor=actor,
|
||||||
|
decided_at=effective_now,
|
||||||
|
)
|
||||||
|
_emit_transfer_event(session, transfer, "declined", actor)
|
||||||
|
session.flush()
|
||||||
|
return transfer
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_ownership_transfer(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
transfer: OwnershipTransfer,
|
||||||
|
provider: ResourceOwnershipProvider,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> OwnershipTransfer:
|
||||||
|
effective_now = _utc(now)
|
||||||
|
_require_active(session, transfer, effective_now)
|
||||||
|
_authorize(provider, session, transfer, actor, "cancel_transfer")
|
||||||
|
transfer.status = OwnershipTransferStatus.CANCELLED.value
|
||||||
|
transfer.cancelled_at = effective_now
|
||||||
|
_touch(transfer)
|
||||||
|
_record_decision(
|
||||||
|
transfer,
|
||||||
|
action="cancelled",
|
||||||
|
actor=actor,
|
||||||
|
decided_at=effective_now,
|
||||||
|
)
|
||||||
|
_emit_transfer_event(session, transfer, "cancelled", actor)
|
||||||
|
session.flush()
|
||||||
|
return transfer
|
||||||
|
|
||||||
|
|
||||||
|
def start_administrative_recovery(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
resource: OwnershipResourceRef,
|
||||||
|
provider: ResourceOwnershipProvider,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
target_owner: OwnershipSubjectRef,
|
||||||
|
idempotency_key: str,
|
||||||
|
reason: str,
|
||||||
|
policy: OwnershipTransferPolicy = OwnershipTransferPolicy(),
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> OwnershipTransfer:
|
||||||
|
if not reason.strip():
|
||||||
|
raise OwnershipTransferError("Administrative recovery requires a reason")
|
||||||
|
if policy.recent_authentication_required and not actor.recently_authenticated:
|
||||||
|
raise OwnershipAuthorizationError(
|
||||||
|
"Administrative recovery requires recent authentication"
|
||||||
|
)
|
||||||
|
effective_now = _utc(now)
|
||||||
|
transfer = _start_transfer(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
resource=resource,
|
||||||
|
provider=provider,
|
||||||
|
actor=actor,
|
||||||
|
target_owner=target_owner,
|
||||||
|
kind=OwnershipTransferKind.ADMINISTRATIVE_RECOVERY,
|
||||||
|
initial_status=OwnershipTransferStatus.AWAITING_RECOVERY_APPROVALS,
|
||||||
|
authorization_action="request_recovery",
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
reason=reason,
|
||||||
|
expiry_days=policy.default_expiry_days,
|
||||||
|
policy=policy,
|
||||||
|
now=effective_now,
|
||||||
|
assurance_profile=policy.recovery_assurance_profile,
|
||||||
|
required_approvals=policy.recovery_required_approvals,
|
||||||
|
execute_after=effective_now
|
||||||
|
+ timedelta(hours=policy.recovery_delay_hours),
|
||||||
|
metadata={
|
||||||
|
"recent_authentication_required": (
|
||||||
|
policy.recent_authentication_required
|
||||||
|
),
|
||||||
|
"encryption_keys_included": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return transfer
|
||||||
|
|
||||||
|
|
||||||
|
def approve_administrative_recovery(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
transfer: OwnershipTransfer,
|
||||||
|
provider: ResourceOwnershipProvider,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> OwnershipTransfer:
|
||||||
|
effective_now = _utc(now)
|
||||||
|
_require_active(session, transfer, effective_now)
|
||||||
|
_require_recovery_authentication(transfer, actor)
|
||||||
|
if (
|
||||||
|
transfer.kind != OwnershipTransferKind.ADMINISTRATIVE_RECOVERY.value
|
||||||
|
or transfer.status
|
||||||
|
not in {
|
||||||
|
OwnershipTransferStatus.AWAITING_RECOVERY_APPROVALS.value,
|
||||||
|
OwnershipTransferStatus.RECOVERY_SCHEDULED.value,
|
||||||
|
}
|
||||||
|
):
|
||||||
|
raise OwnershipTransferError("Ownership recovery is not awaiting approval")
|
||||||
|
_authorize(provider, session, transfer, actor, "approve_recovery")
|
||||||
|
approvals = list(transfer.approvals or [])
|
||||||
|
if any(
|
||||||
|
item.get("actor_type") == actor.type and item.get("actor_id") == actor.id
|
||||||
|
for item in approvals
|
||||||
|
):
|
||||||
|
return transfer
|
||||||
|
approvals.append(
|
||||||
|
{
|
||||||
|
"actor_type": actor.type,
|
||||||
|
"actor_id": actor.id,
|
||||||
|
"approved_at": effective_now.isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
transfer.approvals = approvals
|
||||||
|
if len(approvals) >= transfer.required_approvals:
|
||||||
|
transfer.status = OwnershipTransferStatus.RECOVERY_SCHEDULED.value
|
||||||
|
_touch(transfer)
|
||||||
|
_record_decision(
|
||||||
|
transfer,
|
||||||
|
action="recovery_approved",
|
||||||
|
actor=actor,
|
||||||
|
decided_at=effective_now,
|
||||||
|
details={"approval_count": len(approvals)},
|
||||||
|
)
|
||||||
|
_emit_transfer_event(session, transfer, "recovery_approved", actor)
|
||||||
|
session.flush()
|
||||||
|
return transfer
|
||||||
|
|
||||||
|
|
||||||
|
def execute_administrative_recovery(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
transfer: OwnershipTransfer,
|
||||||
|
provider: ResourceOwnershipProvider,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> OwnershipTransfer:
|
||||||
|
effective_now = _utc(now)
|
||||||
|
_require_active(session, transfer, effective_now)
|
||||||
|
_require_recovery_authentication(transfer, actor)
|
||||||
|
if transfer.status != OwnershipTransferStatus.RECOVERY_SCHEDULED.value:
|
||||||
|
raise OwnershipTransferError("Ownership recovery has not reached its approval quorum")
|
||||||
|
if transfer.execute_after and _utc(transfer.execute_after) > effective_now:
|
||||||
|
raise OwnershipTransferError("Ownership recovery assurance delay has not elapsed")
|
||||||
|
_authorize(provider, session, transfer, actor, "execute_recovery")
|
||||||
|
provider.apply_owner(
|
||||||
|
session,
|
||||||
|
tenant_id=transfer.tenant_id,
|
||||||
|
resource_id=transfer.resource_id,
|
||||||
|
expected_owner=_owner_ref(transfer, target=False),
|
||||||
|
target_owner=_owner_ref(transfer, target=True),
|
||||||
|
actor=actor,
|
||||||
|
reason=transfer.reason,
|
||||||
|
)
|
||||||
|
transfer.status = OwnershipTransferStatus.COMPLETED.value
|
||||||
|
transfer.completed_at = effective_now
|
||||||
|
_touch(transfer)
|
||||||
|
_record_decision(
|
||||||
|
transfer,
|
||||||
|
action="recovery_executed",
|
||||||
|
actor=actor,
|
||||||
|
decided_at=effective_now,
|
||||||
|
)
|
||||||
|
_emit_transfer_event(session, transfer, "recovery_completed", actor)
|
||||||
|
session.flush()
|
||||||
|
return transfer
|
||||||
|
|
||||||
|
|
||||||
|
def expire_due_ownership_transfers(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
limit: int = 250,
|
||||||
|
) -> int:
|
||||||
|
effective_now = _utc(now)
|
||||||
|
rows = (
|
||||||
|
session.query(OwnershipTransfer)
|
||||||
|
.filter(
|
||||||
|
OwnershipTransfer.status.notin_(
|
||||||
|
sorted(TERMINAL_OWNERSHIP_TRANSFER_STATUSES)
|
||||||
|
),
|
||||||
|
OwnershipTransfer.expires_at <= effective_now,
|
||||||
|
)
|
||||||
|
.order_by(OwnershipTransfer.expires_at.asc(), OwnershipTransfer.id.asc())
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for transfer in rows:
|
||||||
|
transfer.status = OwnershipTransferStatus.EXPIRED.value
|
||||||
|
transfer.expired_at = effective_now
|
||||||
|
_touch(transfer)
|
||||||
|
_record_decision(
|
||||||
|
transfer,
|
||||||
|
action="expired",
|
||||||
|
actor=None,
|
||||||
|
decided_at=effective_now,
|
||||||
|
)
|
||||||
|
_emit_transfer_event(session, transfer, "expired", None)
|
||||||
|
session.flush()
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _start_transfer(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
resource: OwnershipResourceRef,
|
||||||
|
provider: ResourceOwnershipProvider,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
target_owner: OwnershipSubjectRef,
|
||||||
|
kind: OwnershipTransferKind,
|
||||||
|
initial_status: OwnershipTransferStatus,
|
||||||
|
authorization_action: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
reason: str | None,
|
||||||
|
expiry_days: int | None,
|
||||||
|
policy: OwnershipTransferPolicy,
|
||||||
|
now: datetime | None,
|
||||||
|
assurance_profile: str | None,
|
||||||
|
required_approvals: int,
|
||||||
|
execute_after: datetime | None,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
) -> OwnershipTransfer:
|
||||||
|
if not resource.module_id or resource.module_id != resource.module_id.strip():
|
||||||
|
raise OwnershipTransferError("Ownership resource module id is invalid")
|
||||||
|
clean_key = idempotency_key.strip()
|
||||||
|
if not clean_key or len(clean_key) > 200:
|
||||||
|
raise OwnershipTransferError("A bounded ownership idempotency key is required")
|
||||||
|
effective_now = _utc(now)
|
||||||
|
days = policy.default_expiry_days if expiry_days is None else expiry_days
|
||||||
|
if not policy.min_expiry_days <= days <= policy.max_expiry_days:
|
||||||
|
raise OwnershipTransferError(
|
||||||
|
"Ownership transfer expiry is outside the effective policy"
|
||||||
|
)
|
||||||
|
current_owner = provider.current_owner(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
resource_id=resource.resource_id,
|
||||||
|
)
|
||||||
|
if current_owner is None:
|
||||||
|
raise OwnershipTransferError("Owned resource was not found")
|
||||||
|
if current_owner.type == target_owner.type and current_owner.id == target_owner.id:
|
||||||
|
raise OwnershipTransferError("Target is already the resource owner")
|
||||||
|
_authorize_direct(
|
||||||
|
provider,
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
resource_id=resource.resource_id,
|
||||||
|
current_owner=current_owner,
|
||||||
|
target_owner=target_owner,
|
||||||
|
actor=actor,
|
||||||
|
action=authorization_action,
|
||||||
|
)
|
||||||
|
request_hash = _request_hash(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
resource=resource,
|
||||||
|
kind=kind,
|
||||||
|
current_owner=current_owner,
|
||||||
|
target_owner=target_owner,
|
||||||
|
actor=actor,
|
||||||
|
reason=reason,
|
||||||
|
expiry_days=days,
|
||||||
|
assurance_profile=assurance_profile,
|
||||||
|
required_approvals=required_approvals,
|
||||||
|
execute_after=execute_after,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
existing = (
|
||||||
|
session.query(OwnershipTransfer)
|
||||||
|
.filter(
|
||||||
|
OwnershipTransfer.tenant_id == tenant_id,
|
||||||
|
OwnershipTransfer.resource_module == resource.module_id,
|
||||||
|
OwnershipTransfer.idempotency_key == clean_key,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
if existing.canonical_request_hash != request_hash:
|
||||||
|
raise OwnershipIdempotencyConflict(
|
||||||
|
"Ownership idempotency key is already bound to another request"
|
||||||
|
)
|
||||||
|
return existing
|
||||||
|
transfer = OwnershipTransfer(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
resource_module=resource.module_id,
|
||||||
|
resource_type=resource.resource_type,
|
||||||
|
resource_id=resource.resource_id,
|
||||||
|
kind=kind.value,
|
||||||
|
status=initial_status.value,
|
||||||
|
current_owner_type=current_owner.type,
|
||||||
|
current_owner_id=current_owner.id,
|
||||||
|
target_owner_type=target_owner.type,
|
||||||
|
target_owner_id=target_owner.id,
|
||||||
|
initiated_by_type=actor.type,
|
||||||
|
initiated_by_id=actor.id,
|
||||||
|
reason=reason.strip() if reason else None,
|
||||||
|
assurance_profile=assurance_profile,
|
||||||
|
required_approvals=required_approvals,
|
||||||
|
approvals=[],
|
||||||
|
decisions=[],
|
||||||
|
idempotency_key=clean_key,
|
||||||
|
canonical_request_hash=request_hash,
|
||||||
|
expires_at=effective_now + timedelta(days=days),
|
||||||
|
execute_after=execute_after,
|
||||||
|
metadata_=dict(metadata),
|
||||||
|
)
|
||||||
|
session.add(transfer)
|
||||||
|
session.flush()
|
||||||
|
_record_decision(
|
||||||
|
transfer,
|
||||||
|
action="started",
|
||||||
|
actor=actor,
|
||||||
|
decided_at=effective_now,
|
||||||
|
details={
|
||||||
|
"kind": kind.value,
|
||||||
|
"initial_status": initial_status.value,
|
||||||
|
"assurance_profile": assurance_profile,
|
||||||
|
"required_approvals": required_approvals,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_emit_transfer_event(session, transfer, "started", actor)
|
||||||
|
return transfer
|
||||||
|
|
||||||
|
|
||||||
|
def _authorize(
|
||||||
|
provider: ResourceOwnershipProvider,
|
||||||
|
session: Session,
|
||||||
|
transfer: OwnershipTransfer,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
action: str,
|
||||||
|
) -> None:
|
||||||
|
_authorize_direct(
|
||||||
|
provider,
|
||||||
|
session,
|
||||||
|
tenant_id=transfer.tenant_id,
|
||||||
|
resource_id=transfer.resource_id,
|
||||||
|
current_owner=_owner_ref(transfer, target=False),
|
||||||
|
target_owner=_owner_ref(transfer, target=True),
|
||||||
|
actor=actor,
|
||||||
|
action=action,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _authorize_direct(
|
||||||
|
provider: ResourceOwnershipProvider,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
resource_id: str,
|
||||||
|
current_owner: OwnershipSubjectRef,
|
||||||
|
target_owner: OwnershipSubjectRef,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
action: str,
|
||||||
|
) -> None:
|
||||||
|
decision = provider.authorize_ownership_action(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
resource_id=resource_id,
|
||||||
|
action=action,
|
||||||
|
actor=actor,
|
||||||
|
current_owner=current_owner,
|
||||||
|
target_owner=target_owner,
|
||||||
|
)
|
||||||
|
if not decision.allowed:
|
||||||
|
raise OwnershipAuthorizationError(
|
||||||
|
decision.reason or f"Ownership action is not allowed: {action}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_active(
|
||||||
|
session: Session,
|
||||||
|
transfer: OwnershipTransfer,
|
||||||
|
now: datetime,
|
||||||
|
) -> None:
|
||||||
|
if transfer.status in TERMINAL_OWNERSHIP_TRANSFER_STATUSES:
|
||||||
|
raise OwnershipTransferError("Ownership transfer is already final")
|
||||||
|
if _utc(transfer.expires_at) <= now:
|
||||||
|
transfer.status = OwnershipTransferStatus.EXPIRED.value
|
||||||
|
transfer.expired_at = now
|
||||||
|
_touch(transfer)
|
||||||
|
_record_decision(
|
||||||
|
transfer,
|
||||||
|
action="expired",
|
||||||
|
actor=None,
|
||||||
|
decided_at=now,
|
||||||
|
)
|
||||||
|
_emit_transfer_event(session, transfer, "expired", None)
|
||||||
|
raise OwnershipTransferExpired("Ownership transfer has expired")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_recovery_authentication(
|
||||||
|
transfer: OwnershipTransfer,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
) -> None:
|
||||||
|
if (
|
||||||
|
bool((transfer.metadata_ or {}).get("recent_authentication_required"))
|
||||||
|
and not actor.recently_authenticated
|
||||||
|
):
|
||||||
|
raise OwnershipAuthorizationError(
|
||||||
|
"Administrative recovery requires recent authentication"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_ref(
|
||||||
|
transfer: OwnershipTransfer,
|
||||||
|
*,
|
||||||
|
target: bool,
|
||||||
|
) -> OwnershipSubjectRef:
|
||||||
|
return OwnershipSubjectRef(
|
||||||
|
type=transfer.target_owner_type if target else transfer.current_owner_type,
|
||||||
|
id=transfer.target_owner_id if target else transfer.current_owner_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _touch(transfer: OwnershipTransfer) -> None:
|
||||||
|
transfer.revision = int(transfer.revision or 0) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def _record_decision(
|
||||||
|
transfer: OwnershipTransfer,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
actor: OwnershipSubjectRef | None,
|
||||||
|
decided_at: datetime,
|
||||||
|
details: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
decisions = list(transfer.decisions or [])
|
||||||
|
decisions.append(
|
||||||
|
{
|
||||||
|
"sequence": len(decisions) + 1,
|
||||||
|
"action": action,
|
||||||
|
"actor_type": actor.type if actor else None,
|
||||||
|
"actor_id": actor.id if actor else None,
|
||||||
|
"decided_at": _utc(decided_at).isoformat(),
|
||||||
|
"status": transfer.status,
|
||||||
|
"details": dict(details or {}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
transfer.decisions = decisions
|
||||||
|
|
||||||
|
|
||||||
|
def _utc(value: datetime | None) -> datetime:
|
||||||
|
if value is None:
|
||||||
|
return utcnow()
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _request_hash(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
resource: OwnershipResourceRef,
|
||||||
|
kind: OwnershipTransferKind,
|
||||||
|
current_owner: OwnershipSubjectRef,
|
||||||
|
target_owner: OwnershipSubjectRef,
|
||||||
|
actor: OwnershipSubjectRef,
|
||||||
|
reason: str | None,
|
||||||
|
expiry_days: int,
|
||||||
|
assurance_profile: str | None,
|
||||||
|
required_approvals: int,
|
||||||
|
execute_after: datetime | None,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
) -> str:
|
||||||
|
payload = {
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"resource": {
|
||||||
|
"module_id": resource.module_id,
|
||||||
|
"type": resource.resource_type,
|
||||||
|
"id": resource.resource_id,
|
||||||
|
},
|
||||||
|
"kind": kind.value,
|
||||||
|
"current_owner": {"type": current_owner.type, "id": current_owner.id},
|
||||||
|
"target_owner": {"type": target_owner.type, "id": target_owner.id},
|
||||||
|
"actor": {"type": actor.type, "id": actor.id},
|
||||||
|
"reason": reason.strip() if reason else None,
|
||||||
|
"expiry_days": expiry_days,
|
||||||
|
"assurance_profile": assurance_profile,
|
||||||
|
"required_approvals": required_approvals,
|
||||||
|
"execute_after": (
|
||||||
|
_utc(execute_after).isoformat()
|
||||||
|
if execute_after is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"metadata": metadata,
|
||||||
|
}
|
||||||
|
encoded = json.dumps(
|
||||||
|
payload,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_transfer_event(
|
||||||
|
session: Session,
|
||||||
|
transfer: OwnershipTransfer,
|
||||||
|
action: str,
|
||||||
|
actor: OwnershipSubjectRef | None,
|
||||||
|
) -> None:
|
||||||
|
from govoplan_core.core.events import (
|
||||||
|
EventActorRef,
|
||||||
|
EventObjectRef,
|
||||||
|
EventTenantRef,
|
||||||
|
PlatformEvent,
|
||||||
|
emit_platform_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
emit_platform_event(
|
||||||
|
session,
|
||||||
|
PlatformEvent(
|
||||||
|
type=f"core.ownership_transfer.{action}.v1",
|
||||||
|
module_id="core",
|
||||||
|
tenant=EventTenantRef(id=transfer.tenant_id),
|
||||||
|
actor=EventActorRef(type=actor.type, id=actor.id) if actor else None,
|
||||||
|
resource=EventObjectRef(
|
||||||
|
type="ownership_transfer",
|
||||||
|
id=transfer.id,
|
||||||
|
),
|
||||||
|
classification="confidential",
|
||||||
|
payload={
|
||||||
|
"schema_version": 1,
|
||||||
|
"resource_module": transfer.resource_module,
|
||||||
|
"resource_type": transfer.resource_type,
|
||||||
|
"resource_id": transfer.resource_id,
|
||||||
|
"kind": transfer.kind,
|
||||||
|
"status": transfer.status,
|
||||||
|
"current_owner_type": transfer.current_owner_type,
|
||||||
|
"current_owner_id": transfer.current_owner_id,
|
||||||
|
"target_owner_type": transfer.target_owner_type,
|
||||||
|
"target_owner_id": transfer.target_owner_id,
|
||||||
|
"assurance_profile": transfer.assurance_profile,
|
||||||
|
"required_approvals": transfer.required_approvals,
|
||||||
|
"approval_count": len(transfer.approvals or []),
|
||||||
|
"encryption_keys_included": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"OwnershipActionDecision",
|
||||||
|
"OwnershipAuthorizationError",
|
||||||
|
"OwnershipIdempotencyConflict",
|
||||||
|
"OwnershipProviderRegistration",
|
||||||
|
"OwnershipResourceRef",
|
||||||
|
"OwnershipSubjectRef",
|
||||||
|
"OwnershipTransfer",
|
||||||
|
"OwnershipTransferError",
|
||||||
|
"OwnershipTransferExpired",
|
||||||
|
"OwnershipTransferKind",
|
||||||
|
"OwnershipTransferPolicy",
|
||||||
|
"OwnershipTransferStatus",
|
||||||
|
"ResourceOwnershipProvider",
|
||||||
|
"accept_ownership_transfer",
|
||||||
|
"approve_administrative_recovery",
|
||||||
|
"approve_ownership_request",
|
||||||
|
"cancel_ownership_transfer",
|
||||||
|
"decline_ownership_transfer",
|
||||||
|
"execute_administrative_recovery",
|
||||||
|
"expire_due_ownership_transfers",
|
||||||
|
"request_ownership",
|
||||||
|
"start_administrative_recovery",
|
||||||
|
"start_owner_initiated_transfer",
|
||||||
|
]
|
||||||
169
src/govoplan_core/core/people.py
Normal file
169
src/govoplan_core/core/people.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_ACCESS_PEOPLE_SEARCH = "access.people_search"
|
||||||
|
CAPABILITY_ADDRESSES_PEOPLE_SEARCH = "addresses.people_search"
|
||||||
|
|
||||||
|
# Identity search is deliberately absent here. ``identity.search`` is an
|
||||||
|
# instance-wide canonical-identity directory and has no tenant/principal
|
||||||
|
# visibility contract. Ordinary task pickers must use principal-aware search
|
||||||
|
# providers instead.
|
||||||
|
DEFAULT_PEOPLE_SEARCH_CAPABILITIES = (
|
||||||
|
CAPABILITY_ACCESS_PEOPLE_SEARCH,
|
||||||
|
CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PeopleSearchError(ValueError):
|
||||||
|
"""Stable, non-diagnostic error raised by people-search providers."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PersonSearchCandidate:
|
||||||
|
"""A policy-filtered selection target returned by a directory provider."""
|
||||||
|
|
||||||
|
selection_key: str
|
||||||
|
kind: str
|
||||||
|
reference_id: str
|
||||||
|
display_name: str
|
||||||
|
email: str | None = None
|
||||||
|
source_module: str | None = None
|
||||||
|
source_label: str | None = None
|
||||||
|
source_ref: str | None = None
|
||||||
|
source_revision: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PeopleSearchGroup:
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
candidates: tuple[PersonSearchCandidate, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PeopleSearchProvider(Protocol):
|
||||||
|
"""Server-side, principal-aware people/delivery-target search.
|
||||||
|
|
||||||
|
Implementations must derive visibility from ``principal`` and may only
|
||||||
|
return records the principal can discover in its active tenant and policy
|
||||||
|
context. Callers remain responsible for authorizing the task for which the
|
||||||
|
picker is used (for example, creating a scheduling request).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def search_people(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: str,
|
||||||
|
limit: int = 25,
|
||||||
|
) -> Sequence[PeopleSearchGroup]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def people_search_providers(
|
||||||
|
registry: object | None,
|
||||||
|
*,
|
||||||
|
capability_names: Sequence[str] = DEFAULT_PEOPLE_SEARCH_CAPABILITIES,
|
||||||
|
) -> tuple[PeopleSearchProvider, ...]:
|
||||||
|
if registry is None or not hasattr(registry, "has_capability") or not hasattr(registry, "capability"):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
providers: list[PeopleSearchProvider] = []
|
||||||
|
for capability_name in capability_names:
|
||||||
|
if not registry.has_capability(capability_name):
|
||||||
|
continue
|
||||||
|
capability = registry.capability(capability_name)
|
||||||
|
if isinstance(capability, PeopleSearchProvider):
|
||||||
|
providers.append(capability)
|
||||||
|
return tuple(providers)
|
||||||
|
|
||||||
|
|
||||||
|
def search_visible_people(
|
||||||
|
registry: object | None,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: str,
|
||||||
|
limit: int = 25,
|
||||||
|
capability_names: Sequence[str] = DEFAULT_PEOPLE_SEARCH_CAPABILITIES,
|
||||||
|
) -> tuple[PeopleSearchGroup, ...]:
|
||||||
|
"""Collect grouped results without importing optional feature modules.
|
||||||
|
|
||||||
|
``limit`` is enforced per provider so one directory cannot starve another
|
||||||
|
result group. Providers are expected to return at most that many candidates
|
||||||
|
in total. Duplicate group keys are merged and candidate selection keys are
|
||||||
|
de-duplicated while preserving provider order.
|
||||||
|
"""
|
||||||
|
|
||||||
|
normalized_query = str(query or "").strip()
|
||||||
|
normalized_limit = max(1, min(int(limit), 100))
|
||||||
|
ordered_group_keys: list[str] = []
|
||||||
|
labels: dict[str, str] = {}
|
||||||
|
candidates_by_group: dict[str, list[PersonSearchCandidate]] = {}
|
||||||
|
candidate_keys_by_group: dict[str, set[str]] = {}
|
||||||
|
|
||||||
|
for provider in people_search_providers(registry, capability_names=capability_names):
|
||||||
|
groups = provider.search_people(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
query=normalized_query,
|
||||||
|
limit=normalized_limit,
|
||||||
|
)
|
||||||
|
provider_candidate_count = 0
|
||||||
|
for group in groups:
|
||||||
|
if not group.key:
|
||||||
|
continue
|
||||||
|
if group.key not in candidates_by_group:
|
||||||
|
ordered_group_keys.append(group.key)
|
||||||
|
labels[group.key] = group.label
|
||||||
|
candidates_by_group[group.key] = []
|
||||||
|
candidate_keys_by_group[group.key] = set()
|
||||||
|
for candidate in group.candidates:
|
||||||
|
if provider_candidate_count >= normalized_limit:
|
||||||
|
break
|
||||||
|
if not candidate.selection_key or candidate.selection_key in candidate_keys_by_group[group.key]:
|
||||||
|
continue
|
||||||
|
candidate_keys_by_group[group.key].add(candidate.selection_key)
|
||||||
|
candidates_by_group[group.key].append(candidate)
|
||||||
|
provider_candidate_count += 1
|
||||||
|
|
||||||
|
return tuple(
|
||||||
|
PeopleSearchGroup(
|
||||||
|
key=group_key,
|
||||||
|
label=labels[group_key],
|
||||||
|
candidates=tuple(candidates_by_group[group_key]),
|
||||||
|
)
|
||||||
|
for group_key in ordered_group_keys
|
||||||
|
if candidates_by_group[group_key]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def person_selection_key(kind: str, reference_id: str, *, email: str | None = None) -> str:
|
||||||
|
normalized_kind = str(kind).strip().casefold()
|
||||||
|
normalized_reference = str(reference_id).strip()
|
||||||
|
normalized_email = str(email or "").strip().casefold()
|
||||||
|
if not normalized_kind or not normalized_reference:
|
||||||
|
raise ValueError("Person selection keys require a kind and reference id.")
|
||||||
|
return ":".join(part for part in (normalized_kind, normalized_reference, normalized_email) if part)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_ACCESS_PEOPLE_SEARCH",
|
||||||
|
"CAPABILITY_ADDRESSES_PEOPLE_SEARCH",
|
||||||
|
"DEFAULT_PEOPLE_SEARCH_CAPABILITIES",
|
||||||
|
"PeopleSearchError",
|
||||||
|
"PeopleSearchGroup",
|
||||||
|
"PeopleSearchProvider",
|
||||||
|
"PersonSearchCandidate",
|
||||||
|
"people_search_providers",
|
||||||
|
"person_selection_key",
|
||||||
|
"search_visible_people",
|
||||||
|
]
|
||||||
@@ -4,9 +4,24 @@ from dataclasses import dataclass, field
|
|||||||
from typing import Any, Iterable, Literal, Mapping, Protocol, cast, runtime_checkable
|
from typing import Any, Iterable, Literal, Mapping, Protocol, cast, runtime_checkable
|
||||||
from urllib.parse import quote, unquote
|
from urllib.parse import quote, unquote
|
||||||
|
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
|
||||||
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
|
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
|
||||||
|
SchedulingParticipantVisibility = Literal["aggregates_only", "names_and_statuses"]
|
||||||
|
DefinitionScopeType = Literal["system", "tenant", "group", "user"]
|
||||||
|
DefinitionKind = Literal["flow", "template"]
|
||||||
|
DefinitionGovernanceAction = Literal[
|
||||||
|
"view",
|
||||||
|
"edit",
|
||||||
|
"run",
|
||||||
|
"reuse",
|
||||||
|
"derive",
|
||||||
|
"automate",
|
||||||
|
]
|
||||||
|
|
||||||
CAPABILITY_POLICY_PRIVACY_RETENTION = "policy.privacyRetention"
|
CAPABILITY_POLICY_PRIVACY_RETENTION = "policy.privacyRetention"
|
||||||
|
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY = "policy.schedulingParticipantPrivacy"
|
||||||
|
CAPABILITY_POLICY_DEFINITION_GOVERNANCE = "policy.definitionGovernance"
|
||||||
|
|
||||||
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign")
|
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign")
|
||||||
|
|
||||||
@@ -126,6 +141,112 @@ class PolicyDecision:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
|
*,
|
||||||
|
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 SchedulingParticipantPrivacyRequest:
|
||||||
|
"""Context for resolving what one Scheduling participant may see.
|
||||||
|
|
||||||
|
``requested_visibility`` is the Scheduling-owned configuration result. A
|
||||||
|
policy provider may preserve or reduce it, but must not broaden it. Core
|
||||||
|
deliberately defines no fallback when the optional capability is absent;
|
||||||
|
that secure default remains the responsibility of Scheduling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
tenant_id: str
|
||||||
|
scheduling_request_id: str
|
||||||
|
participant_id: str
|
||||||
|
requested_visibility: SchedulingParticipantVisibility
|
||||||
|
actor_user_id: str | None = None
|
||||||
|
context: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SchedulingParticipantPrivacyDecision:
|
||||||
|
"""Policy-resolved visibility of other participants' names and statuses."""
|
||||||
|
|
||||||
|
effective_visibility: SchedulingParticipantVisibility
|
||||||
|
reason: str | None = None
|
||||||
|
source_path: tuple[PolicySourceStep, ...] = ()
|
||||||
|
details: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"effective_visibility": self.effective_visibility,
|
||||||
|
"reason": self.reason,
|
||||||
|
"source_path": [step.to_dict() for step in self.source_path],
|
||||||
|
"details": dict(self.details),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class SchedulingParticipantPrivacyPolicy(Protocol):
|
||||||
|
"""Optional policy hook for Scheduling participant-roster disclosure."""
|
||||||
|
|
||||||
|
def resolve_scheduling_participant_visibility(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
request: SchedulingParticipantPrivacyRequest,
|
||||||
|
) -> SchedulingParticipantPrivacyDecision:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class PrivacyRetentionService(Protocol):
|
class PrivacyRetentionService(Protocol):
|
||||||
def privacy_policy_from_settings(self, *args: Any, **kwargs: Any) -> Any:
|
def privacy_policy_from_settings(self, *args: Any, **kwargs: Any) -> Any:
|
||||||
@@ -160,3 +281,16 @@ class PrivacyRetentionService(Protocol):
|
|||||||
|
|
||||||
def apply_retention_policy(self, *args: Any, **kwargs: Any) -> Any:
|
def apply_retention_policy(self, *args: Any, **kwargs: Any) -> Any:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def scheduling_participant_privacy_policy(
|
||||||
|
registry: object | None,
|
||||||
|
) -> SchedulingParticipantPrivacyPolicy | None:
|
||||||
|
"""Return the optional provider without selecting a visibility fallback."""
|
||||||
|
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY)
|
||||||
|
return capability if isinstance(capability, SchedulingParticipantPrivacyPolicy) else None
|
||||||
|
|||||||
349
src/govoplan_core/core/poll.py
Normal file
349
src/govoplan_core/core/poll.py
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_POLL_SCHEDULING = "poll.scheduling"
|
||||||
|
|
||||||
|
|
||||||
|
class PollCapabilityError(ValueError):
|
||||||
|
"""Stable error raised by Poll capability implementations."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollOptionRequest:
|
||||||
|
label: str
|
||||||
|
key: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
value: Mapping[str, object] | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollCreateCommand:
|
||||||
|
title: str
|
||||||
|
kind: str
|
||||||
|
status: str
|
||||||
|
visibility: str
|
||||||
|
result_visibility: str
|
||||||
|
description: str | None = None
|
||||||
|
context_module: str | None = None
|
||||||
|
context_resource_type: str | None = None
|
||||||
|
context_resource_id: str | None = None
|
||||||
|
workflow_state: str | None = None
|
||||||
|
workflow_steps: tuple[Mapping[str, object], ...] = ()
|
||||||
|
allow_anonymous: bool = False
|
||||||
|
allow_response_update: bool = True
|
||||||
|
min_choices: int = 1
|
||||||
|
max_choices: int | None = None
|
||||||
|
opens_at: datetime | None = None
|
||||||
|
closes_at: datetime | None = None
|
||||||
|
options: tuple[PollOptionRequest, ...] = ()
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollUpdateCommand:
|
||||||
|
"""Scheduling-owned Poll fields synchronized as one policy snapshot."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
description: str | None
|
||||||
|
visibility: str
|
||||||
|
result_visibility: str
|
||||||
|
allow_anonymous: bool
|
||||||
|
allow_response_update: bool
|
||||||
|
closes_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollOptionUpdateCommand:
|
||||||
|
"""Complete mutable option snapshot supplied by an owning module.
|
||||||
|
|
||||||
|
Providers must treat an exact repeat as a no-op. When the snapshot
|
||||||
|
changes, answers for this option are invalidated while answers for other
|
||||||
|
options are retained.
|
||||||
|
"""
|
||||||
|
|
||||||
|
label: str
|
||||||
|
description: str | None = None
|
||||||
|
value: Mapping[str, object] | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollOptionOrderCommand:
|
||||||
|
"""Complete active option order supplied by an owning module.
|
||||||
|
|
||||||
|
Providers must reject partial, duplicate, or unknown option collections.
|
||||||
|
An exact repeat is an idempotent no-op. Reordering changes positions only;
|
||||||
|
option identities and their existing answers remain valid.
|
||||||
|
"""
|
||||||
|
|
||||||
|
option_ids: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollInvitationCommand:
|
||||||
|
respondent_id: str | None = None
|
||||||
|
respondent_label: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollAnswerRequest:
|
||||||
|
option_id: str | None = None
|
||||||
|
option_key: str | None = None
|
||||||
|
value: object = None
|
||||||
|
rank: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollSubmitResponseCommand:
|
||||||
|
respondent_id: str
|
||||||
|
respondent_label: str | None = None
|
||||||
|
answers: tuple[PollAnswerRequest, ...] = ()
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollResponseRetirementCommand:
|
||||||
|
"""Retire live responses while preserving their auditable history.
|
||||||
|
|
||||||
|
Owning modules provide every server-trusted identity that can refer to the
|
||||||
|
participant. Providers must soft-delete matching live responses, retain
|
||||||
|
their answers, and treat an exact ``idempotency_key`` replay as a no-op.
|
||||||
|
"""
|
||||||
|
|
||||||
|
respondent_ids: tuple[str, ...] = ()
|
||||||
|
invitation_id: str | None = None
|
||||||
|
reason: str = "participant_removed"
|
||||||
|
idempotency_key: str = ""
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollOptionRef:
|
||||||
|
id: str
|
||||||
|
position: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollRef:
|
||||||
|
id: str
|
||||||
|
status: str
|
||||||
|
options: tuple[PollOptionRef, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollInvitationRef:
|
||||||
|
id: str
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollAnswerRef:
|
||||||
|
option_id: str | None = None
|
||||||
|
option_key: str | None = None
|
||||||
|
value: object = None
|
||||||
|
rank: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollResponseRef:
|
||||||
|
invitation_id: str | None
|
||||||
|
submitted_at: datetime
|
||||||
|
# Optional for backwards compatibility with existing providers. A
|
||||||
|
# consumer can use it to reconcile an authenticated response without
|
||||||
|
# trusting client-supplied invitation metadata.
|
||||||
|
respondent_id: str | None = None
|
||||||
|
# Optional for backwards compatibility. Providers that support response
|
||||||
|
# editing expose the authoritative, still-valid answers here.
|
||||||
|
answers: tuple[PollAnswerRef, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PollResponseRetirementRef:
|
||||||
|
"""Outcome of an auditable response-retirement request."""
|
||||||
|
|
||||||
|
response_ids: tuple[str, ...] = ()
|
||||||
|
retired_at: datetime | None = None
|
||||||
|
newly_retired_count: int = 0
|
||||||
|
replayed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PollSchedulingProvider(Protocol):
|
||||||
|
def create_poll(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str | None,
|
||||||
|
command: PollCreateCommand,
|
||||||
|
) -> PollRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def create_invitation(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollInvitationCommand,
|
||||||
|
) -> PollInvitationRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def update_poll(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollUpdateCommand,
|
||||||
|
) -> PollRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def update_option(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
option_id: str,
|
||||||
|
command: PollOptionUpdateCommand,
|
||||||
|
) -> PollOptionRef:
|
||||||
|
"""Update one option and invalidate only answers for that option."""
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
def reorder_options(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollOptionOrderCommand,
|
||||||
|
) -> PollRef:
|
||||||
|
"""Atomically replace the complete active option order."""
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def close_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def decide_poll(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
option_id: str | None,
|
||||||
|
) -> PollRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def set_workflow_context(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
workflow_state: str,
|
||||||
|
workflow_steps: Sequence[Mapping[str, object]],
|
||||||
|
context_module: str,
|
||||||
|
context_resource_type: str,
|
||||||
|
context_resource_id: str,
|
||||||
|
) -> PollRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def result_summary(self, session: object, *, tenant_id: str, poll_id: str) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def list_responses(self, session: object, *, tenant_id: str, poll_id: str) -> Sequence[PollResponseRef]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_response(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
respondent_ids: Sequence[str],
|
||||||
|
invitation_id: str | None = None,
|
||||||
|
) -> PollResponseRef | None:
|
||||||
|
"""Return one response matching server-trusted participant identities."""
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PollResponseSubmissionProvider(Protocol):
|
||||||
|
"""Optional extension for modules that collect responses through Poll."""
|
||||||
|
|
||||||
|
def submit_response(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollSubmitResponseCommand,
|
||||||
|
) -> PollResponseRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PollResponseRetirementProvider(Protocol):
|
||||||
|
"""Optional extension for owning modules that remove participants."""
|
||||||
|
|
||||||
|
def retire_responses(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollResponseRetirementCommand,
|
||||||
|
) -> PollResponseRetirementRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def poll_scheduling_provider(registry: object | None) -> PollSchedulingProvider | None:
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_POLL_SCHEDULING):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_POLL_SCHEDULING)
|
||||||
|
return capability if isinstance(capability, PollSchedulingProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def poll_response_submission_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> PollResponseSubmissionProvider | None:
|
||||||
|
provider = poll_scheduling_provider(registry)
|
||||||
|
return provider if isinstance(provider, PollResponseSubmissionProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def poll_response_retirement_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> PollResponseRetirementProvider | None:
|
||||||
|
"""Resolve response retirement without making it mandatory for Poll v1 providers."""
|
||||||
|
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(CAPABILITY_POLL_SCHEDULING):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_POLL_SCHEDULING)
|
||||||
|
return (
|
||||||
|
capability
|
||||||
|
if isinstance(capability, PollResponseRetirementProvider)
|
||||||
|
else None
|
||||||
|
)
|
||||||
272
src/govoplan_core/core/poll_participation.py
Normal file
272
src/govoplan_core/core/poll_participation.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
445
src/govoplan_core/core/postbox.py
Normal file
445
src/govoplan_core/core/postbox.py
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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", "acknowledge", "administer"]
|
||||||
|
PostboxMessageListState = Literal["all", "unread", "read", "acknowledged"]
|
||||||
|
PostboxMessageAvailability = Literal["available", "withdrawn", "expired"]
|
||||||
|
|
||||||
|
|
||||||
|
@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()
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PostboxParticipantRef:
|
||||||
|
kind: str
|
||||||
|
reference_type: str
|
||||||
|
reference_id: str | None = None
|
||||||
|
label: str | None = None
|
||||||
|
address: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PostboxAttachmentRef:
|
||||||
|
reference_type: str
|
||||||
|
reference_id: str
|
||||||
|
name: str | None = None
|
||||||
|
media_type: str | None = None
|
||||||
|
size_bytes: int | None = None
|
||||||
|
digest: str | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PostboxMessageRef:
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
postbox_id: str
|
||||||
|
subject: str
|
||||||
|
body_text: str | None
|
||||||
|
status: str
|
||||||
|
availability: PostboxMessageAvailability
|
||||||
|
classification: str
|
||||||
|
sender_label: str | None
|
||||||
|
delivered_at: datetime
|
||||||
|
read_at: datetime | None = None
|
||||||
|
acknowledged_at: datetime | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
withdrawn_at: datetime | None = None
|
||||||
|
producer_module: str | None = None
|
||||||
|
producer_resource_type: str | None = None
|
||||||
|
producer_resource_id: str | None = None
|
||||||
|
encryption_profile: str = "plaintext_v1"
|
||||||
|
key_epoch: int = 1
|
||||||
|
ciphertext_ref: str | None = None
|
||||||
|
signed_manifest_ref: str | None = None
|
||||||
|
participants: tuple[PostboxParticipantRef, ...] = ()
|
||||||
|
attachments: tuple[PostboxAttachmentRef, ...] = ()
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PostboxDeliveryReceiptSummaryRef:
|
||||||
|
delivery_id: str
|
||||||
|
message_id: str
|
||||||
|
postbox_id: str
|
||||||
|
delivery_status: str
|
||||||
|
accepted_at: datetime
|
||||||
|
current_holder_count: int = 0
|
||||||
|
currently_readable: bool = False
|
||||||
|
message_count: int = 1
|
||||||
|
routed_message_count: int = 0
|
||||||
|
readable_message_count: int = 0
|
||||||
|
read_receipt_count: int = 0
|
||||||
|
acknowledged_receipt_count: int = 0
|
||||||
|
withdrawn_message_count: int = 0
|
||||||
|
expired_message_count: int = 0
|
||||||
|
first_read_at: datetime | None = None
|
||||||
|
last_read_at: datetime | None = None
|
||||||
|
first_acknowledged_at: datetime | None = None
|
||||||
|
last_acknowledged_at: datetime | None = None
|
||||||
|
route_status_counts: Mapping[str, int] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PostboxDeliveryRequest:
|
||||||
|
tenant_id: str
|
||||||
|
target: PostboxTargetRef
|
||||||
|
producer_module: str
|
||||||
|
producer_resource_type: str
|
||||||
|
producer_resource_id: str | None
|
||||||
|
idempotency_key: str
|
||||||
|
subject: str
|
||||||
|
body_text: str | None = None
|
||||||
|
sender_label: str | None = None
|
||||||
|
classification: str = "internal"
|
||||||
|
participants: tuple[PostboxParticipantRef, ...] = ()
|
||||||
|
attachments: tuple[PostboxAttachmentRef, ...] = ()
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PostboxDeliveryResult:
|
||||||
|
delivery_id: str
|
||||||
|
postbox_id: str
|
||||||
|
message_id: str
|
||||||
|
address: str
|
||||||
|
status: str
|
||||||
|
vacant: bool
|
||||||
|
holder_count: int
|
||||||
|
duplicate: bool = False
|
||||||
|
evidence: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class PostboxDeliveryRejected(RuntimeError):
|
||||||
|
"""A delivery was rejected before the provider accepted any effect."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
temporary: bool = False,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
self.temporary = temporary
|
||||||
|
|
||||||
|
|
||||||
|
class PostboxDeliveryOutcomeUnknown(RuntimeError):
|
||||||
|
"""The provider may have accepted an effect and must not be bypassed."""
|
||||||
|
|
||||||
|
def __init__(self, code: str, message: str) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PostboxDirectoryProvider(Protocol):
|
||||||
|
def list_visible_postboxes(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
actor: PostboxActorRef,
|
||||||
|
) -> Sequence[PostboxDirectoryEntryRef]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def resolve_postbox(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
target: PostboxTargetRef,
|
||||||
|
materialize: bool = False,
|
||||||
|
) -> PostboxDirectoryEntryRef | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def delivery_catalog(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> PostboxDeliveryCatalogRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PostboxAccessProvider(Protocol):
|
||||||
|
def explain_access(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
postbox_id: str,
|
||||||
|
actor: PostboxActorRef,
|
||||||
|
action: PostboxAction,
|
||||||
|
) -> PostboxAccessDecisionRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PostboxMessagesProvider(Protocol):
|
||||||
|
def list_messages(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
postbox_ids: Sequence[str],
|
||||||
|
actor: PostboxActorRef,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
query: str | None = None,
|
||||||
|
state: PostboxMessageListState = "all",
|
||||||
|
) -> Sequence[PostboxMessageRef]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_message(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
message_id: str,
|
||||||
|
actor: PostboxActorRef,
|
||||||
|
) -> PostboxMessageRef | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def mark_message(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
message_id: str,
|
||||||
|
actor: PostboxActorRef,
|
||||||
|
state: Literal["read", "acknowledged"],
|
||||||
|
) -> PostboxMessageRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PostboxDeliveryProvider(Protocol):
|
||||||
|
def deliver(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
request: PostboxDeliveryRequest,
|
||||||
|
) -> PostboxDeliveryResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PostboxEvidenceProvider(Protocol):
|
||||||
|
def link_evidence(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
message_id: str,
|
||||||
|
attachment: PostboxAttachmentRef,
|
||||||
|
) -> PostboxMessageRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def delivery_receipt_summaries(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
producer_module: str,
|
||||||
|
delivery_ids: Sequence[str],
|
||||||
|
) -> Mapping[str, PostboxDeliveryReceiptSummaryRef]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PostboxRoutingProvider(Protocol):
|
||||||
|
def dispatch_due_routes(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def _postbox_provider(
|
||||||
|
registry: object | None,
|
||||||
|
*,
|
||||||
|
capability_name: str,
|
||||||
|
provider_type: type,
|
||||||
|
) -> object | None:
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(capability_name):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(capability_name)
|
||||||
|
return capability if isinstance(capability, provider_type) else None
|
||||||
|
|
||||||
|
|
||||||
|
def postbox_directory_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> PostboxDirectoryProvider | None:
|
||||||
|
provider = _postbox_provider(
|
||||||
|
registry,
|
||||||
|
capability_name=CAPABILITY_POSTBOX_DIRECTORY,
|
||||||
|
provider_type=PostboxDirectoryProvider,
|
||||||
|
)
|
||||||
|
return provider if isinstance(provider, PostboxDirectoryProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def postbox_access_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> PostboxAccessProvider | None:
|
||||||
|
provider = _postbox_provider(
|
||||||
|
registry,
|
||||||
|
capability_name=CAPABILITY_POSTBOX_ACCESS,
|
||||||
|
provider_type=PostboxAccessProvider,
|
||||||
|
)
|
||||||
|
return provider if isinstance(provider, PostboxAccessProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def postbox_messages_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> PostboxMessagesProvider | None:
|
||||||
|
provider = _postbox_provider(
|
||||||
|
registry,
|
||||||
|
capability_name=CAPABILITY_POSTBOX_MESSAGES,
|
||||||
|
provider_type=PostboxMessagesProvider,
|
||||||
|
)
|
||||||
|
return provider if isinstance(provider, PostboxMessagesProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def postbox_delivery_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> PostboxDeliveryProvider | None:
|
||||||
|
provider = _postbox_provider(
|
||||||
|
registry,
|
||||||
|
capability_name=CAPABILITY_POSTBOX_DELIVERY,
|
||||||
|
provider_type=PostboxDeliveryProvider,
|
||||||
|
)
|
||||||
|
return provider if isinstance(provider, PostboxDeliveryProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def postbox_evidence_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> PostboxEvidenceProvider | None:
|
||||||
|
provider = _postbox_provider(
|
||||||
|
registry,
|
||||||
|
capability_name=CAPABILITY_POSTBOX_EVIDENCE,
|
||||||
|
provider_type=PostboxEvidenceProvider,
|
||||||
|
)
|
||||||
|
return provider if isinstance(provider, PostboxEvidenceProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def postbox_routing_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> PostboxRoutingProvider | None:
|
||||||
|
provider = _postbox_provider(
|
||||||
|
registry,
|
||||||
|
capability_name=CAPABILITY_POSTBOX_ROUTING,
|
||||||
|
provider_type=PostboxRoutingProvider,
|
||||||
|
)
|
||||||
|
return provider if isinstance(provider, PostboxRoutingProvider) else None
|
||||||
111
src/govoplan_core/core/principal_cache.py
Normal file
111
src/govoplan_core/core/principal_cache.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy import func, or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.change_sequence import (
|
||||||
|
ChangeSequenceEntry,
|
||||||
|
retained_sequence_floor,
|
||||||
|
record_change,
|
||||||
|
)
|
||||||
|
|
||||||
|
AUTH_PRINCIPAL_REVISION_COLLECTION = "core.auth-principal-revisions"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AuthPrincipalRevision:
|
||||||
|
system: int
|
||||||
|
tenant: int
|
||||||
|
|
||||||
|
|
||||||
|
def auth_principal_revision(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None,
|
||||||
|
) -> AuthPrincipalRevision:
|
||||||
|
"""Return the durable global and tenant authorization revision.
|
||||||
|
|
||||||
|
The sequence rows make invalidation visible across processes. Retention
|
||||||
|
floors keep revisions monotonic if old change rows are pruned.
|
||||||
|
"""
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
session.query(
|
||||||
|
ChangeSequenceEntry.tenant_id,
|
||||||
|
func.max(ChangeSequenceEntry.id),
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
ChangeSequenceEntry.module_id == "core",
|
||||||
|
ChangeSequenceEntry.collection == AUTH_PRINCIPAL_REVISION_COLLECTION,
|
||||||
|
or_(
|
||||||
|
ChangeSequenceEntry.tenant_id.is_(None),
|
||||||
|
ChangeSequenceEntry.tenant_id == tenant_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.group_by(ChangeSequenceEntry.tenant_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
revisions = {row_tenant_id: int(sequence_id or 0) for row_tenant_id, sequence_id in rows}
|
||||||
|
system = max(
|
||||||
|
revisions.get(None, 0),
|
||||||
|
retained_sequence_floor(
|
||||||
|
session,
|
||||||
|
tenant_id=None,
|
||||||
|
module_id="core",
|
||||||
|
collections=(AUTH_PRINCIPAL_REVISION_COLLECTION,),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
tenant = 0
|
||||||
|
if tenant_id is not None:
|
||||||
|
tenant = max(
|
||||||
|
revisions.get(tenant_id, 0),
|
||||||
|
retained_sequence_floor(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
module_id="core",
|
||||||
|
collections=(AUTH_PRINCIPAL_REVISION_COLLECTION,),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return AuthPrincipalRevision(system=system, tenant=tenant)
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_auth_principals(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None,
|
||||||
|
source_module: str,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
actor_type: str | None = None,
|
||||||
|
actor_id: str | None = None,
|
||||||
|
reason: str | None = None,
|
||||||
|
) -> ChangeSequenceEntry:
|
||||||
|
"""Advance the authorization revision in the caller's transaction."""
|
||||||
|
|
||||||
|
return record_change(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
module_id="core",
|
||||||
|
collection=AUTH_PRINCIPAL_REVISION_COLLECTION,
|
||||||
|
resource_type="auth_principal_revision",
|
||||||
|
resource_id=tenant_id or "system",
|
||||||
|
operation="invalidated",
|
||||||
|
actor_type=actor_type,
|
||||||
|
actor_id=actor_id,
|
||||||
|
payload={
|
||||||
|
"source_module": source_module,
|
||||||
|
"resource_type": resource_type,
|
||||||
|
"resource_id": resource_id,
|
||||||
|
**({"reason": reason} if reason else {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AUTH_PRINCIPAL_REVISION_COLLECTION",
|
||||||
|
"AuthPrincipalRevision",
|
||||||
|
"auth_principal_revision",
|
||||||
|
"invalidate_auth_principals",
|
||||||
|
]
|
||||||
499
src/govoplan_core/core/references.py
Normal file
499
src/govoplan_core/core/references.py
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_ACCESS_DIRECTORY,
|
||||||
|
AccessDirectory,
|
||||||
|
GroupRef,
|
||||||
|
UserRef,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_ACCESS_REFERENCE_OPTIONS = "access.reference_options"
|
||||||
|
ReferenceAvailability = Literal["available", "inactive", "unavailable"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReferenceOption:
|
||||||
|
"""A stable identifier paired with presentation-only directory metadata."""
|
||||||
|
|
||||||
|
value: str
|
||||||
|
label: str
|
||||||
|
description: str | None = None
|
||||||
|
kind: str | None = None
|
||||||
|
availability: ReferenceAvailability = "available"
|
||||||
|
disabled: bool = False
|
||||||
|
source_module: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"value": self.value,
|
||||||
|
"label": self.label,
|
||||||
|
"description": self.description,
|
||||||
|
"kind": self.kind,
|
||||||
|
"availability": self.availability,
|
||||||
|
"disabled": self.disabled,
|
||||||
|
"source_module": self.source_module,
|
||||||
|
"provenance": dict(self.provenance),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReferenceSearchRequest:
|
||||||
|
kind: str
|
||||||
|
tenant_id: str | None
|
||||||
|
query: str = ""
|
||||||
|
selected_values: tuple[str, ...] = ()
|
||||||
|
limit: int = 50
|
||||||
|
cursor: str | None = None
|
||||||
|
context: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReferenceSearchPage:
|
||||||
|
options: tuple[ReferenceOption, ...] = ()
|
||||||
|
next_cursor: str | None = None
|
||||||
|
has_more: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ReferenceOptionProvider(Protocol):
|
||||||
|
"""Optional module-neutral provider for typed reference selectors."""
|
||||||
|
|
||||||
|
def search_reference_options(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ReferenceSearchRequest,
|
||||||
|
) -> ReferenceSearchPage:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def reference_option_provider(
|
||||||
|
registry: object | None,
|
||||||
|
capability_name: str,
|
||||||
|
) -> ReferenceOptionProvider | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(capability_name)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
provider = registry.capability(capability_name)
|
||||||
|
return provider if isinstance(provider, ReferenceOptionProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def access_scope_reference_options(
|
||||||
|
registry: object | None,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
scope_type: str,
|
||||||
|
query: str = "",
|
||||||
|
selected_values: Sequence[str] = (),
|
||||||
|
limit: int = 50,
|
||||||
|
administrative: bool = False,
|
||||||
|
session: object | None = None,
|
||||||
|
reference_kind: str | None = None,
|
||||||
|
) -> tuple[ReferenceOption, ...]:
|
||||||
|
"""Return canonical user-account or group references for definition scopes."""
|
||||||
|
|
||||||
|
return access_scope_reference_page(
|
||||||
|
registry,
|
||||||
|
principal,
|
||||||
|
scope_type=scope_type,
|
||||||
|
query=query,
|
||||||
|
selected_values=selected_values,
|
||||||
|
limit=limit,
|
||||||
|
administrative=administrative,
|
||||||
|
session=session,
|
||||||
|
reference_kind=reference_kind,
|
||||||
|
).options
|
||||||
|
|
||||||
|
|
||||||
|
def access_scope_reference_page(
|
||||||
|
registry: object | None,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
scope_type: str,
|
||||||
|
query: str = "",
|
||||||
|
selected_values: Sequence[str] = (),
|
||||||
|
limit: int = 50,
|
||||||
|
cursor: str | None = None,
|
||||||
|
administrative: bool = False,
|
||||||
|
session: object | None = None,
|
||||||
|
reference_kind: str | None = None,
|
||||||
|
) -> ReferenceSearchPage:
|
||||||
|
"""Search bounded Access references while retaining selected values."""
|
||||||
|
|
||||||
|
clean_type = str(scope_type or "").strip().casefold()
|
||||||
|
if clean_type not in {"user", "group"}:
|
||||||
|
raise ValueError("Scope reference type must be user or group.")
|
||||||
|
clean_kind = str(reference_kind or clean_type).strip().casefold()
|
||||||
|
if clean_kind not in (
|
||||||
|
{"user", "membership"} if clean_type == "user" else {"group"}
|
||||||
|
):
|
||||||
|
raise ValueError("Scope reference kind is incompatible with the scope type.")
|
||||||
|
normalized_limit = max(1, min(int(limit), 200))
|
||||||
|
selected = tuple(
|
||||||
|
dict.fromkeys(
|
||||||
|
str(value).strip() for value in selected_values if str(value).strip()
|
||||||
|
)
|
||||||
|
)[:200]
|
||||||
|
tenant_id = str(getattr(principal, "tenant_id", "") or "")
|
||||||
|
provider = reference_option_provider(
|
||||||
|
registry,
|
||||||
|
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
|
||||||
|
)
|
||||||
|
if provider is not None:
|
||||||
|
if not tenant_id:
|
||||||
|
return ReferenceSearchPage(
|
||||||
|
options=tuple(
|
||||||
|
_unavailable_option(value, kind=clean_kind)
|
||||||
|
for value in selected
|
||||||
|
)
|
||||||
|
)
|
||||||
|
page = provider.search_reference_options(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
request=ReferenceSearchRequest(
|
||||||
|
kind=clean_kind,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
query=str(query or "").strip().casefold(),
|
||||||
|
selected_values=selected,
|
||||||
|
limit=normalized_limit,
|
||||||
|
cursor=str(cursor).strip() if cursor else None,
|
||||||
|
context={"administrative": administrative},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return ReferenceSearchPage(
|
||||||
|
options=_bounded_page_options(
|
||||||
|
page.options,
|
||||||
|
selected_values=selected,
|
||||||
|
limit=normalized_limit,
|
||||||
|
kind=clean_kind,
|
||||||
|
),
|
||||||
|
next_cursor=page.next_cursor,
|
||||||
|
has_more=page.has_more,
|
||||||
|
)
|
||||||
|
|
||||||
|
directory = _access_directory(registry)
|
||||||
|
if directory is None:
|
||||||
|
return ReferenceSearchPage(
|
||||||
|
options=_fallback_scope_options(
|
||||||
|
principal,
|
||||||
|
scope_type=clean_type,
|
||||||
|
reference_kind=clean_kind,
|
||||||
|
query=query,
|
||||||
|
selected_values=selected,
|
||||||
|
limit=normalized_limit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not tenant_id:
|
||||||
|
return ReferenceSearchPage(
|
||||||
|
options=tuple(
|
||||||
|
_unavailable_option(value, kind=clean_kind) for value in selected
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if clean_type == "user":
|
||||||
|
candidates = _user_options(
|
||||||
|
directory.users_for_tenant(tenant_id),
|
||||||
|
principal=principal,
|
||||||
|
administrative=administrative,
|
||||||
|
value_kind=clean_kind,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
candidates = _group_options(
|
||||||
|
directory.groups_for_tenant(tenant_id),
|
||||||
|
principal=principal,
|
||||||
|
administrative=administrative,
|
||||||
|
)
|
||||||
|
return ReferenceSearchPage(
|
||||||
|
options=_filter_and_retain_options(
|
||||||
|
candidates,
|
||||||
|
query=query,
|
||||||
|
selected_values=selected,
|
||||||
|
limit=normalized_limit,
|
||||||
|
kind=clean_kind,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def access_scope_reference_provider_available(registry: object | None) -> bool:
|
||||||
|
return (
|
||||||
|
reference_option_provider(
|
||||||
|
registry,
|
||||||
|
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
or _access_directory(registry) is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_access_scope_reference(
|
||||||
|
registry: object | None,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None,
|
||||||
|
preserve_existing: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Validate new directory-backed scope references while preserving history."""
|
||||||
|
|
||||||
|
clean_type = str(scope_type or "").strip().casefold()
|
||||||
|
clean_id = str(scope_id or "").strip() or None
|
||||||
|
if clean_type not in {"user", "group"}:
|
||||||
|
return clean_id
|
||||||
|
if not clean_id:
|
||||||
|
raise ValueError(f"{clean_type.capitalize()} definitions require a scope ID.")
|
||||||
|
if clean_id == preserve_existing:
|
||||||
|
return clean_id
|
||||||
|
|
||||||
|
directory = _access_directory(registry)
|
||||||
|
if directory is None:
|
||||||
|
return clean_id
|
||||||
|
if not tenant_id:
|
||||||
|
raise ValueError("Directory-backed scopes require an active tenant.")
|
||||||
|
|
||||||
|
if clean_type == "group":
|
||||||
|
group = directory.get_group(clean_id)
|
||||||
|
if group is None or group.tenant_id != tenant_id:
|
||||||
|
raise ValueError("The selected group is not available in the active tenant.")
|
||||||
|
if group.status != "active":
|
||||||
|
raise ValueError("Inactive groups cannot be selected for new definitions.")
|
||||||
|
return group.id
|
||||||
|
|
||||||
|
matching_user = next(
|
||||||
|
(
|
||||||
|
user
|
||||||
|
for user in directory.users_for_tenant(tenant_id)
|
||||||
|
if user.account_id == clean_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if matching_user is None:
|
||||||
|
raise ValueError("The selected user account is not available in the active tenant.")
|
||||||
|
if matching_user.status != "active":
|
||||||
|
raise ValueError("Inactive users cannot be selected for new definitions.")
|
||||||
|
account = directory.get_account(matching_user.account_id)
|
||||||
|
if account is not None and account.status != "active":
|
||||||
|
raise ValueError("Inactive user accounts cannot be selected for new definitions.")
|
||||||
|
return matching_user.account_id
|
||||||
|
|
||||||
|
|
||||||
|
def _access_directory(registry: object | None) -> AccessDirectory | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
provider = registry.capability(CAPABILITY_ACCESS_DIRECTORY)
|
||||||
|
return provider if isinstance(provider, AccessDirectory) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _user_options(
|
||||||
|
users: Sequence[UserRef],
|
||||||
|
*,
|
||||||
|
principal: object,
|
||||||
|
administrative: bool,
|
||||||
|
value_kind: str = "user",
|
||||||
|
) -> tuple[ReferenceOption, ...]:
|
||||||
|
principal_account_id = str(getattr(principal, "account_id", "") or "")
|
||||||
|
options: dict[str, ReferenceOption] = {}
|
||||||
|
for user in users:
|
||||||
|
if not administrative and user.account_id != principal_account_id:
|
||||||
|
continue
|
||||||
|
inactive = user.status != "active"
|
||||||
|
label = user.display_name or user.email or user.account_id
|
||||||
|
detail_parts = [part for part in (user.email, "Inactive" if inactive else None) if part]
|
||||||
|
value = user.id if value_kind == "membership" else user.account_id
|
||||||
|
options[value] = ReferenceOption(
|
||||||
|
value=value,
|
||||||
|
label=label,
|
||||||
|
description=" · ".join(detail_parts) or None,
|
||||||
|
kind=value_kind,
|
||||||
|
availability="inactive" if inactive else "available",
|
||||||
|
disabled=inactive,
|
||||||
|
source_module="access",
|
||||||
|
provenance={
|
||||||
|
"membership_id": user.id,
|
||||||
|
"tenant_id": user.tenant_id,
|
||||||
|
"account_id": user.account_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return tuple(options.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _group_options(
|
||||||
|
groups: Sequence[GroupRef],
|
||||||
|
*,
|
||||||
|
principal: object,
|
||||||
|
administrative: bool,
|
||||||
|
) -> tuple[ReferenceOption, ...]:
|
||||||
|
permitted = {
|
||||||
|
str(value)
|
||||||
|
for value in getattr(principal, "group_ids", ())
|
||||||
|
if str(value)
|
||||||
|
}
|
||||||
|
return tuple(
|
||||||
|
ReferenceOption(
|
||||||
|
value=group.id,
|
||||||
|
label=group.name or group.id,
|
||||||
|
description="Inactive" if group.status != "active" else None,
|
||||||
|
kind="group",
|
||||||
|
availability=(
|
||||||
|
"inactive" if group.status != "active" else "available"
|
||||||
|
),
|
||||||
|
disabled=group.status != "active",
|
||||||
|
source_module="access",
|
||||||
|
provenance={"tenant_id": group.tenant_id, "group_id": group.id},
|
||||||
|
)
|
||||||
|
for group in groups
|
||||||
|
if administrative or group.id in permitted
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_and_retain_options(
|
||||||
|
options: Sequence[ReferenceOption],
|
||||||
|
*,
|
||||||
|
query: str,
|
||||||
|
selected_values: Sequence[str],
|
||||||
|
limit: int,
|
||||||
|
kind: str,
|
||||||
|
) -> tuple[ReferenceOption, ...]:
|
||||||
|
by_value = {option.value: option for option in options}
|
||||||
|
needle = str(query or "").strip().casefold()
|
||||||
|
matches = [
|
||||||
|
option
|
||||||
|
for option in options
|
||||||
|
if not needle
|
||||||
|
or needle
|
||||||
|
in " ".join(
|
||||||
|
(
|
||||||
|
option.value,
|
||||||
|
option.label,
|
||||||
|
option.description or "",
|
||||||
|
)
|
||||||
|
).casefold()
|
||||||
|
][:limit]
|
||||||
|
returned = {option.value for option in matches}
|
||||||
|
for value in selected_values:
|
||||||
|
if value in returned:
|
||||||
|
continue
|
||||||
|
matches.append(by_value.get(value) or _unavailable_option(value, kind=kind))
|
||||||
|
returned.add(value)
|
||||||
|
return tuple(matches)
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_page_options(
|
||||||
|
options: Sequence[ReferenceOption],
|
||||||
|
*,
|
||||||
|
selected_values: Sequence[str],
|
||||||
|
limit: int,
|
||||||
|
kind: str,
|
||||||
|
) -> tuple[ReferenceOption, ...]:
|
||||||
|
by_value = {option.value: option for option in options}
|
||||||
|
selected = set(selected_values)
|
||||||
|
bounded = [
|
||||||
|
option for option in options if option.value not in selected
|
||||||
|
][:limit]
|
||||||
|
returned = {option.value for option in bounded}
|
||||||
|
for value in selected_values:
|
||||||
|
if value in returned:
|
||||||
|
continue
|
||||||
|
bounded.append(by_value.get(value) or _unavailable_option(value, kind=kind))
|
||||||
|
returned.add(value)
|
||||||
|
return tuple(bounded)
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_scope_options(
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
scope_type: str,
|
||||||
|
reference_kind: str | None = None,
|
||||||
|
query: str,
|
||||||
|
selected_values: Sequence[str],
|
||||||
|
limit: int,
|
||||||
|
) -> tuple[ReferenceOption, ...]:
|
||||||
|
candidates: list[ReferenceOption] = []
|
||||||
|
if scope_type == "user":
|
||||||
|
kind = str(reference_kind or "user")
|
||||||
|
value = str(
|
||||||
|
(
|
||||||
|
getattr(principal, "membership_id", "")
|
||||||
|
if kind == "membership"
|
||||||
|
else getattr(principal, "account_id", "")
|
||||||
|
)
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
if value:
|
||||||
|
candidates.append(
|
||||||
|
ReferenceOption(
|
||||||
|
value=value,
|
||||||
|
label=(
|
||||||
|
str(getattr(principal, "display_name", "") or "")
|
||||||
|
or str(getattr(principal, "email", "") or "")
|
||||||
|
or value
|
||||||
|
),
|
||||||
|
description="Current user · Access directory unavailable",
|
||||||
|
kind=kind,
|
||||||
|
source_module="core",
|
||||||
|
provenance={"fallback": True},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
candidates.extend(
|
||||||
|
ReferenceOption(
|
||||||
|
value=str(group_id),
|
||||||
|
label=str(group_id),
|
||||||
|
description="Permitted group · Access directory unavailable",
|
||||||
|
kind="group",
|
||||||
|
source_module="core",
|
||||||
|
provenance={"fallback": True},
|
||||||
|
)
|
||||||
|
for group_id in getattr(principal, "group_ids", ())
|
||||||
|
if str(group_id)
|
||||||
|
)
|
||||||
|
return _filter_and_retain_options(
|
||||||
|
candidates,
|
||||||
|
query=query,
|
||||||
|
selected_values=selected_values,
|
||||||
|
limit=limit,
|
||||||
|
kind=scope_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unavailable_option(value: str, *, kind: str) -> ReferenceOption:
|
||||||
|
return ReferenceOption(
|
||||||
|
value=value,
|
||||||
|
label=f"Unavailable {kind}",
|
||||||
|
description=value,
|
||||||
|
kind=kind,
|
||||||
|
availability="unavailable",
|
||||||
|
disabled=True,
|
||||||
|
source_module=None,
|
||||||
|
provenance={"retained_reference": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_ACCESS_REFERENCE_OPTIONS",
|
||||||
|
"ReferenceAvailability",
|
||||||
|
"ReferenceOption",
|
||||||
|
"ReferenceOptionProvider",
|
||||||
|
"ReferenceSearchPage",
|
||||||
|
"ReferenceSearchRequest",
|
||||||
|
"access_scope_reference_page",
|
||||||
|
"access_scope_reference_options",
|
||||||
|
"access_scope_reference_provider_available",
|
||||||
|
"reference_option_provider",
|
||||||
|
"validate_access_scope_reference",
|
||||||
|
]
|
||||||
@@ -16,13 +16,33 @@ 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.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,
|
||||||
|
)
|
||||||
|
|
||||||
_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 +67,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 +86,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 +121,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:
|
||||||
@@ -111,12 +169,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:
|
||||||
@@ -144,12 +249,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,
|
||||||
@@ -186,46 +374,21 @@ class PlatformRegistry:
|
|||||||
def validate(self) -> RegistrySnapshot:
|
def validate(self) -> RegistrySnapshot:
|
||||||
ordered = tuple(self._topologically_sorted())
|
ordered = tuple(self._topologically_sorted())
|
||||||
available_capabilities = set(self._capability_factories)
|
available_capabilities = set(self._capability_factories)
|
||||||
seen_permissions: dict[str, PermissionDefinition] = {}
|
|
||||||
for manifest in ordered:
|
for manifest in ordered:
|
||||||
_validate_manifest_shape(manifest)
|
_validate_manifest_shape(manifest)
|
||||||
for dependency in manifest.dependencies:
|
_validate_manifest_relationships(
|
||||||
if dependency not in self._manifests:
|
manifest,
|
||||||
raise RegistryError(f"Module {manifest.id!r} depends on unknown module {dependency!r}")
|
known_modules=self._manifests,
|
||||||
for capability in manifest.required_capabilities:
|
available_capabilities=available_capabilities,
|
||||||
if capability not in available_capabilities:
|
)
|
||||||
raise RegistryError(f"Module {manifest.id!r} requires unavailable capability {capability!r}")
|
permissions = _collect_manifest_permissions(ordered)
|
||||||
for dependency in manifest.optional_dependencies:
|
_validate_public_frontend_route_uniqueness(ordered)
|
||||||
if dependency == manifest.id:
|
|
||||||
raise RegistryError(f"Module {manifest.id!r} cannot list itself as an optional dependency")
|
|
||||||
for permission in manifest.permissions:
|
|
||||||
if permission.module_id != manifest.id:
|
|
||||||
raise RegistryError(f"Permission {permission.scope!r} has mismatched module id {permission.module_id!r}")
|
|
||||||
if not _SCOPE_RE.match(permission.scope):
|
|
||||||
raise RegistryError(f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}")
|
|
||||||
expected_prefix = f"{permission.module_id}:{permission.resource}:"
|
|
||||||
if not permission.scope.startswith(expected_prefix) or permission.scope.rsplit(":", 1)[-1] != permission.action:
|
|
||||||
raise RegistryError(f"Permission fields do not match scope {permission.scope!r}")
|
|
||||||
if permission.scope in seen_permissions:
|
|
||||||
raise RegistryError(f"Duplicate permission scope: {permission.scope}")
|
|
||||||
seen_permissions[permission.scope] = permission
|
|
||||||
|
|
||||||
_validate_interface_closure(ordered)
|
_validate_interface_closure(ordered)
|
||||||
|
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
|
||||||
known_scopes = set(seen_permissions)
|
|
||||||
for manifest in ordered:
|
|
||||||
for template in manifest.role_templates:
|
|
||||||
for scope in template.permissions:
|
|
||||||
if scope in {"*", "tenant:*", "system:*"}:
|
|
||||||
continue
|
|
||||||
if _WILDCARD_RE.match(scope):
|
|
||||||
continue
|
|
||||||
if scope not in known_scopes:
|
|
||||||
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
|
|
||||||
|
|
||||||
return RegistrySnapshot(
|
return RegistrySnapshot(
|
||||||
manifests=ordered,
|
manifests=ordered,
|
||||||
permissions=tuple(seen_permissions.values()),
|
permissions=tuple(permissions.values()),
|
||||||
role_templates=tuple(template for manifest in ordered for template in manifest.role_templates),
|
role_templates=tuple(template for manifest in ordered for template in manifest.role_templates),
|
||||||
nav_items=self.nav_items(),
|
nav_items=self.nav_items(),
|
||||||
)
|
)
|
||||||
@@ -253,6 +416,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,
|
||||||
*,
|
*,
|
||||||
@@ -299,7 +509,147 @@ def _attribute_delete_veto_issue(
|
|||||||
return replace(issue, module_id=issue.module_id or registration.module_id, details=details)
|
return replace(issue, module_id=issue.module_id or registration.module_id, details=details)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_manifest_relationships(
|
||||||
|
manifest: ModuleManifest,
|
||||||
|
*,
|
||||||
|
known_modules: Mapping[str, ModuleManifest],
|
||||||
|
available_capabilities: set[str],
|
||||||
|
) -> None:
|
||||||
|
for dependency in manifest.dependencies:
|
||||||
|
if dependency not in known_modules:
|
||||||
|
raise RegistryError(f"Module {manifest.id!r} depends on unknown module {dependency!r}")
|
||||||
|
for capability in manifest.required_capabilities:
|
||||||
|
if capability not in available_capabilities:
|
||||||
|
raise RegistryError(f"Module {manifest.id!r} requires unavailable capability {capability!r}")
|
||||||
|
for dependency in manifest.optional_dependencies:
|
||||||
|
if dependency == manifest.id:
|
||||||
|
raise RegistryError(f"Module {manifest.id!r} cannot list itself as an optional dependency")
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_manifest_permissions(manifests: tuple[ModuleManifest, ...]) -> dict[str, PermissionDefinition]:
|
||||||
|
permissions: dict[str, PermissionDefinition] = {}
|
||||||
|
for manifest in manifests:
|
||||||
|
for permission in manifest.permissions:
|
||||||
|
_validate_manifest_permission(manifest, permission, permissions)
|
||||||
|
permissions[permission.scope] = permission
|
||||||
|
return permissions
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_manifest_permission(
|
||||||
|
manifest: ModuleManifest,
|
||||||
|
permission: PermissionDefinition,
|
||||||
|
seen_permissions: Mapping[str, PermissionDefinition],
|
||||||
|
) -> None:
|
||||||
|
if permission.module_id != manifest.id:
|
||||||
|
raise RegistryError(f"Permission {permission.scope!r} has mismatched module id {permission.module_id!r}")
|
||||||
|
if not _SCOPE_RE.match(permission.scope):
|
||||||
|
raise RegistryError(f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}")
|
||||||
|
expected_prefix = f"{permission.module_id}:{permission.resource}:"
|
||||||
|
if not permission.scope.startswith(expected_prefix) or permission.scope.rsplit(":", 1)[-1] != permission.action:
|
||||||
|
raise RegistryError(f"Permission fields do not match scope {permission.scope!r}")
|
||||||
|
if permission.scope in seen_permissions:
|
||||||
|
raise RegistryError(f"Duplicate permission scope: {permission.scope}")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_role_template_scopes(
|
||||||
|
manifests: tuple[ModuleManifest, ...],
|
||||||
|
*,
|
||||||
|
known_scopes: set[str],
|
||||||
|
) -> None:
|
||||||
|
seen_templates: dict[tuple[str, str], str] = {}
|
||||||
|
for manifest in manifests:
|
||||||
|
for template in manifest.role_templates:
|
||||||
|
template_key = (template.level, template.slug)
|
||||||
|
previous_module = seen_templates.get(template_key)
|
||||||
|
if previous_module is not None:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Duplicate {template.level} role template slug {template.slug!r} "
|
||||||
|
f"in modules {previous_module!r} and {manifest.id!r}"
|
||||||
|
)
|
||||||
|
seen_templates[template_key] = manifest.id
|
||||||
|
if template.default_authenticated and template.level != "tenant":
|
||||||
|
raise RegistryError(
|
||||||
|
f"Default authenticated role template {template.slug!r} must be tenant-level"
|
||||||
|
)
|
||||||
|
if template.default_authenticated and not template.managed:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Default authenticated role template {template.slug!r} must be managed"
|
||||||
|
)
|
||||||
|
for scope in template.permissions:
|
||||||
|
if template.default_authenticated and (
|
||||||
|
scope in {"*", "tenant:*", "system:*"}
|
||||||
|
or _WILDCARD_RE.match(scope)
|
||||||
|
):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Default authenticated role template {template.slug!r} "
|
||||||
|
"must use explicit permissions, not wildcard scopes"
|
||||||
|
)
|
||||||
|
if _role_template_scope_known(scope, known_scopes):
|
||||||
|
continue
|
||||||
|
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _role_template_scope_known(scope: str, known_scopes: set[str]) -> bool:
|
||||||
|
if scope in {"*", "tenant:*", "system:*"}:
|
||||||
|
return True
|
||||||
|
if _WILDCARD_RE.match(scope):
|
||||||
|
return True
|
||||||
|
return scope in known_scopes
|
||||||
|
|
||||||
|
|
||||||
def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
||||||
|
_validate_manifest_identity(manifest)
|
||||||
|
_validate_manifest_contract_lists(manifest)
|
||||||
|
_validate_manifest_overlaps(manifest)
|
||||||
|
_validate_manifest_migration_spec(manifest)
|
||||||
|
_validate_manifest_frontend(manifest)
|
||||||
|
for item in manifest.nav_items:
|
||||||
|
_validate_nav_item(manifest.id, item)
|
||||||
|
for topic in manifest.documentation:
|
||||||
|
for issue in user_workflow_scope_condition_issues(topic):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
|
||||||
|
)
|
||||||
|
_validate_documentation_extensions(manifest)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_documentation_extensions(manifest: ModuleManifest) -> None:
|
||||||
|
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:
|
||||||
if not _MODULE_ID_RE.match(manifest.id):
|
if not _MODULE_ID_RE.match(manifest.id):
|
||||||
raise RegistryError(f"Module manifest id must match {_MODULE_ID_RE.pattern}: {manifest.id!r}")
|
raise RegistryError(f"Module manifest id must match {_MODULE_ID_RE.pattern}: {manifest.id!r}")
|
||||||
if not manifest.name.strip():
|
if not manifest.name.strip():
|
||||||
@@ -313,12 +663,43 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
|||||||
f"{SUPPORTED_MANIFEST_CONTRACT_VERSION!r}"
|
f"{SUPPORTED_MANIFEST_CONTRACT_VERSION!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_manifest_contract_lists(manifest: ModuleManifest) -> None:
|
||||||
_validate_dependency_list(manifest.id, "dependencies", manifest.dependencies)
|
_validate_dependency_list(manifest.id, "dependencies", manifest.dependencies)
|
||||||
_validate_dependency_list(manifest.id, "optional_dependencies", manifest.optional_dependencies)
|
_validate_dependency_list(manifest.id, "optional_dependencies", manifest.optional_dependencies)
|
||||||
_validate_capability_list(manifest.id, "required_capabilities", manifest.required_capabilities)
|
_validate_capability_list(manifest.id, "required_capabilities", manifest.required_capabilities)
|
||||||
_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:
|
||||||
overlap = set(manifest.dependencies) & set(manifest.optional_dependencies)
|
overlap = set(manifest.dependencies) & set(manifest.optional_dependencies)
|
||||||
if overlap:
|
if overlap:
|
||||||
joined = ", ".join(sorted(overlap))
|
joined = ", ".join(sorted(overlap))
|
||||||
@@ -328,13 +709,18 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
|||||||
joined = ", ".join(sorted(capability_overlap))
|
joined = ", ".join(sorted(capability_overlap))
|
||||||
raise RegistryError(f"Module {manifest.id!r} lists capabilities as both required and optional: {joined}")
|
raise RegistryError(f"Module {manifest.id!r} lists capabilities as both required and optional: {joined}")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_manifest_migration_spec(manifest: ModuleManifest) -> None:
|
||||||
if manifest.migration_spec is not None:
|
if manifest.migration_spec is not None:
|
||||||
if manifest.migration_spec.module_id != manifest.id:
|
if manifest.migration_spec.module_id != manifest.id:
|
||||||
raise RegistryError(f"Module {manifest.id!r} has migration spec for {manifest.migration_spec.module_id!r}")
|
raise RegistryError(f"Module {manifest.id!r} has migration spec for {manifest.migration_spec.module_id!r}")
|
||||||
if manifest.migration_spec.metadata is None and not manifest.migration_spec.script_location:
|
if manifest.migration_spec.metadata is None and not manifest.migration_spec.script_location:
|
||||||
raise RegistryError(f"Module {manifest.id!r} migration spec must declare metadata or script location")
|
raise RegistryError(f"Module {manifest.id!r} migration spec must declare metadata or script location")
|
||||||
|
|
||||||
if manifest.frontend is not None:
|
|
||||||
|
def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
|
||||||
|
if manifest.frontend is None:
|
||||||
|
return
|
||||||
frontend = manifest.frontend
|
frontend = manifest.frontend
|
||||||
if frontend.module_id != manifest.id:
|
if frontend.module_id != manifest.id:
|
||||||
raise RegistryError(f"Module {manifest.id!r} has frontend metadata for {frontend.module_id!r}")
|
raise RegistryError(f"Module {manifest.id!r} has frontend metadata for {frontend.module_id!r}")
|
||||||
@@ -346,16 +732,144 @@ def _validate_manifest_shape(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, *frontend.public_routes):
|
||||||
|
_validate_frontend_route(manifest.id, route.path, route.component)
|
||||||
for route in (*frontend.routes, *frontend.settings_routes):
|
for route in (*frontend.routes, *frontend.settings_routes):
|
||||||
if not route.path.startswith("/"):
|
if route.surface_id is not None:
|
||||||
raise RegistryError(f"Frontend route for module {manifest.id!r} must start with '/': {route.path!r}")
|
_validate_view_surface_id(manifest.id, route.surface_id)
|
||||||
if not route.component.strip():
|
|
||||||
raise RegistryError(f"Frontend route {route.path!r} for module {manifest.id!r} must declare a component")
|
|
||||||
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)
|
||||||
|
|
||||||
for item in manifest.nav_items:
|
|
||||||
_validate_nav_item(manifest.id, item)
|
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:
|
||||||
|
if not path.startswith("/"):
|
||||||
|
raise RegistryError(f"Frontend route for module {module_id!r} must start with '/': {path!r}")
|
||||||
|
if not component.strip():
|
||||||
|
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:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from govoplan_core.core.modules import ModuleContext
|
from govoplan_core.core.modules import ModuleContext
|
||||||
|
|
||||||
_context: ModuleContext | None = None
|
_context: ModuleContext | None = None
|
||||||
@@ -21,3 +23,41 @@ def get_runtime_context() -> ModuleContext | None:
|
|||||||
|
|
||||||
def get_registry() -> object | None:
|
def get_registry() -> object | None:
|
||||||
return _context.registry if _context is not None else None
|
return _context.registry if _context is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleSettingsProxy:
|
||||||
|
def __init__(self, runtime: ModuleRuntimeState) -> None:
|
||||||
|
self._runtime = runtime
|
||||||
|
|
||||||
|
def __getattr__(self, name: str) -> Any:
|
||||||
|
return getattr(self._runtime.get_settings(), name)
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleRuntimeState:
|
||||||
|
def __init__(self, module_name: str) -> None:
|
||||||
|
self.module_name = module_name
|
||||||
|
self.settings = ModuleSettingsProxy(self)
|
||||||
|
self._registry: object | None = None
|
||||||
|
self._settings: object | None = None
|
||||||
|
|
||||||
|
def configure_runtime(self, *, registry: object | None = None, settings: object | None = None) -> None:
|
||||||
|
if registry is not None:
|
||||||
|
self._registry = registry
|
||||||
|
if settings is not None:
|
||||||
|
self._settings = settings
|
||||||
|
|
||||||
|
def clear_runtime(self) -> None:
|
||||||
|
self._registry = None
|
||||||
|
self._settings = None
|
||||||
|
|
||||||
|
def get_registry(self) -> object | None:
|
||||||
|
return self._registry
|
||||||
|
|
||||||
|
def get_settings(self) -> object:
|
||||||
|
if self._settings is not None:
|
||||||
|
return self._settings
|
||||||
|
try:
|
||||||
|
from govoplan_core.settings import settings as legacy_settings
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise RuntimeError(f"GovOPlaN {self.module_name} runtime settings are not configured") from exc
|
||||||
|
return legacy_settings
|
||||||
|
|||||||
383
src/govoplan_core/core/sanctions.py
Normal file
383
src/govoplan_core/core/sanctions.py
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
SANCTIONS_SNAPSHOT_CONTRACT_VERSION = "1"
|
||||||
|
SANCTIONS_SCREENING_CONTRACT_VERSION = "1"
|
||||||
|
CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS = (
|
||||||
|
"connectors.sanctionsSnapshotProvider"
|
||||||
|
)
|
||||||
|
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING = (
|
||||||
|
"riskCompliance.sanctionsScreeningProvider"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsSnapshotReference:
|
||||||
|
ref: str
|
||||||
|
tenant_id: str
|
||||||
|
provider_id: str
|
||||||
|
publisher: str
|
||||||
|
jurisdiction: str
|
||||||
|
list_type: str
|
||||||
|
source_id: str
|
||||||
|
source_version: str
|
||||||
|
publication_at: datetime | None
|
||||||
|
effective_at: datetime | None
|
||||||
|
acquired_at: datetime
|
||||||
|
content_type: str
|
||||||
|
byte_count: int
|
||||||
|
sha256: str
|
||||||
|
parser_version: str
|
||||||
|
raw_evidence_ref: str
|
||||||
|
connector_run_id: str
|
||||||
|
signature_evidence: Mapping[str, object] = field(
|
||||||
|
default_factory=dict
|
||||||
|
)
|
||||||
|
licence_notes: str | None = None
|
||||||
|
trust_notes: str | None = None
|
||||||
|
transport_evidence: Mapping[str, object] = field(
|
||||||
|
default_factory=dict
|
||||||
|
)
|
||||||
|
contract_version: str = SANCTIONS_SNAPSHOT_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.contract_version != SANCTIONS_SNAPSHOT_CONTRACT_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
"Unsupported sanctions snapshot contract version."
|
||||||
|
)
|
||||||
|
required = (
|
||||||
|
self.ref,
|
||||||
|
self.tenant_id,
|
||||||
|
self.provider_id,
|
||||||
|
self.publisher,
|
||||||
|
self.jurisdiction,
|
||||||
|
self.list_type,
|
||||||
|
self.source_id,
|
||||||
|
self.source_version,
|
||||||
|
self.content_type,
|
||||||
|
self.sha256,
|
||||||
|
self.parser_version,
|
||||||
|
self.raw_evidence_ref,
|
||||||
|
self.connector_run_id,
|
||||||
|
)
|
||||||
|
if not all(value.strip() for value in required):
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions snapshot references require complete provenance."
|
||||||
|
)
|
||||||
|
if self.byte_count < 1:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions snapshot evidence must not be empty."
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
len(self.sha256) != 64
|
||||||
|
or any(character not in "0123456789abcdef" for character in self.sha256)
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions snapshot SHA-256 evidence is invalid."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsSnapshotPayload:
|
||||||
|
snapshot: SanctionsSnapshotReference
|
||||||
|
content: bytes
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if len(self.content) != self.snapshot.byte_count:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions snapshot content length does not match metadata."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsScreeningSubject:
|
||||||
|
subject_type: Literal["person", "entity"]
|
||||||
|
primary_name: str | None = None
|
||||||
|
subject_ref: str | None = None
|
||||||
|
aliases: tuple[str, ...] = ()
|
||||||
|
identifiers: tuple[Mapping[str, str], ...] = ()
|
||||||
|
dates: tuple[str, ...] = ()
|
||||||
|
addresses: tuple[Mapping[str, str], ...] = ()
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.subject_type not in {"person", "entity"}:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening subjects must be a person or entity."
|
||||||
|
)
|
||||||
|
if not str(self.primary_name or "").strip() and not any(
|
||||||
|
str(value.get("value") or "").strip()
|
||||||
|
for value in self.identifiers
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"A sanctions screening subject needs a name or identifier."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsScreeningPolicy:
|
||||||
|
fuzzy_threshold: float = 0.88
|
||||||
|
max_snapshot_age_days: int = 7
|
||||||
|
max_candidates: int = 100
|
||||||
|
failure_policy: Literal["block", "review", "degraded"] = "block"
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not 0.8 <= self.fuzzy_threshold <= 1:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening fuzzy threshold must be between 0.8 and 1."
|
||||||
|
)
|
||||||
|
if not 1 <= self.max_snapshot_age_days <= 365:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions snapshot age must be between 1 and 365 days."
|
||||||
|
)
|
||||||
|
if not 1 <= self.max_candidates <= 100:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening candidate limit must be between 1 and 100."
|
||||||
|
)
|
||||||
|
if self.failure_policy not in {"block", "review", "degraded"}:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening failure policy is not supported."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsScreeningRequest:
|
||||||
|
list_snapshot_id: str
|
||||||
|
idempotency_key: str
|
||||||
|
subject: SanctionsScreeningSubject
|
||||||
|
policy: SanctionsScreeningPolicy = field(
|
||||||
|
default_factory=SanctionsScreeningPolicy
|
||||||
|
)
|
||||||
|
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
"Unsupported sanctions screening contract version."
|
||||||
|
)
|
||||||
|
if not self.list_snapshot_id.strip():
|
||||||
|
raise ValueError(
|
||||||
|
"A sanctions list snapshot is required for screening."
|
||||||
|
)
|
||||||
|
if not self.idempotency_key.strip():
|
||||||
|
raise ValueError(
|
||||||
|
"A sanctions screening idempotency key is required."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsScreeningEvidence:
|
||||||
|
ref: str
|
||||||
|
run_id: str
|
||||||
|
outcome: str
|
||||||
|
candidate_count: int
|
||||||
|
list_snapshot_id: str
|
||||||
|
source_version: str
|
||||||
|
subject_fingerprint: str
|
||||||
|
matcher_version: str
|
||||||
|
normalization_version: str
|
||||||
|
policy_version: str
|
||||||
|
completed_at: datetime | None
|
||||||
|
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
"Unsupported sanctions screening evidence version."
|
||||||
|
)
|
||||||
|
if self.ref != f"risk-screening:{self.run_id}":
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening evidence reference is invalid."
|
||||||
|
)
|
||||||
|
if self.candidate_count < 0:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening candidate count cannot be negative."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsScreeningFreshnessRequest:
|
||||||
|
evidence_ref: str
|
||||||
|
current_subject: SanctionsScreeningSubject | None = None
|
||||||
|
expected_list_snapshot_id: str | None = None
|
||||||
|
policy: SanctionsScreeningPolicy = field(
|
||||||
|
default_factory=SanctionsScreeningPolicy
|
||||||
|
)
|
||||||
|
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
"Unsupported sanctions screening freshness contract version."
|
||||||
|
)
|
||||||
|
if not self.evidence_ref.startswith("risk-screening:"):
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening evidence reference is invalid."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsScreeningFreshness:
|
||||||
|
evidence: SanctionsScreeningEvidence
|
||||||
|
fresh: bool
|
||||||
|
reasons: tuple[str, ...]
|
||||||
|
checked_at: datetime
|
||||||
|
current_list_snapshot_id: str | None
|
||||||
|
gate_decision: Literal["allow", "block", "review", "degraded"]
|
||||||
|
gate_reasons: tuple[str, ...]
|
||||||
|
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
"Unsupported sanctions screening freshness version."
|
||||||
|
)
|
||||||
|
if self.fresh != (not self.reasons):
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening freshness reasons are inconsistent."
|
||||||
|
)
|
||||||
|
if self.gate_decision not in {
|
||||||
|
"allow",
|
||||||
|
"block",
|
||||||
|
"review",
|
||||||
|
"degraded",
|
||||||
|
}:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening gate decision is invalid."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsScreeningResult:
|
||||||
|
evidence: SanctionsScreeningEvidence
|
||||||
|
freshness: SanctionsScreeningFreshness
|
||||||
|
created: bool
|
||||||
|
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
"Unsupported sanctions screening result version."
|
||||||
|
)
|
||||||
|
if self.evidence != self.freshness.evidence:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening result evidence is inconsistent."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class SanctionsSnapshotProvider(Protocol):
|
||||||
|
def list_snapshots(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[SanctionsSnapshotReference]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_snapshot(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
snapshot_ref: str,
|
||||||
|
) -> SanctionsSnapshotReference | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def read_snapshot(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
snapshot_ref: str,
|
||||||
|
) -> SanctionsSnapshotPayload:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class SanctionsScreeningProvider(Protocol):
|
||||||
|
def request_screening(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
request: SanctionsScreeningRequest,
|
||||||
|
) -> SanctionsScreeningResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
def check_freshness(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
request: SanctionsScreeningFreshnessRequest,
|
||||||
|
) -> SanctionsScreeningFreshness:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def sanctions_snapshot_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> SanctionsSnapshotProvider | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(
|
||||||
|
CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
provider = registry.capability(
|
||||||
|
CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
provider
|
||||||
|
if isinstance(provider, SanctionsSnapshotProvider)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sanctions_screening_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> SanctionsScreeningProvider | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(
|
||||||
|
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
provider = registry.capability(
|
||||||
|
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
provider
|
||||||
|
if isinstance(provider, SanctionsScreeningProvider)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS",
|
||||||
|
"CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING",
|
||||||
|
"SANCTIONS_SCREENING_CONTRACT_VERSION",
|
||||||
|
"SANCTIONS_SNAPSHOT_CONTRACT_VERSION",
|
||||||
|
"SanctionsScreeningEvidence",
|
||||||
|
"SanctionsScreeningFreshness",
|
||||||
|
"SanctionsScreeningFreshnessRequest",
|
||||||
|
"SanctionsScreeningPolicy",
|
||||||
|
"SanctionsScreeningProvider",
|
||||||
|
"SanctionsScreeningRequest",
|
||||||
|
"SanctionsScreeningResult",
|
||||||
|
"SanctionsScreeningSubject",
|
||||||
|
"SanctionsSnapshotPayload",
|
||||||
|
"SanctionsSnapshotProvider",
|
||||||
|
"SanctionsSnapshotReference",
|
||||||
|
"sanctions_screening_provider",
|
||||||
|
"sanctions_snapshot_provider",
|
||||||
|
]
|
||||||
473
src/govoplan_core/core/search.py
Normal file
473
src/govoplan_core/core/search.py
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from govoplan_core.core.external_references import ExternalObjectReference
|
||||||
|
from govoplan_core.core.modules import ModuleContext
|
||||||
|
|
||||||
|
|
||||||
|
SearchContextKind = Literal["global", "module", "resource"]
|
||||||
|
SearchVisibility = Literal["tenant", "restricted"]
|
||||||
|
SearchIndexChangeKind = Literal["upsert", "delete"]
|
||||||
|
SEARCH_SOURCE_CONTRACT_VERSION = "1"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchQuery:
|
||||||
|
text: str
|
||||||
|
tenant_id: str
|
||||||
|
module_ids: tuple[str, ...] = ()
|
||||||
|
resource_types: tuple[str, ...] = ()
|
||||||
|
context_kind: SearchContextKind = "global"
|
||||||
|
context_id: str | None = None
|
||||||
|
limit: int = 25
|
||||||
|
offset: int = 0
|
||||||
|
cursor: str | None = None
|
||||||
|
language: str = "simple"
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
normalized_text = self.text.strip()
|
||||||
|
if len(normalized_text) > 500:
|
||||||
|
raise ValueError("Search text is limited to 500 characters.")
|
||||||
|
if not self.tenant_id.strip():
|
||||||
|
raise ValueError("Search queries require a tenant.")
|
||||||
|
if not 1 <= self.limit <= 200:
|
||||||
|
raise ValueError("Search result limits must be between 1 and 200.")
|
||||||
|
if self.offset < 0:
|
||||||
|
raise ValueError("Search offsets cannot be negative.")
|
||||||
|
if self.cursor and self.offset:
|
||||||
|
raise ValueError(
|
||||||
|
"Search cursor and offset pagination cannot be combined."
|
||||||
|
)
|
||||||
|
if not self.language.replace("_", "").isalnum():
|
||||||
|
raise ValueError("Search language configuration is invalid.")
|
||||||
|
if len(self.module_ids) > 50 or len(self.resource_types) > 50:
|
||||||
|
raise ValueError(
|
||||||
|
"Search queries support at most 50 module and type filters."
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "text", normalized_text)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchResult:
|
||||||
|
provider_id: str
|
||||||
|
module_id: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
title: str
|
||||||
|
url: str
|
||||||
|
summary: str | None = None
|
||||||
|
score: float = 0.0
|
||||||
|
highlights: tuple[str, ...] = ()
|
||||||
|
breadcrumbs: tuple[str, ...] = ()
|
||||||
|
external_reference: ExternalObjectReference | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
source_revision: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchResourceReference:
|
||||||
|
tenant_id: str
|
||||||
|
module_id: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for field_name in (
|
||||||
|
"tenant_id",
|
||||||
|
"module_id",
|
||||||
|
"resource_type",
|
||||||
|
"resource_id",
|
||||||
|
):
|
||||||
|
if not str(getattr(self, field_name) or "").strip():
|
||||||
|
raise ValueError(
|
||||||
|
f"Search resource reference {field_name} is required."
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key(self) -> str:
|
||||||
|
values = (
|
||||||
|
self.tenant_id,
|
||||||
|
self.module_id,
|
||||||
|
self.resource_type,
|
||||||
|
self.resource_id,
|
||||||
|
)
|
||||||
|
return "|".join(
|
||||||
|
f"{len(value)}:{value}"
|
||||||
|
for value in values
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchResourceType:
|
||||||
|
provider_id: str
|
||||||
|
module_id: str
|
||||||
|
resource_type: str
|
||||||
|
label: str
|
||||||
|
index_version: int = 1
|
||||||
|
language: str = "simple"
|
||||||
|
requires_authorization_recheck: bool = True
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not all(
|
||||||
|
value.strip()
|
||||||
|
for value in (
|
||||||
|
self.provider_id,
|
||||||
|
self.module_id,
|
||||||
|
self.resource_type,
|
||||||
|
self.label,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Search resource type identifiers and label are required."
|
||||||
|
)
|
||||||
|
if self.index_version < 1:
|
||||||
|
raise ValueError("Search index versions start at one.")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchDocument:
|
||||||
|
tenant_id: str
|
||||||
|
module_id: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
title: str
|
||||||
|
url: str
|
||||||
|
summary: str | None = None
|
||||||
|
body: str | None = None
|
||||||
|
keywords: tuple[str, ...] = ()
|
||||||
|
visibility: SearchVisibility = "restricted"
|
||||||
|
acl_tokens: tuple[str, ...] = ()
|
||||||
|
external_reference: ExternalObjectReference | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
provider_id: str | None = None
|
||||||
|
source_revision: str = "1"
|
||||||
|
change_cursor: str | None = None
|
||||||
|
source_updated_at: datetime | None = None
|
||||||
|
language: str = "simple"
|
||||||
|
index_version: int = 1
|
||||||
|
requires_authorization_recheck: bool = False
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
required = {
|
||||||
|
"tenant_id": self.tenant_id,
|
||||||
|
"module_id": self.module_id,
|
||||||
|
"resource_type": self.resource_type,
|
||||||
|
"resource_id": self.resource_id,
|
||||||
|
"title": self.title,
|
||||||
|
"url": self.url,
|
||||||
|
}
|
||||||
|
for field_name, value in required.items():
|
||||||
|
if not str(value or "").strip():
|
||||||
|
raise ValueError(f"Search document {field_name} is required.")
|
||||||
|
limits = {
|
||||||
|
"module_id": 100,
|
||||||
|
"resource_type": 100,
|
||||||
|
"resource_id": 255,
|
||||||
|
"title": 500,
|
||||||
|
"url": 1500,
|
||||||
|
"summary": 4_000,
|
||||||
|
"body": 200_000,
|
||||||
|
"source_revision": 255,
|
||||||
|
"change_cursor": 500,
|
||||||
|
"language": 32,
|
||||||
|
}
|
||||||
|
for field_name, limit in limits.items():
|
||||||
|
value = getattr(self, field_name)
|
||||||
|
if value is not None and len(str(value)) > limit:
|
||||||
|
raise ValueError(
|
||||||
|
f"Search document {field_name} is limited to "
|
||||||
|
f"{limit} characters."
|
||||||
|
)
|
||||||
|
if len(self.keywords) > 100 or any(
|
||||||
|
len(keyword) > 200
|
||||||
|
for keyword in self.keywords
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Search documents support at most 100 bounded keywords."
|
||||||
|
)
|
||||||
|
if len(self.acl_tokens) > 500 or any(
|
||||||
|
len(token) > 500
|
||||||
|
for token in self.acl_tokens
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Search documents support at most 500 bounded ACL tokens."
|
||||||
|
)
|
||||||
|
if self.visibility == "restricted" and not self.acl_tokens:
|
||||||
|
raise ValueError(
|
||||||
|
"Restricted search documents require at least one ACL token."
|
||||||
|
)
|
||||||
|
if not self.source_revision.strip():
|
||||||
|
raise ValueError(
|
||||||
|
"Search documents require a stable source revision."
|
||||||
|
)
|
||||||
|
if self.index_version < 1:
|
||||||
|
raise ValueError("Search document index versions start at one.")
|
||||||
|
if (
|
||||||
|
self.requires_authorization_recheck
|
||||||
|
and not str(self.provider_id or "").strip()
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Search documents requiring authorization rechecks must "
|
||||||
|
"name their source provider."
|
||||||
|
)
|
||||||
|
if self.provider_id is not None and len(self.provider_id) > 200:
|
||||||
|
raise ValueError(
|
||||||
|
"Search document provider_id is limited to 200 characters."
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reference(self) -> SearchResourceReference:
|
||||||
|
return SearchResourceReference(
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
module_id=self.module_id,
|
||||||
|
resource_type=self.resource_type,
|
||||||
|
resource_id=self.resource_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchAuthorizationRequest:
|
||||||
|
reference: SearchResourceReference
|
||||||
|
source_revision: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchBackfillRequest:
|
||||||
|
tenant_id: str
|
||||||
|
provider_id: str
|
||||||
|
resource_type: str
|
||||||
|
rebuild_id: str
|
||||||
|
cursor: str | None = None
|
||||||
|
limit: int = 100
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not 1 <= self.limit <= 500:
|
||||||
|
raise ValueError(
|
||||||
|
"Search backfill page size must be between 1 and 500."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchBackfillPage:
|
||||||
|
documents: tuple[SearchDocument, ...]
|
||||||
|
next_cursor: str | None
|
||||||
|
complete: bool
|
||||||
|
high_watermark: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if len(self.documents) > 500:
|
||||||
|
raise ValueError(
|
||||||
|
"Search backfill providers returned more than 500 documents."
|
||||||
|
)
|
||||||
|
if not self.complete and not self.next_cursor:
|
||||||
|
raise ValueError(
|
||||||
|
"Incomplete search backfill pages require a next cursor."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchIndexChange:
|
||||||
|
change_id: str
|
||||||
|
provider_id: str
|
||||||
|
kind: SearchIndexChangeKind
|
||||||
|
reference: SearchResourceReference
|
||||||
|
source_revision: str
|
||||||
|
cursor: str
|
||||||
|
document: SearchDocument | None = None
|
||||||
|
occurred_at: datetime | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not all(
|
||||||
|
value.strip()
|
||||||
|
for value in (
|
||||||
|
self.change_id,
|
||||||
|
self.provider_id,
|
||||||
|
self.source_revision,
|
||||||
|
self.cursor,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Search index changes require stable identity and revision."
|
||||||
|
)
|
||||||
|
if self.kind == "upsert":
|
||||||
|
if (
|
||||||
|
self.document is None
|
||||||
|
or self.document.reference != self.reference
|
||||||
|
or self.document.provider_id != self.provider_id
|
||||||
|
or self.document.source_revision
|
||||||
|
!= self.source_revision
|
||||||
|
or self.document.change_cursor != self.cursor
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Search upserts require a matching document."
|
||||||
|
)
|
||||||
|
elif self.document is not None:
|
||||||
|
raise ValueError(
|
||||||
|
"Search deletes must not contain a document."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class SearchProvider(Protocol):
|
||||||
|
def search(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: SearchQuery,
|
||||||
|
) -> Sequence[SearchResult]:
|
||||||
|
"""Return only results the principal may currently read."""
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class SearchIndexWriter(Protocol):
|
||||||
|
def upsert_document(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
document: SearchDocument,
|
||||||
|
) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def delete_document(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
module_id: str,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
) -> bool:
|
||||||
|
...
|
||||||
|
|
||||||
|
def enqueue_change(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
change: SearchIndexChange,
|
||||||
|
) -> bool:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class SearchSourceProvider(Protocol):
|
||||||
|
def resource_types(self) -> Sequence[SearchResourceType]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def backfill(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
request: SearchBackfillRequest,
|
||||||
|
) -> SearchBackfillPage:
|
||||||
|
...
|
||||||
|
|
||||||
|
def authorize(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
requests: Sequence[SearchAuthorizationRequest],
|
||||||
|
) -> Mapping[str, bool]:
|
||||||
|
"""Return an explicit decision for every requested reference key."""
|
||||||
|
|
||||||
|
|
||||||
|
SearchProviderFactory = Callable[[ModuleContext], SearchProvider]
|
||||||
|
SearchSourceProviderFactory = Callable[
|
||||||
|
[ModuleContext],
|
||||||
|
SearchSourceProvider,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchProviderRegistration:
|
||||||
|
id: str
|
||||||
|
factory: SearchProviderFactory
|
||||||
|
resource_types: tuple[str, ...] = ()
|
||||||
|
order: int = 100
|
||||||
|
|
||||||
|
def create(self, context: ModuleContext) -> SearchProvider:
|
||||||
|
provider = self.factory(context)
|
||||||
|
if not isinstance(provider, SearchProvider):
|
||||||
|
raise TypeError(
|
||||||
|
f"Search provider {self.id!r} does not implement SearchProvider."
|
||||||
|
)
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RegisteredSearchProvider:
|
||||||
|
module_id: str
|
||||||
|
registration: SearchProviderRegistration
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchSourceProviderRegistration:
|
||||||
|
id: str
|
||||||
|
factory: SearchSourceProviderFactory
|
||||||
|
order: int = 100
|
||||||
|
|
||||||
|
def create(self, context: ModuleContext) -> SearchSourceProvider:
|
||||||
|
provider = self.factory(context)
|
||||||
|
if not isinstance(provider, SearchSourceProvider):
|
||||||
|
raise TypeError(
|
||||||
|
f"Search source {self.id!r} does not implement "
|
||||||
|
"SearchSourceProvider."
|
||||||
|
)
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RegisteredSearchSourceProvider:
|
||||||
|
module_id: str
|
||||||
|
registration: SearchSourceProviderRegistration
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_SEARCH_INDEX_WRITER = "search.index_writer"
|
||||||
|
|
||||||
|
|
||||||
|
def search_index_writer(registry: object | None) -> SearchIndexWriter | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_SEARCH_INDEX_WRITER)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
writer = registry.capability(CAPABILITY_SEARCH_INDEX_WRITER)
|
||||||
|
return writer if isinstance(writer, SearchIndexWriter) else None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_SEARCH_INDEX_WRITER",
|
||||||
|
"SEARCH_SOURCE_CONTRACT_VERSION",
|
||||||
|
"RegisteredSearchProvider",
|
||||||
|
"RegisteredSearchSourceProvider",
|
||||||
|
"SearchAuthorizationRequest",
|
||||||
|
"SearchBackfillPage",
|
||||||
|
"SearchBackfillRequest",
|
||||||
|
"SearchContextKind",
|
||||||
|
"SearchDocument",
|
||||||
|
"SearchIndexChange",
|
||||||
|
"SearchIndexChangeKind",
|
||||||
|
"SearchIndexWriter",
|
||||||
|
"SearchProvider",
|
||||||
|
"SearchProviderFactory",
|
||||||
|
"SearchProviderRegistration",
|
||||||
|
"SearchQuery",
|
||||||
|
"SearchResourceReference",
|
||||||
|
"SearchResourceType",
|
||||||
|
"SearchResult",
|
||||||
|
"SearchSourceProvider",
|
||||||
|
"SearchSourceProviderFactory",
|
||||||
|
"SearchSourceProviderRegistration",
|
||||||
|
"SearchVisibility",
|
||||||
|
"search_index_writer",
|
||||||
|
]
|
||||||
68
src/govoplan_core/core/sqlalchemy_change_tracking.py
Normal file
68
src/govoplan_core/core/sqlalchemy_change_tracking.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
ChangeOperation = Literal["created", "updated", "deleted"]
|
||||||
|
|
||||||
|
|
||||||
|
def object_state(obj: object) -> Any:
|
||||||
|
return inspect(obj)
|
||||||
|
|
||||||
|
|
||||||
|
def has_attr_changes(state: Any, attrs: tuple[str, ...]) -> bool:
|
||||||
|
return any(name in state.attrs and state.attrs[name].history.has_changes() for name in attrs)
|
||||||
|
|
||||||
|
|
||||||
|
def previous_value(obj: object, attr_name: str) -> str | None:
|
||||||
|
state = object_state(obj)
|
||||||
|
if attr_name not in state.attrs:
|
||||||
|
return None
|
||||||
|
history = state.attrs[attr_name].history
|
||||||
|
if not history.has_changes() or not history.deleted:
|
||||||
|
return None
|
||||||
|
value = history.deleted[0]
|
||||||
|
return str(value) if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_object_id(obj: object, new_id: Callable[[], str]) -> str:
|
||||||
|
resource_id = getattr(obj, "id", None)
|
||||||
|
if resource_id:
|
||||||
|
return str(resource_id)
|
||||||
|
resource_id = new_id()
|
||||||
|
setattr(obj, "id", resource_id)
|
||||||
|
return resource_id
|
||||||
|
|
||||||
|
|
||||||
|
def operation_for_object(obj: object, *, changed_attrs: tuple[str, ...]) -> ChangeOperation | None:
|
||||||
|
state = object_state(obj)
|
||||||
|
if state.pending:
|
||||||
|
return "created"
|
||||||
|
if state.deleted:
|
||||||
|
return "deleted"
|
||||||
|
if not has_attr_changes(state, changed_attrs):
|
||||||
|
return None
|
||||||
|
return "updated"
|
||||||
|
|
||||||
|
|
||||||
|
def operation_for_soft_deletable(
|
||||||
|
obj: object,
|
||||||
|
*,
|
||||||
|
changed_attrs: tuple[str, ...],
|
||||||
|
deleted_attr: str = "deleted_at",
|
||||||
|
) -> ChangeOperation | None:
|
||||||
|
state = object_state(obj)
|
||||||
|
if state.pending:
|
||||||
|
return "created"
|
||||||
|
if not has_attr_changes(state, changed_attrs):
|
||||||
|
return None
|
||||||
|
if deleted_attr in state.attrs:
|
||||||
|
history = state.attrs[deleted_attr].history
|
||||||
|
if history.has_changes():
|
||||||
|
if any(value is not None for value in history.added):
|
||||||
|
return "deleted"
|
||||||
|
if any(value is not None for value in history.deleted):
|
||||||
|
return "created"
|
||||||
|
return "updated"
|
||||||
264
src/govoplan_core/core/tabular_sources.py
Normal file
264
src/govoplan_core/core/tabular_sources.py
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_CONNECTORS_TABULAR_SOURCES = "connectors.tabularSources"
|
||||||
|
CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER = "connectors.tabularSnapshotWriter"
|
||||||
|
|
||||||
|
|
||||||
|
class TabularSourceError(ValueError):
|
||||||
|
"""Stable base error for provider-neutral tabular source operations."""
|
||||||
|
|
||||||
|
|
||||||
|
class TabularSourceNotFoundError(TabularSourceError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TabularSourceAccessError(TabularSourceError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TabularSourceValidationError(TabularSourceError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def parse_tabular_csv(
|
||||||
|
csv_text: str,
|
||||||
|
*,
|
||||||
|
delimiter: str = ",",
|
||||||
|
max_rows: int = 10_000,
|
||||||
|
) -> tuple[Mapping[str, object], ...]:
|
||||||
|
"""Parse a bounded CSV document into JSON-compatible tabular rows."""
|
||||||
|
|
||||||
|
if len(delimiter) != 1:
|
||||||
|
raise TabularSourceValidationError("CSV delimiter must be one character.")
|
||||||
|
try:
|
||||||
|
reader = csv.DictReader(io.StringIO(csv_text), delimiter=delimiter)
|
||||||
|
original_headers, normalized_headers = _csv_headers(reader.fieldnames)
|
||||||
|
rows: list[dict[str, object]] = []
|
||||||
|
for row in reader:
|
||||||
|
_validate_csv_row_shape(row)
|
||||||
|
if _csv_row_is_empty(row, original_headers):
|
||||||
|
continue
|
||||||
|
if len(rows) >= max_rows:
|
||||||
|
raise TabularSourceValidationError(
|
||||||
|
f"CSV snapshots are limited to {max_rows:,} rows."
|
||||||
|
)
|
||||||
|
rows.append(_csv_row(row, original_headers, normalized_headers))
|
||||||
|
return tuple(rows)
|
||||||
|
except csv.Error as exc:
|
||||||
|
raise TabularSourceValidationError(f"CSV input could not be parsed: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_headers(
|
||||||
|
fieldnames: Sequence[str | None] | None,
|
||||||
|
) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||||
|
if not fieldnames:
|
||||||
|
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
|
||||||
|
original = tuple(str(name or "") for name in fieldnames)
|
||||||
|
normalized = tuple(
|
||||||
|
name.strip().lstrip("\ufeff") if position == 0 else name.strip()
|
||||||
|
for position, name in enumerate(original)
|
||||||
|
)
|
||||||
|
if any(not name for name in normalized):
|
||||||
|
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
|
||||||
|
if len(set(normalized)) != len(normalized):
|
||||||
|
raise TabularSourceValidationError("CSV column names must be unique.")
|
||||||
|
return original, normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_csv_row_shape(row: Mapping[str | None, object]) -> None:
|
||||||
|
extras = row.get(None)
|
||||||
|
if isinstance(extras, list) and any(str(value or "").strip() for value in extras):
|
||||||
|
raise TabularSourceValidationError(
|
||||||
|
"A CSV row contains more values than the header defines."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_row_is_empty(
|
||||||
|
row: Mapping[str | None, str | list[str] | None],
|
||||||
|
headers: Sequence[str],
|
||||||
|
) -> bool:
|
||||||
|
return all(
|
||||||
|
value is None or isinstance(value, list) or not value.strip()
|
||||||
|
for value in (row.get(header) for header in headers)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_row(
|
||||||
|
row: Mapping[str | None, str | list[str] | None],
|
||||||
|
original_headers: Sequence[str],
|
||||||
|
normalized_headers: Sequence[str],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
normalized: _csv_scalar(value if isinstance(value, str) else None)
|
||||||
|
for original, normalized in zip(
|
||||||
|
original_headers,
|
||||||
|
normalized_headers,
|
||||||
|
strict=True,
|
||||||
|
)
|
||||||
|
for value in (row.get(original),)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_scalar(value: str | None) -> object:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = value.strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
lowered = text.casefold()
|
||||||
|
if lowered in {"true", "false"}:
|
||||||
|
return lowered == "true"
|
||||||
|
if re.fullmatch(r"-?(?:0|[1-9][0-9]*)", text):
|
||||||
|
return int(text)
|
||||||
|
if re.fullmatch(r"-?(?:0|[1-9][0-9]*)\.[0-9]+", text):
|
||||||
|
return float(text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TabularColumn:
|
||||||
|
name: str
|
||||||
|
data_type: str
|
||||||
|
nullable: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TabularSource:
|
||||||
|
"""Opaque, policy-filtered source reference exposed to consuming modules."""
|
||||||
|
|
||||||
|
ref: str
|
||||||
|
provider: str
|
||||||
|
source_name: str
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
schema: tuple[TabularColumn, ...] = ()
|
||||||
|
schema_version: str = "1"
|
||||||
|
fingerprint: str = ""
|
||||||
|
row_count: int | None = None
|
||||||
|
byte_count: int | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
capabilities: tuple[str, ...] = ("read",)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TabularReadRequest:
|
||||||
|
source_ref: str
|
||||||
|
limit: int = 250
|
||||||
|
offset: int = 0
|
||||||
|
columns: tuple[str, ...] = ()
|
||||||
|
expected_fingerprint: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TabularReadResult:
|
||||||
|
source: TabularSource
|
||||||
|
rows: tuple[Mapping[str, object], ...]
|
||||||
|
total_rows: int
|
||||||
|
truncated: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TabularSnapshotInput:
|
||||||
|
name: str
|
||||||
|
source_name: str
|
||||||
|
rows: tuple[Mapping[str, object], ...]
|
||||||
|
description: str | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class TabularSourceProvider(Protocol):
|
||||||
|
"""Principal-aware catalogue and bounded reader for tabular sources."""
|
||||||
|
|
||||||
|
def list_sources(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: str = "",
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[TabularSource]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_source(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
source_ref: str,
|
||||||
|
) -> TabularSource | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def read_source(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: TabularReadRequest,
|
||||||
|
) -> TabularReadResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class TabularSnapshotWriter(Protocol):
|
||||||
|
"""Optional capability for importing bounded static tabular snapshots."""
|
||||||
|
|
||||||
|
def create_snapshot(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
snapshot: TabularSnapshotInput,
|
||||||
|
) -> TabularSource:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def tabular_source_provider(registry: object | None) -> TabularSourceProvider | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_CONNECTORS_TABULAR_SOURCES)
|
||||||
|
return capability if isinstance(capability, TabularSourceProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def tabular_snapshot_writer(registry: object | None) -> TabularSnapshotWriter | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER)
|
||||||
|
return capability if isinstance(capability, TabularSnapshotWriter) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _capability(registry: object | None, name: str) -> object | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(name)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return registry.capability(name)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER",
|
||||||
|
"CAPABILITY_CONNECTORS_TABULAR_SOURCES",
|
||||||
|
"TabularColumn",
|
||||||
|
"TabularReadRequest",
|
||||||
|
"TabularReadResult",
|
||||||
|
"TabularSnapshotInput",
|
||||||
|
"TabularSnapshotWriter",
|
||||||
|
"TabularSource",
|
||||||
|
"TabularSourceAccessError",
|
||||||
|
"TabularSourceError",
|
||||||
|
"TabularSourceNotFoundError",
|
||||||
|
"TabularSourceProvider",
|
||||||
|
"TabularSourceValidationError",
|
||||||
|
"parse_tabular_csv",
|
||||||
|
"tabular_snapshot_writer",
|
||||||
|
"tabular_source_provider",
|
||||||
|
]
|
||||||
349
src/govoplan_core/core/throttling.py
Normal file
349
src/govoplan_core/core/throttling.py
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable, Iterable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from redis import Redis
|
||||||
|
from redis.exceptions import RedisError
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_NAMESPACE_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]{0,99}$")
|
||||||
|
_REDIS_INCREMENT_SCRIPT = """
|
||||||
|
local count = redis.call('INCR', KEYS[1])
|
||||||
|
if count == 1 then
|
||||||
|
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
||||||
|
end
|
||||||
|
local ttl = redis.call('TTL', KEYS[1])
|
||||||
|
return {count, ttl}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FixedWindowBucket:
|
||||||
|
count: int = 0
|
||||||
|
retry_after_seconds: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ThrottleDimension:
|
||||||
|
"""One independently limited dimension without putting its value in keys."""
|
||||||
|
|
||||||
|
namespace: str
|
||||||
|
subject: str
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ThrottleDecision:
|
||||||
|
allowed: bool
|
||||||
|
retry_after_seconds: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class FixedWindowStore(Protocol):
|
||||||
|
def read(self, key: str) -> FixedWindowBucket: ...
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket: ...
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryFixedWindowStore:
|
||||||
|
"""Bounded process-local store for development and distributed-store loss."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
max_entries: int = 10_000,
|
||||||
|
clock: Callable[[], float] = time.monotonic,
|
||||||
|
) -> None:
|
||||||
|
self._entries: dict[str, tuple[int, float]] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._max_entries = max(2, max_entries)
|
||||||
|
self._clock = clock
|
||||||
|
|
||||||
|
def read(self, key: str) -> FixedWindowBucket:
|
||||||
|
now = self._clock()
|
||||||
|
with self._lock:
|
||||||
|
entry = self._active_entry(key, now=now)
|
||||||
|
if entry is None:
|
||||||
|
return FixedWindowBucket()
|
||||||
|
count, expires_at = entry
|
||||||
|
return FixedWindowBucket(
|
||||||
|
count=count,
|
||||||
|
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
|
||||||
|
now = self._clock()
|
||||||
|
with self._lock:
|
||||||
|
entry = self._active_entry(key, now=now)
|
||||||
|
if entry is None:
|
||||||
|
self._make_room(now=now, incoming_key=key)
|
||||||
|
count = 1
|
||||||
|
expires_at = now + window_seconds
|
||||||
|
else:
|
||||||
|
count = entry[0] + 1
|
||||||
|
expires_at = entry[1]
|
||||||
|
self._entries[key] = (count, expires_at)
|
||||||
|
return FixedWindowBucket(
|
||||||
|
count=count,
|
||||||
|
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._entries.pop(key, None)
|
||||||
|
|
||||||
|
def _active_entry(self, key: str, *, now: float) -> tuple[int, float] | None:
|
||||||
|
entry = self._entries.get(key)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
if entry[1] <= now:
|
||||||
|
self._entries.pop(key, None)
|
||||||
|
return None
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def _make_room(self, *, now: float, incoming_key: str) -> None:
|
||||||
|
if incoming_key in self._entries or len(self._entries) < self._max_entries:
|
||||||
|
return
|
||||||
|
for key, (_count, expires_at) in tuple(self._entries.items()):
|
||||||
|
if expires_at <= now:
|
||||||
|
self._entries.pop(key, None)
|
||||||
|
while len(self._entries) >= self._max_entries:
|
||||||
|
self._entries.pop(next(iter(self._entries)))
|
||||||
|
|
||||||
|
|
||||||
|
class RedisFixedWindowStore:
|
||||||
|
"""Atomic Redis fixed-window counters shared across API workers."""
|
||||||
|
|
||||||
|
def __init__(self, redis_url: str) -> None:
|
||||||
|
self._client = Redis.from_url(
|
||||||
|
redis_url,
|
||||||
|
decode_responses=True,
|
||||||
|
socket_connect_timeout=0.25,
|
||||||
|
socket_timeout=0.25,
|
||||||
|
health_check_interval=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
def read(self, key: str) -> FixedWindowBucket:
|
||||||
|
pipeline = self._client.pipeline(transaction=False)
|
||||||
|
pipeline.get(key)
|
||||||
|
pipeline.ttl(key)
|
||||||
|
raw_count, raw_ttl = pipeline.execute()
|
||||||
|
return FixedWindowBucket(
|
||||||
|
count=int(raw_count or 0),
|
||||||
|
retry_after_seconds=max(0, int(raw_ttl or 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
|
||||||
|
result = self._client.eval(
|
||||||
|
_REDIS_INCREMENT_SCRIPT,
|
||||||
|
1,
|
||||||
|
key,
|
||||||
|
window_seconds,
|
||||||
|
)
|
||||||
|
if not isinstance(result, (list, tuple)) or len(result) != 2:
|
||||||
|
raise RedisError("Unexpected fixed-window throttle response from Redis")
|
||||||
|
return FixedWindowBucket(
|
||||||
|
count=int(result[0]),
|
||||||
|
retry_after_seconds=max(1, int(result[1])),
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
self._client.delete(key)
|
||||||
|
|
||||||
|
|
||||||
|
class ResilientFixedWindowStore:
|
||||||
|
"""Mirror locally, prefer Redis, and fall back safely during an outage."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
primary: FixedWindowStore | None,
|
||||||
|
fallback: FixedWindowStore,
|
||||||
|
*,
|
||||||
|
retry_seconds: int = 30,
|
||||||
|
clock: Callable[[], float] = time.monotonic,
|
||||||
|
) -> None:
|
||||||
|
self._primary = primary
|
||||||
|
self._fallback = fallback
|
||||||
|
self._retry_seconds = max(1, retry_seconds)
|
||||||
|
self._clock = clock
|
||||||
|
self._primary_unavailable_until = 0.0
|
||||||
|
self._state_lock = threading.Lock()
|
||||||
|
|
||||||
|
def read(self, key: str) -> FixedWindowBucket:
|
||||||
|
fallback_result = self._fallback.read(key)
|
||||||
|
primary = self._available_primary()
|
||||||
|
if primary is None:
|
||||||
|
return fallback_result
|
||||||
|
try:
|
||||||
|
return _stricter_bucket(primary.read(key), fallback_result)
|
||||||
|
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||||
|
self._mark_primary_unavailable(exc)
|
||||||
|
return fallback_result
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
|
||||||
|
fallback_result = self._fallback.increment(
|
||||||
|
key,
|
||||||
|
window_seconds=window_seconds,
|
||||||
|
)
|
||||||
|
primary = self._available_primary()
|
||||||
|
if primary is None:
|
||||||
|
return fallback_result
|
||||||
|
try:
|
||||||
|
primary_result = primary.increment(
|
||||||
|
key,
|
||||||
|
window_seconds=window_seconds,
|
||||||
|
)
|
||||||
|
return _stricter_bucket(primary_result, fallback_result)
|
||||||
|
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||||
|
self._mark_primary_unavailable(exc)
|
||||||
|
return fallback_result
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
self._fallback.delete(key)
|
||||||
|
primary = self._available_primary()
|
||||||
|
if primary is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
primary.delete(key)
|
||||||
|
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||||
|
self._mark_primary_unavailable(exc)
|
||||||
|
|
||||||
|
def _available_primary(self) -> FixedWindowStore | None:
|
||||||
|
if self._primary is None:
|
||||||
|
return None
|
||||||
|
with self._state_lock:
|
||||||
|
if self._clock() < self._primary_unavailable_until:
|
||||||
|
return None
|
||||||
|
return self._primary
|
||||||
|
|
||||||
|
def _mark_primary_unavailable(self, exc: Exception) -> None:
|
||||||
|
should_log = False
|
||||||
|
with self._state_lock:
|
||||||
|
now = self._clock()
|
||||||
|
if now >= self._primary_unavailable_until:
|
||||||
|
should_log = True
|
||||||
|
self._primary_unavailable_until = now + self._retry_seconds
|
||||||
|
if should_log:
|
||||||
|
logger.warning(
|
||||||
|
"Distributed throttling is unavailable; using the process-local fallback (%s)",
|
||||||
|
type(exc).__name__,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stricter_bucket(
|
||||||
|
first: FixedWindowBucket,
|
||||||
|
second: FixedWindowBucket,
|
||||||
|
) -> FixedWindowBucket:
|
||||||
|
return FixedWindowBucket(
|
||||||
|
count=max(first.count, second.count),
|
||||||
|
retry_after_seconds=max(
|
||||||
|
first.retry_after_seconds,
|
||||||
|
second.retry_after_seconds,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FixedWindowThrottle:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: FixedWindowStore,
|
||||||
|
*,
|
||||||
|
window_seconds: int,
|
||||||
|
key_prefix: str = "govoplan:throttle:v1",
|
||||||
|
) -> None:
|
||||||
|
self._store = store
|
||||||
|
self._window_seconds = max(1, window_seconds)
|
||||||
|
self._key_prefix = key_prefix.rstrip(":")
|
||||||
|
|
||||||
|
def check(self, dimensions: Iterable[ThrottleDimension]) -> ThrottleDecision:
|
||||||
|
return self._decision(tuple(dimensions), increment=False)
|
||||||
|
|
||||||
|
def record(self, dimensions: Iterable[ThrottleDimension]) -> ThrottleDecision:
|
||||||
|
return self._decision(tuple(dimensions), increment=True)
|
||||||
|
|
||||||
|
def reset(self, dimensions: Iterable[ThrottleDimension]) -> None:
|
||||||
|
for dimension in tuple(dimensions):
|
||||||
|
self._store.delete(self._key(dimension))
|
||||||
|
|
||||||
|
def _decision(
|
||||||
|
self,
|
||||||
|
dimensions: tuple[ThrottleDimension, ...],
|
||||||
|
*,
|
||||||
|
increment: bool,
|
||||||
|
) -> ThrottleDecision:
|
||||||
|
if not dimensions:
|
||||||
|
raise ValueError("At least one throttle dimension is required")
|
||||||
|
blocked_retry_after = 0
|
||||||
|
for dimension in dimensions:
|
||||||
|
if dimension.limit < 1:
|
||||||
|
raise ValueError("Throttle limits must be positive")
|
||||||
|
key = self._key(dimension)
|
||||||
|
state = (
|
||||||
|
self._store.increment(key, window_seconds=self._window_seconds)
|
||||||
|
if increment
|
||||||
|
else self._store.read(key)
|
||||||
|
)
|
||||||
|
if state.count >= dimension.limit:
|
||||||
|
blocked_retry_after = max(
|
||||||
|
blocked_retry_after,
|
||||||
|
max(1, state.retry_after_seconds),
|
||||||
|
)
|
||||||
|
return ThrottleDecision(
|
||||||
|
allowed=blocked_retry_after == 0,
|
||||||
|
retry_after_seconds=blocked_retry_after,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _key(self, dimension: ThrottleDimension) -> str:
|
||||||
|
namespace = dimension.namespace.strip().casefold()
|
||||||
|
if not _NAMESPACE_PATTERN.fullmatch(namespace):
|
||||||
|
raise ValueError("Throttle namespaces must use lowercase letters, digits, '.', '_' or '-'")
|
||||||
|
subject_digest = hashlib.sha256(dimension.subject.encode("utf-8")).hexdigest()
|
||||||
|
return f"{self._key_prefix}:{namespace}:{subject_digest}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_fixed_window_throttle(
|
||||||
|
*,
|
||||||
|
redis_url: str | None,
|
||||||
|
window_seconds: int,
|
||||||
|
redis_retry_seconds: int = 30,
|
||||||
|
max_local_entries: int = 10_000,
|
||||||
|
key_prefix: str = "govoplan:throttle:v1",
|
||||||
|
) -> FixedWindowThrottle:
|
||||||
|
primary = (
|
||||||
|
RedisFixedWindowStore(redis_url)
|
||||||
|
if redis_url is not None and redis_url.strip()
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
store = ResilientFixedWindowStore(
|
||||||
|
primary,
|
||||||
|
InMemoryFixedWindowStore(max_entries=max_local_entries),
|
||||||
|
retry_seconds=redis_retry_seconds,
|
||||||
|
)
|
||||||
|
return FixedWindowThrottle(
|
||||||
|
store,
|
||||||
|
window_seconds=window_seconds,
|
||||||
|
key_prefix=key_prefix,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FixedWindowBucket",
|
||||||
|
"FixedWindowStore",
|
||||||
|
"FixedWindowThrottle",
|
||||||
|
"InMemoryFixedWindowStore",
|
||||||
|
"RedisFixedWindowStore",
|
||||||
|
"ResilientFixedWindowStore",
|
||||||
|
"ThrottleDecision",
|
||||||
|
"ThrottleDimension",
|
||||||
|
"build_fixed_window_throttle",
|
||||||
|
]
|
||||||
95
src/govoplan_core/core/views.py
Normal file
95
src/govoplan_core/core/views.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
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
|
||||||
|
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,
|
||||||
|
) -> EffectiveView: ...
|
||||||
|
|
||||||
|
|
||||||
|
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",
|
||||||
|
]
|
||||||
44
src/govoplan_core/core/workflows.py
Normal file
44
src/govoplan_core/core/workflows.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_WORKFLOW_RUNTIME_WORKER = "workflow.runtimeWorker"
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class WorkflowRuntimeWorker(Protocol):
|
||||||
|
def reconcile_pending(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_WORKFLOW_RUNTIME_WORKER",
|
||||||
|
"WorkflowRuntimeWorker",
|
||||||
|
"workflow_runtime_worker",
|
||||||
|
]
|
||||||
@@ -32,6 +32,7 @@ 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.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)
|
||||||
@@ -65,7 +66,7 @@ def bootstrap_dev_data(
|
|||||||
api_key_secret: str | None = None,
|
api_key_secret: str | None = None,
|
||||||
tenant_slug: str = "default",
|
tenant_slug: str = "default",
|
||||||
user_email: str = "admin@example.local",
|
user_email: str = "admin@example.local",
|
||||||
user_password: str = "dev-admin",
|
user_password: str = "dev-admin", # noqa: S107 - development bootstrap only.
|
||||||
) -> BootstrapResult:
|
) -> BootstrapResult:
|
||||||
tenant = session.query(Tenant).filter(Tenant.slug == tenant_slug).one_or_none()
|
tenant = session.query(Tenant).filter(Tenant.slug == tenant_slug).one_or_none()
|
||||||
if tenant is None:
|
if tenant is None:
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Iterable, Mapping
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sysconfig
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from alembic import command
|
from alembic import command
|
||||||
@@ -15,6 +18,7 @@ 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.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
|
||||||
@@ -24,6 +28,8 @@ from govoplan_core.server.config import ManifestFactory
|
|||||||
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
||||||
from govoplan_core.settings import settings
|
from govoplan_core.settings import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Historic development databases could be created partly through Alembic and
|
# Historic development databases could be created partly through Alembic and
|
||||||
# partly through Base.metadata.create_all(). In that state Alembic still says
|
# partly through Base.metadata.create_all(). In that state Alembic still says
|
||||||
# "2c..." while the 3d/4e file-storage tables already exist, so a normal
|
# "2c..." while the 3d/4e file-storage tables already exist, so a normal
|
||||||
@@ -44,6 +50,7 @@ MIGRATION_TASK_PHASES = (*PRE_MIGRATION_TASK_PHASES, *POST_MIGRATION_TASK_PHASES
|
|||||||
MIGRATION_TRACK_RELEASE = "release"
|
MIGRATION_TRACK_RELEASE = "release"
|
||||||
MIGRATION_TRACK_DEV = "dev"
|
MIGRATION_TRACK_DEV = "dev"
|
||||||
MIGRATION_TRACKS = (MIGRATION_TRACK_RELEASE, MIGRATION_TRACK_DEV)
|
MIGRATION_TRACKS = (MIGRATION_TRACK_RELEASE, MIGRATION_TRACK_DEV)
|
||||||
|
_SQL_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||||
|
|
||||||
_NAMESPACE_TABLE_RENAMES = (
|
_NAMESPACE_TABLE_RENAMES = (
|
||||||
("tenants", "tenancy_tenants"),
|
("tenants", "tenancy_tenants"),
|
||||||
@@ -164,6 +171,15 @@ _CREATE_ALL_THROUGH_HIERARCHICAL_INDEXES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _LegacyCreateAllSchemaState:
|
||||||
|
current: str | None
|
||||||
|
has_no_revision: bool
|
||||||
|
has_file_storage: bool
|
||||||
|
has_file_folders: bool
|
||||||
|
has_create_all_hierarchical_schema: bool
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class MigrationResult:
|
class MigrationResult:
|
||||||
previous_revision: str | None
|
previous_revision: str | None
|
||||||
@@ -201,7 +217,10 @@ def _registered_module_registry(
|
|||||||
server_config = get_server_config()
|
server_config = get_server_config()
|
||||||
active_database_url = database_url or settings.database_url
|
active_database_url = database_url or settings.database_url
|
||||||
if active_database_url:
|
if active_database_url:
|
||||||
configure_database(active_database_url)
|
# Registry planning may target a different database than the currently
|
||||||
|
# configured handle. The global handle is replaced either way, so
|
||||||
|
# dispose its superseded pool instead of leaking open DBAPI connections.
|
||||||
|
configure_database(active_database_url, dispose_previous=True)
|
||||||
active_manifest_factories = manifest_factories or tuple(server_config.manifest_factories)
|
active_manifest_factories = manifest_factories or tuple(server_config.manifest_factories)
|
||||||
raw_enabled_modules = tuple(enabled_modules) if enabled_modules is not None else load_startup_enabled_modules(server_config.enabled_modules)
|
raw_enabled_modules = tuple(enabled_modules) if enabled_modules is not None else load_startup_enabled_modules(server_config.enabled_modules)
|
||||||
candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
|
candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
|
||||||
@@ -231,10 +250,7 @@ def run_registered_module_migration_tasks(
|
|||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
manifest_factories: tuple[ManifestFactory, ...] = (),
|
manifest_factories: tuple[ManifestFactory, ...] = (),
|
||||||
) -> tuple[dict[str, object], ...]:
|
) -> tuple[dict[str, object], ...]:
|
||||||
active_phases = tuple(dict.fromkeys(str(item).strip() for item in phases if str(item).strip()))
|
active_phases = _normalized_migration_task_phases(phases)
|
||||||
invalid_phases = tuple(phase for phase in active_phases if phase not in MIGRATION_TASK_PHASES)
|
|
||||||
if invalid_phases:
|
|
||||||
raise ValueError("Unsupported module migration task phase(s): " + ", ".join(invalid_phases))
|
|
||||||
url = database_url or settings.database_url
|
url = database_url or settings.database_url
|
||||||
registry = _registered_module_registry(
|
registry = _registered_module_registry(
|
||||||
database_url=url,
|
database_url=url,
|
||||||
@@ -242,75 +258,81 @@ def run_registered_module_migration_tasks(
|
|||||||
manifest_factories=manifest_factories,
|
manifest_factories=manifest_factories,
|
||||||
)
|
)
|
||||||
manifests = {manifest.id: manifest for manifest in registry.manifests()}
|
manifests = {manifest.id: manifest for manifest in registry.manifests()}
|
||||||
ordered_ids = tuple(dict.fromkeys([
|
ordered_ids = _ordered_migration_task_module_ids(migration_module_order, manifests)
|
||||||
*(str(item).strip() for item in (migration_module_order or ()) if str(item).strip()),
|
|
||||||
*manifests.keys(),
|
|
||||||
]))
|
|
||||||
records: list[dict[str, object]] = []
|
records: list[dict[str, object]] = []
|
||||||
database = get_database()
|
database = get_database()
|
||||||
with database.SessionLocal() as session:
|
with database.SessionLocal() as session:
|
||||||
for phase in active_phases:
|
for phase in active_phases:
|
||||||
|
for manifest, task in _iter_phase_migration_tasks(phase, ordered_ids=ordered_ids, manifests=manifests):
|
||||||
|
_run_registered_module_migration_task(
|
||||||
|
session,
|
||||||
|
manifest=manifest,
|
||||||
|
task=task,
|
||||||
|
database_url=url,
|
||||||
|
dry_run=dry_run,
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
return tuple(records)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_migration_task_phases(phases: tuple[str, ...] | list[str]) -> tuple[str, ...]:
|
||||||
|
active_phases = tuple(dict.fromkeys(str(item).strip() for item in phases if str(item).strip()))
|
||||||
|
invalid_phases = tuple(phase for phase in active_phases if phase not in MIGRATION_TASK_PHASES)
|
||||||
|
if invalid_phases:
|
||||||
|
raise ValueError("Unsupported module migration task phase(s): " + ", ".join(invalid_phases))
|
||||||
|
return active_phases
|
||||||
|
|
||||||
|
|
||||||
|
def _ordered_migration_task_module_ids(
|
||||||
|
migration_module_order: tuple[str, ...] | list[str] | None,
|
||||||
|
manifests: Mapping[str, object],
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
return tuple(dict.fromkeys([
|
||||||
|
*(str(item).strip() for item in (migration_module_order or ()) if str(item).strip()),
|
||||||
|
*manifests.keys(),
|
||||||
|
]))
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_phase_migration_tasks(
|
||||||
|
phase: str,
|
||||||
|
*,
|
||||||
|
ordered_ids: tuple[str, ...],
|
||||||
|
manifests: Mapping[str, object],
|
||||||
|
) -> Iterable[tuple[object, object]]:
|
||||||
for module_id in ordered_ids:
|
for module_id in ordered_ids:
|
||||||
manifest = manifests.get(module_id)
|
manifest = manifests.get(module_id)
|
||||||
if manifest is None or manifest.migration_spec is None:
|
migration_spec = getattr(manifest, "migration_spec", None)
|
||||||
|
if manifest is None or migration_spec is None:
|
||||||
continue
|
continue
|
||||||
for task in manifest.migration_spec.migration_tasks:
|
for task in migration_spec.migration_tasks:
|
||||||
if task.phase != phase:
|
if task.phase == phase:
|
||||||
continue
|
yield manifest, task
|
||||||
record: dict[str, object] = {
|
|
||||||
"module_id": manifest.id,
|
|
||||||
"task_id": task.task_id,
|
def _run_registered_module_migration_task(
|
||||||
"phase": task.phase,
|
session: object,
|
||||||
"summary": task.summary,
|
*,
|
||||||
"task_version": task.task_version,
|
manifest: object,
|
||||||
"safety": task.safety,
|
task: object,
|
||||||
"idempotent": task.idempotent,
|
database_url: str,
|
||||||
"dry_run": dry_run,
|
dry_run: bool,
|
||||||
}
|
records: list[dict[str, object]],
|
||||||
if task.timeout_seconds is not None:
|
) -> None:
|
||||||
record["timeout_seconds"] = task.timeout_seconds
|
record = _migration_task_record(manifest, task, dry_run=dry_run)
|
||||||
if not task.idempotent:
|
_validate_migration_task_can_run(manifest, task, record, records, dry_run=dry_run)
|
||||||
record.update({"status": "blocked", "message": "Task is not idempotent."})
|
|
||||||
records.append(record)
|
|
||||||
raise ModuleMigrationTaskExecutionError(
|
|
||||||
f"Module migration task {manifest.id}/{task.task_id} is not idempotent.",
|
|
||||||
records=tuple(records),
|
|
||||||
)
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
record.update({"status": "skipped", "message": "Dry run; executor was not called."})
|
record.update({"status": "skipped", "message": "Dry run; executor was not called."})
|
||||||
records.append(record)
|
records.append(record)
|
||||||
continue
|
return
|
||||||
if task.executor is None:
|
normalized = _execute_module_migration_task(
|
||||||
record.update({"status": "blocked", "message": "Task has no executor."})
|
session,
|
||||||
records.append(record)
|
manifest=manifest,
|
||||||
raise ModuleMigrationTaskExecutionError(
|
task=task,
|
||||||
f"Module migration task {manifest.id}/{task.task_id} has no executor.",
|
record=record,
|
||||||
records=tuple(records),
|
records=records,
|
||||||
)
|
database_url=database_url,
|
||||||
context = ModuleMigrationTaskContext(
|
|
||||||
module_id=manifest.id,
|
|
||||||
task_id=task.task_id,
|
|
||||||
phase=task.phase,
|
|
||||||
database_url=url,
|
|
||||||
target_version=manifest.version,
|
|
||||||
session=session,
|
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
metadata=task.metadata,
|
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
result = task.executor(context)
|
|
||||||
normalized = _normalize_migration_task_result(result)
|
|
||||||
except Exception as exc:
|
|
||||||
session.rollback()
|
|
||||||
record.update({
|
|
||||||
"status": "blocked",
|
|
||||||
"message": f"{type(exc).__name__}: {exc}",
|
|
||||||
})
|
|
||||||
records.append(record)
|
|
||||||
raise ModuleMigrationTaskExecutionError(
|
|
||||||
f"Module migration task {manifest.id}/{task.task_id} failed.",
|
|
||||||
records=tuple(records),
|
|
||||||
) from exc
|
|
||||||
record.update({
|
record.update({
|
||||||
"status": normalized.status,
|
"status": normalized.status,
|
||||||
"message": normalized.message,
|
"message": normalized.message,
|
||||||
@@ -324,7 +346,102 @@ def run_registered_module_migration_tasks(
|
|||||||
records=tuple(records),
|
records=tuple(records),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
return tuple(records)
|
|
||||||
|
|
||||||
|
def _migration_task_record(manifest: object, task: object, *, dry_run: bool) -> dict[str, object]:
|
||||||
|
record: dict[str, object] = {
|
||||||
|
"module_id": manifest.id,
|
||||||
|
"task_id": task.task_id,
|
||||||
|
"phase": task.phase,
|
||||||
|
"summary": task.summary,
|
||||||
|
"task_version": task.task_version,
|
||||||
|
"safety": task.safety,
|
||||||
|
"idempotent": task.idempotent,
|
||||||
|
"dry_run": dry_run,
|
||||||
|
}
|
||||||
|
if task.timeout_seconds is not None:
|
||||||
|
record["timeout_seconds"] = task.timeout_seconds
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_migration_task_can_run(
|
||||||
|
manifest: object,
|
||||||
|
task: object,
|
||||||
|
record: dict[str, object],
|
||||||
|
records: list[dict[str, object]],
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> None:
|
||||||
|
if not task.idempotent:
|
||||||
|
_block_migration_task(
|
||||||
|
manifest,
|
||||||
|
task,
|
||||||
|
record,
|
||||||
|
records,
|
||||||
|
message="Task is not idempotent.",
|
||||||
|
error=f"Module migration task {manifest.id}/{task.task_id} is not idempotent.",
|
||||||
|
)
|
||||||
|
if not dry_run and task.executor is None:
|
||||||
|
_block_migration_task(
|
||||||
|
manifest,
|
||||||
|
task,
|
||||||
|
record,
|
||||||
|
records,
|
||||||
|
message="Task has no executor.",
|
||||||
|
error=f"Module migration task {manifest.id}/{task.task_id} has no executor.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _block_migration_task(
|
||||||
|
manifest: object,
|
||||||
|
task: object,
|
||||||
|
record: dict[str, object],
|
||||||
|
records: list[dict[str, object]],
|
||||||
|
*,
|
||||||
|
message: str,
|
||||||
|
error: str,
|
||||||
|
) -> None:
|
||||||
|
record.update({"status": "blocked", "message": message})
|
||||||
|
records.append(record)
|
||||||
|
raise ModuleMigrationTaskExecutionError(
|
||||||
|
error,
|
||||||
|
records=tuple(records),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_module_migration_task(
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
manifest: object,
|
||||||
|
task: object,
|
||||||
|
record: dict[str, object],
|
||||||
|
records: list[dict[str, object]],
|
||||||
|
database_url: str,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> ModuleMigrationTaskResult:
|
||||||
|
context = ModuleMigrationTaskContext(
|
||||||
|
module_id=manifest.id,
|
||||||
|
task_id=task.task_id,
|
||||||
|
phase=task.phase,
|
||||||
|
database_url=database_url,
|
||||||
|
target_version=manifest.version,
|
||||||
|
session=session,
|
||||||
|
dry_run=dry_run,
|
||||||
|
metadata=task.metadata,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return _normalize_migration_task_result(task.executor(context))
|
||||||
|
except Exception as exc:
|
||||||
|
session.rollback()
|
||||||
|
record.update({
|
||||||
|
"status": "blocked",
|
||||||
|
"message": f"{type(exc).__name__}: {exc}",
|
||||||
|
})
|
||||||
|
records.append(record)
|
||||||
|
raise ModuleMigrationTaskExecutionError(
|
||||||
|
f"Module migration task {manifest.id}/{task.task_id} failed.",
|
||||||
|
records=tuple(records),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
def _normalize_migration_task_result(result: ModuleMigrationTaskResult | None) -> ModuleMigrationTaskResult:
|
def _normalize_migration_task_result(result: ModuleMigrationTaskResult | None) -> ModuleMigrationTaskResult:
|
||||||
@@ -347,11 +464,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:
|
||||||
@@ -521,19 +640,28 @@ def _backfill_user_lock_state_for_create_all_schema(database_url: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _row_count(connection, table_name: str) -> int:
|
def _row_count(connection, table_name: str) -> int:
|
||||||
quoted = connection.dialect.identifier_preparer.quote(table_name)
|
quoted = _quoted_table_name(connection, table_name)
|
||||||
return int(connection.execute(text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one())
|
statement = text(f"SELECT COUNT(*) FROM {quoted}") # noqa: S608 # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||||
|
return int(connection.execute(statement).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
def _drop_table(connection, table_name: str) -> None:
|
def _drop_table(connection, table_name: str) -> None:
|
||||||
quoted = connection.dialect.identifier_preparer.quote(table_name)
|
quoted = _quoted_table_name(connection, table_name)
|
||||||
connection.execute(text(f"DROP TABLE {quoted}"))
|
statement = text(f"DROP TABLE {quoted}") # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||||
|
connection.execute(statement)
|
||||||
|
|
||||||
|
|
||||||
def _rename_table(connection, old_name: str, new_name: str) -> None:
|
def _rename_table(connection, old_name: str, new_name: str) -> None:
|
||||||
quoted_old = connection.dialect.identifier_preparer.quote(old_name)
|
quoted_old = _quoted_table_name(connection, old_name)
|
||||||
quoted_new = connection.dialect.identifier_preparer.quote(new_name)
|
quoted_new = _quoted_table_name(connection, new_name)
|
||||||
connection.execute(text(f"ALTER TABLE {quoted_old} RENAME TO {quoted_new}"))
|
statement = text(f"ALTER TABLE {quoted_old} RENAME TO {quoted_new}") # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||||
|
connection.execute(statement)
|
||||||
|
|
||||||
|
|
||||||
|
def _quoted_table_name(connection, table_name: str) -> str:
|
||||||
|
if not _SQL_IDENTIFIER_RE.fullmatch(table_name):
|
||||||
|
raise ValueError(f"Unsafe table identifier: {table_name!r}")
|
||||||
|
return connection.dialect.identifier_preparer.quote(table_name)
|
||||||
|
|
||||||
|
|
||||||
def _reconcile_scope_table_names(connection, tables: set[str]) -> bool:
|
def _reconcile_scope_table_names(connection, tables: set[str]) -> bool:
|
||||||
@@ -707,7 +835,8 @@ def reconcile_covered_alembic_dependency_heads(
|
|||||||
for revision_id in current:
|
for revision_id in current:
|
||||||
try:
|
try:
|
||||||
revision = script.get_revision(revision_id)
|
revision = script.get_revision(revision_id)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
logger.debug("Skipping Alembic revision %s while pruning dependency heads: %s", revision_id, exc, exc_info=True)
|
||||||
continue
|
continue
|
||||||
ancestors = {
|
ancestors = {
|
||||||
item.revision
|
item.revision
|
||||||
@@ -741,15 +870,34 @@ def reconcile_legacy_create_all_schema(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
url = database_url or settings.database_url
|
url = database_url or settings.database_url
|
||||||
engine = create_engine(url)
|
state = _legacy_create_all_schema_state(url)
|
||||||
|
target = _legacy_create_all_reconciliation_target(state)
|
||||||
|
if target is None:
|
||||||
|
return None
|
||||||
|
if target == REVISION_HIERARCHICAL_SETTINGS:
|
||||||
|
_backfill_user_lock_state_for_create_all_schema(url)
|
||||||
|
|
||||||
|
command.stamp(alembic_config(database_url=url, migration_track=migration_track), target)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_create_all_schema_state(database_url: str) -> _LegacyCreateAllSchemaState:
|
||||||
|
engine = create_engine(database_url)
|
||||||
try:
|
try:
|
||||||
with engine.connect() as connection:
|
with engine.connect() as connection:
|
||||||
heads = MigrationContext.configure(connection).get_current_heads()
|
heads = MigrationContext.configure(connection).get_current_heads()
|
||||||
current = heads[0] if len(heads) == 1 else None
|
|
||||||
has_no_revision = len(heads) == 0
|
|
||||||
schema = inspect(connection)
|
schema = inspect(connection)
|
||||||
tables = set(schema.get_table_names())
|
tables = set(schema.get_table_names())
|
||||||
|
return _legacy_create_all_schema_state_from_inspection(heads, schema, tables)
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_create_all_schema_state_from_inspection(
|
||||||
|
heads: tuple[str, ...],
|
||||||
|
schema: object,
|
||||||
|
tables: set[str],
|
||||||
|
) -> _LegacyCreateAllSchemaState:
|
||||||
has_file_storage = _FILE_STORAGE_TABLES.issubset(tables) and all(
|
has_file_storage = _FILE_STORAGE_TABLES.issubset(tables) and all(
|
||||||
_has_columns(schema, table, _FILE_STORAGE_COLUMNS[table])
|
_has_columns(schema, table, _FILE_STORAGE_COLUMNS[table])
|
||||||
for table in _FILE_STORAGE_TABLES
|
for table in _FILE_STORAGE_TABLES
|
||||||
@@ -759,39 +907,30 @@ def reconcile_legacy_create_all_schema(
|
|||||||
"file_folders",
|
"file_folders",
|
||||||
_FILE_STORAGE_COLUMNS["file_folders"],
|
_FILE_STORAGE_COLUMNS["file_folders"],
|
||||||
)
|
)
|
||||||
has_create_all_hierarchical_schema = _has_create_all_schema_through_hierarchical_settings(schema, tables)
|
return _LegacyCreateAllSchemaState(
|
||||||
finally:
|
current=heads[0] if len(heads) == 1 else None,
|
||||||
engine.dispose()
|
has_no_revision=len(heads) == 0,
|
||||||
|
has_file_storage=has_file_storage,
|
||||||
|
has_file_folders=has_file_folders,
|
||||||
|
has_create_all_hierarchical_schema=_has_create_all_schema_through_hierarchical_settings(schema, tables),
|
||||||
|
)
|
||||||
|
|
||||||
target: str | None = None
|
|
||||||
if current == REVISION_AUTH_RBAC and has_file_storage and has_file_folders:
|
|
||||||
target = REVISION_FILE_FOLDERS
|
|
||||||
elif current == REVISION_AUTH_RBAC and has_file_storage:
|
|
||||||
target = REVISION_FILE_STORAGE
|
|
||||||
elif current == REVISION_FILE_STORAGE and has_file_folders:
|
|
||||||
target = REVISION_FILE_FOLDERS
|
|
||||||
elif current == REVISION_FILE_FOLDERS and has_create_all_hierarchical_schema:
|
|
||||||
# Development DBs may be stamped at 4e after earlier create_all
|
|
||||||
# reconciliation even though later tables/columns were already created
|
|
||||||
# by a newer model set. Skip only when that complete known schema is
|
|
||||||
# present, then let newer migrations such as mail credential usernames
|
|
||||||
# run normally.
|
|
||||||
_backfill_user_lock_state_for_create_all_schema(url)
|
|
||||||
target = REVISION_HIERARCHICAL_SETTINGS
|
|
||||||
elif has_no_revision and has_create_all_hierarchical_schema:
|
|
||||||
_backfill_user_lock_state_for_create_all_schema(url)
|
|
||||||
target = REVISION_HIERARCHICAL_SETTINGS
|
|
||||||
elif has_no_revision and has_file_storage and has_file_folders:
|
|
||||||
# This is the other create_all-only development shape. The strict
|
|
||||||
# column checks above ensure that we only stamp a complete known schema.
|
|
||||||
target = REVISION_FILE_FOLDERS
|
|
||||||
|
|
||||||
if target is None:
|
def _legacy_create_all_reconciliation_target(state: _LegacyCreateAllSchemaState) -> str | None:
|
||||||
|
if state.current == REVISION_AUTH_RBAC and state.has_file_storage and state.has_file_folders:
|
||||||
|
return REVISION_FILE_FOLDERS
|
||||||
|
if state.current == REVISION_AUTH_RBAC and state.has_file_storage:
|
||||||
|
return REVISION_FILE_STORAGE
|
||||||
|
if state.current == REVISION_FILE_STORAGE and state.has_file_folders:
|
||||||
|
return REVISION_FILE_FOLDERS
|
||||||
|
if state.current == REVISION_FILE_FOLDERS and state.has_create_all_hierarchical_schema:
|
||||||
|
return REVISION_HIERARCHICAL_SETTINGS
|
||||||
|
if state.has_no_revision and state.has_create_all_hierarchical_schema:
|
||||||
|
return REVISION_HIERARCHICAL_SETTINGS
|
||||||
|
if state.has_no_revision and state.has_file_storage and state.has_file_folders:
|
||||||
|
return REVISION_FILE_FOLDERS
|
||||||
return None
|
return None
|
||||||
|
|
||||||
command.stamp(alembic_config(database_url=url, migration_track=migration_track), target)
|
|
||||||
return target
|
|
||||||
|
|
||||||
|
|
||||||
def migrate_database(
|
def migrate_database(
|
||||||
*,
|
*,
|
||||||
|
|||||||
100
src/govoplan_core/db/query_metrics.py
Normal file
100
src/govoplan_core/db/query_metrics.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import time
|
||||||
|
import weakref
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
from sqlalchemy import event
|
||||||
|
from sqlalchemy.engine import Connection, Engine
|
||||||
|
from sqlalchemy.engine.interfaces import ExecutionContext
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class QueryMetrics:
|
||||||
|
query_count: int = 0
|
||||||
|
total_ms: float = 0.0
|
||||||
|
slowest_ms: float = 0.0
|
||||||
|
error_count: int = 0
|
||||||
|
|
||||||
|
def record(self, elapsed_ms: float, *, failed: bool = False) -> None:
|
||||||
|
self.query_count += 1
|
||||||
|
self.total_ms += elapsed_ms
|
||||||
|
self.slowest_ms = max(self.slowest_ms, elapsed_ms)
|
||||||
|
if failed:
|
||||||
|
self.error_count += 1
|
||||||
|
|
||||||
|
|
||||||
|
_current_metrics: ContextVar[QueryMetrics | None] = ContextVar("govoplan_query_metrics", default=None)
|
||||||
|
_instrumented_engines: weakref.WeakSet[Engine] = weakref.WeakSet()
|
||||||
|
_START_STACK_KEY = "govoplan_query_metric_starts"
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def collect_query_metrics() -> Iterator[QueryMetrics]:
|
||||||
|
metrics = QueryMetrics()
|
||||||
|
token = _current_metrics.set(metrics)
|
||||||
|
try:
|
||||||
|
yield metrics
|
||||||
|
finally:
|
||||||
|
_current_metrics.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def current_query_metrics() -> QueryMetrics | None:
|
||||||
|
return _current_metrics.get()
|
||||||
|
|
||||||
|
|
||||||
|
def instrument_engine(engine: Engine) -> Engine:
|
||||||
|
if engine in _instrumented_engines:
|
||||||
|
return engine
|
||||||
|
|
||||||
|
event.listen(engine, "before_cursor_execute", _before_cursor_execute)
|
||||||
|
event.listen(engine, "after_cursor_execute", _after_cursor_execute)
|
||||||
|
event.listen(engine, "handle_error", _handle_error)
|
||||||
|
_instrumented_engines.add(engine)
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
def _before_cursor_execute(
|
||||||
|
conn: Connection,
|
||||||
|
_cursor: object,
|
||||||
|
_statement: str,
|
||||||
|
_parameters: object,
|
||||||
|
_context: ExecutionContext,
|
||||||
|
_executemany: bool,
|
||||||
|
) -> None:
|
||||||
|
if current_query_metrics() is None:
|
||||||
|
return
|
||||||
|
starts = conn.info.setdefault(_START_STACK_KEY, [])
|
||||||
|
starts.append(time.perf_counter())
|
||||||
|
|
||||||
|
|
||||||
|
def _after_cursor_execute(
|
||||||
|
conn: Connection,
|
||||||
|
_cursor: object,
|
||||||
|
_statement: str,
|
||||||
|
_parameters: object,
|
||||||
|
_context: ExecutionContext,
|
||||||
|
_executemany: bool,
|
||||||
|
) -> None:
|
||||||
|
_record_elapsed(conn, failed=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_error(exception_context: object) -> None:
|
||||||
|
conn = getattr(exception_context, "connection", None)
|
||||||
|
if isinstance(conn, Connection):
|
||||||
|
_record_elapsed(conn, failed=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_elapsed(conn: Connection, *, failed: bool) -> None:
|
||||||
|
metrics = current_query_metrics()
|
||||||
|
if metrics is None:
|
||||||
|
return
|
||||||
|
starts = conn.info.get(_START_STACK_KEY)
|
||||||
|
if not starts:
|
||||||
|
return
|
||||||
|
started_at = starts.pop()
|
||||||
|
elapsed_ms = (time.perf_counter() - started_at) * 1000
|
||||||
|
metrics.record(elapsed_ms, failed=failed)
|
||||||
@@ -1,12 +1,23 @@
|
|||||||
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
|
||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from govoplan_core.db.query_metrics import instrument_engine
|
||||||
|
|
||||||
|
|
||||||
|
_POOL_SETTING_BOUNDS: dict[str, tuple[int, int]] = {
|
||||||
|
"GOVOPLAN_DB_POOL_SIZE": (1, 100),
|
||||||
|
"GOVOPLAN_DB_MAX_OVERFLOW": (0, 200),
|
||||||
|
"GOVOPLAN_DB_POOL_TIMEOUT_SECONDS": (1, 300),
|
||||||
|
"GOVOPLAN_DB_POOL_RECYCLE_SECONDS": (0, 86_400),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def default_connect_args(database_url: str) -> dict[str, Any]:
|
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 {}
|
||||||
@@ -22,13 +33,46 @@ 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)
|
||||||
return create_engine(database_url, pool_pre_ping=pool_pre_ping, connect_args=merged_connect_args, **kwargs)
|
if not database_url.startswith("sqlite"):
|
||||||
|
kwargs.setdefault(
|
||||||
|
"pool_size",
|
||||||
|
_pool_setting("GOVOPLAN_DB_POOL_SIZE", 5),
|
||||||
|
)
|
||||||
|
kwargs.setdefault(
|
||||||
|
"max_overflow",
|
||||||
|
_pool_setting("GOVOPLAN_DB_MAX_OVERFLOW", 10),
|
||||||
|
)
|
||||||
|
kwargs.setdefault(
|
||||||
|
"pool_timeout",
|
||||||
|
_pool_setting("GOVOPLAN_DB_POOL_TIMEOUT_SECONDS", 30),
|
||||||
|
)
|
||||||
|
kwargs.setdefault(
|
||||||
|
"pool_recycle",
|
||||||
|
_pool_setting("GOVOPLAN_DB_POOL_RECYCLE_SECONDS", 1800),
|
||||||
|
)
|
||||||
|
return instrument_engine(create_engine(database_url, pool_pre_ping=pool_pre_ping, connect_args=merged_connect_args, **kwargs))
|
||||||
|
|
||||||
|
|
||||||
|
def _pool_setting(name: str, default: int) -> int:
|
||||||
|
raw = os.environ.get(name)
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
value = int(raw)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"{name} must be an integer") from exc
|
||||||
|
minimum, maximum = _POOL_SETTING_BOUNDS[name]
|
||||||
|
if not minimum <= value <= maximum:
|
||||||
|
raise ValueError(
|
||||||
|
f"{name} must be between {minimum} and {maximum}",
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class DatabaseHandle:
|
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
|
||||||
self.engine = engine or create_database_engine(database_url)
|
self.engine = instrument_engine(engine) if engine is not None else create_database_engine(database_url)
|
||||||
self.SessionLocal = sessionmaker(bind=self.engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
self.SessionLocal = sessionmaker(bind=self.engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
||||||
|
|
||||||
def session(self) -> Session:
|
def session(self) -> Session:
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ from collections.abc import Iterable, Sequence
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.engine import make_url
|
||||||
|
from sqlalchemy.exc import ArgumentError
|
||||||
|
|
||||||
from govoplan_core.core.discovery import iter_module_entry_points
|
from govoplan_core.core.discovery import iter_module_entry_points
|
||||||
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 ModuleManifest
|
from govoplan_core.core.modules import ModuleManifest
|
||||||
@@ -34,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:
|
||||||
@@ -112,6 +116,14 @@ def validate_sqlite_database_url(database_url: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def redacted_database_url(database_url: str) -> str:
|
||||||
|
try:
|
||||||
|
return make_url(database_url).render_as_string(hide_password=True)
|
||||||
|
except (ArgumentError, TypeError, ValueError):
|
||||||
|
scheme = database_url.partition(":")[0].strip()
|
||||||
|
return f"{scheme}:<redacted>" if scheme else "<redacted>"
|
||||||
|
|
||||||
|
|
||||||
def _env_truthy(value: str | None) -> bool:
|
def _env_truthy(value: str | None) -> bool:
|
||||||
return value is not None and value.strip().lower() in {"1", "true", "yes", "on"}
|
return value is not None and value.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
@@ -179,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))
|
||||||
@@ -226,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)
|
||||||
@@ -254,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)
|
||||||
@@ -280,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,
|
||||||
@@ -289,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)))
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -297,11 +368,11 @@ def print_devserver_summary(state: DevserverState, *, app: str, no_reload: bool)
|
|||||||
print(f"Config: {state.config_path or DEFAULT_CONFIG}")
|
print(f"Config: {state.config_path or DEFAULT_CONFIG}")
|
||||||
print(f"Runtime root: {state.runtime_root}")
|
print(f"Runtime root: {state.runtime_root}")
|
||||||
if state.database_url:
|
if state.database_url:
|
||||||
print(f"Database: {state.database_url}")
|
print(f"Database: {redacted_database_url(state.database_url)}")
|
||||||
if state.database_url.startswith("postgresql"):
|
if state.database_url.startswith("postgresql"):
|
||||||
pgtools_url = os.getenv("GOVOPLAN_DATABASE_URL_PGTOOLS")
|
pgtools_url = os.getenv("GOVOPLAN_DATABASE_URL_PGTOOLS")
|
||||||
if pgtools_url:
|
if pgtools_url:
|
||||||
print(f"PostgreSQL tools URL: {pgtools_url}")
|
print(f"PostgreSQL tools URL: {redacted_database_url(pgtools_url)}")
|
||||||
if state.bootstrap_db_path is not None:
|
if state.bootstrap_db_path is not None:
|
||||||
bootstrap_state = "enabled" if getattr(state.config.settings, "dev_bootstrap_enabled", False) else "disabled by DEV_BOOTSTRAP_ENABLED"
|
bootstrap_state = "enabled" if getattr(state.config.settings, "dev_bootstrap_enabled", False) else "disabled by DEV_BOOTSTRAP_ENABLED"
|
||||||
print(f"Dev bootstrap for missing SQLite DB: {bootstrap_state} ({state.bootstrap_db_path})")
|
print(f"Dev bootstrap for missing SQLite DB: {bootstrap_state} ({state.bootstrap_db_path})")
|
||||||
@@ -309,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}")
|
||||||
@@ -342,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:
|
||||||
|
|||||||
@@ -67,6 +67,32 @@ class ImapConfig(ImapServerConfig):
|
|||||||
password: str | None = None
|
password: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_split_transport_credentials(value: object) -> object:
|
||||||
|
"""Move legacy transport username/password fields into credentials."""
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return value
|
||||||
|
data = dict(value)
|
||||||
|
credentials = data.get("credentials") if isinstance(data.get("credentials"), dict) else {}
|
||||||
|
credentials = {key: dict(item) for key, item in credentials.items() if isinstance(item, dict)}
|
||||||
|
for protocol in ("smtp", "imap"):
|
||||||
|
transport = data.get(protocol)
|
||||||
|
if not isinstance(transport, dict):
|
||||||
|
continue
|
||||||
|
next_transport = dict(transport)
|
||||||
|
next_credentials = dict(credentials.get(protocol) or {})
|
||||||
|
for field in ("username", "password"):
|
||||||
|
if field in next_transport and field not in next_credentials:
|
||||||
|
next_credentials[field] = next_transport[field]
|
||||||
|
next_transport.pop(field, None)
|
||||||
|
next_transport.pop("enabled", None)
|
||||||
|
data[protocol] = next_transport
|
||||||
|
if next_credentials:
|
||||||
|
credentials[protocol] = next_credentials
|
||||||
|
if credentials:
|
||||||
|
data["credentials"] = credentials
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def default_smtp_port(security: TransportSecurity | str | None) -> int:
|
def default_smtp_port(security: TransportSecurity | str | None) -> int:
|
||||||
if security == TransportSecurity.TLS or security == "tls":
|
if security == TransportSecurity.TLS or security == "tls":
|
||||||
return 465
|
return 465
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
"""Compatibility facade for privacy retention policy.
|
"""Compatibility facade for privacy retention policy.
|
||||||
|
|
||||||
Policy-owned behavior is provided by ``govoplan-policy`` through the
|
Policy-owned behavior is provided by ``govoplan-policy`` through the
|
||||||
@@ -7,27 +5,22 @@ Policy-owned behavior is provided by ``govoplan-policy`` through the
|
|||||||
without importing policy implementation code from core.
|
without importing policy implementation code from core.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Mapping
|
from typing import Any, Mapping
|
||||||
|
|
||||||
from govoplan_core.core.policy import CAPABILITY_POLICY_PRIVACY_RETENTION, PrivacyRetentionService
|
from govoplan_core.core.policy import CAPABILITY_POLICY_PRIVACY_RETENTION, PrivacyRetentionService
|
||||||
from govoplan_core.core.runtime import get_registry
|
from govoplan_core.core.runtime import get_registry
|
||||||
|
from govoplan_core.privacy.schemas import (
|
||||||
|
RETENTION_DAY_KEYS as RETENTION_DAY_KEYS,
|
||||||
|
RETENTION_POLICY_FIELD_KEYS,
|
||||||
|
default_allow_lower_level_limits as default_allow_lower_level_limits,
|
||||||
|
normalize_allow_lower_level_limits,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
PRIVACY_POLICY_SETTINGS_KEY = "privacy_retention_policy"
|
PRIVACY_POLICY_SETTINGS_KEY = "privacy_retention_policy"
|
||||||
RETENTION_DAY_KEYS = (
|
|
||||||
"raw_campaign_json_retention_days",
|
|
||||||
"generated_eml_retention_days",
|
|
||||||
"stored_report_detail_retention_days",
|
|
||||||
"mock_mailbox_retention_days",
|
|
||||||
"audit_detail_retention_days",
|
|
||||||
)
|
|
||||||
RETENTION_POLICY_FIELD_KEYS = (
|
|
||||||
"store_raw_campaign_json",
|
|
||||||
*RETENTION_DAY_KEYS,
|
|
||||||
"audit_detail_level",
|
|
||||||
)
|
|
||||||
|
|
||||||
_DEFAULT_POLICY = {
|
_DEFAULT_POLICY = {
|
||||||
"store_raw_campaign_json": True,
|
"store_raw_campaign_json": True,
|
||||||
"raw_campaign_json_retention_days": None,
|
"raw_campaign_json_retention_days": None,
|
||||||
@@ -122,28 +115,10 @@ def _policy_data(settings_payload: Mapping[str, Any] | None) -> dict[str, Any]:
|
|||||||
for key in RETENTION_POLICY_FIELD_KEYS:
|
for key in RETENTION_POLICY_FIELD_KEYS:
|
||||||
if key in raw:
|
if key in raw:
|
||||||
payload[key] = raw[key]
|
payload[key] = raw[key]
|
||||||
payload["allow_lower_level_limits"] = _normalize_allow_lower_level_limits(raw.get("allow_lower_level_limits"), fill_defaults=True)
|
payload["allow_lower_level_limits"] = normalize_allow_lower_level_limits(raw.get("allow_lower_level_limits"), fill_defaults=True)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
|
|
||||||
if value in (None, ""):
|
|
||||||
return default_allow_lower_level_limits() if fill_defaults else None
|
|
||||||
if not isinstance(value, Mapping):
|
|
||||||
raise ValueError("allow_lower_level_limits must be an object")
|
|
||||||
normalized = default_allow_lower_level_limits() if fill_defaults else {}
|
|
||||||
for key, allowed in value.items():
|
|
||||||
clean_key = str(key)
|
|
||||||
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
|
|
||||||
raise ValueError(f"Unknown retention policy field: {clean_key}")
|
|
||||||
normalized[clean_key] = bool(allowed)
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def default_allow_lower_level_limits() -> dict[str, bool]:
|
|
||||||
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
|
|
||||||
|
|
||||||
|
|
||||||
def privacy_policy_from_settings(item: object) -> Any:
|
def privacy_policy_from_settings(item: object) -> Any:
|
||||||
service = _runtime_service()
|
service = _runtime_service()
|
||||||
if service is not None:
|
if service is not None:
|
||||||
|
|||||||
83
src/govoplan_core/privacy/schemas.py
Normal file
83
src/govoplan_core/privacy/schemas.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
RETENTION_DAY_KEYS = (
|
||||||
|
"raw_campaign_json_retention_days",
|
||||||
|
"generated_eml_retention_days",
|
||||||
|
"stored_report_detail_retention_days",
|
||||||
|
"mock_mailbox_retention_days",
|
||||||
|
"audit_detail_retention_days",
|
||||||
|
)
|
||||||
|
RETENTION_POLICY_FIELD_KEYS = (
|
||||||
|
"store_raw_campaign_json",
|
||||||
|
*RETENTION_DAY_KEYS,
|
||||||
|
"audit_detail_level",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def default_allow_lower_level_limits() -> dict[str, bool]:
|
||||||
|
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
|
||||||
|
if value in (None, ""):
|
||||||
|
return default_allow_lower_level_limits() if fill_defaults else None
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError("allow_lower_level_limits must be an object")
|
||||||
|
normalized = default_allow_lower_level_limits() if fill_defaults else {}
|
||||||
|
for key, allowed in value.items():
|
||||||
|
clean_key = str(key)
|
||||||
|
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
|
||||||
|
raise ValueError(f"Unknown retention policy field: {clean_key}")
|
||||||
|
normalized[clean_key] = bool(allowed)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class PrivacyRetentionPolicyItem(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
store_raw_campaign_json: bool = True
|
||||||
|
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
|
||||||
|
generated_eml_retention_days: int | None = Field(default=None, ge=0)
|
||||||
|
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
|
||||||
|
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
|
||||||
|
audit_detail_retention_days: int | None = Field(default=None, ge=0)
|
||||||
|
audit_detail_level: Literal["full", "redacted", "minimal"] = "full"
|
||||||
|
allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits)
|
||||||
|
|
||||||
|
@field_validator("allow_lower_level_limits", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
|
||||||
|
return normalize_allow_lower_level_limits(value, fill_defaults=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PrivacyRetentionPolicyPatchItem(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
store_raw_campaign_json: bool | None = None
|
||||||
|
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
|
||||||
|
generated_eml_retention_days: int | None = Field(default=None, ge=0)
|
||||||
|
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
|
||||||
|
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
|
||||||
|
audit_detail_retention_days: int | None = Field(default=None, ge=0)
|
||||||
|
audit_detail_level: Literal["full", "redacted", "minimal"] | None = None
|
||||||
|
allow_lower_level_limits: dict[str, bool] | None = None
|
||||||
|
|
||||||
|
@field_validator("allow_lower_level_limits", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
|
||||||
|
return normalize_allow_lower_level_limits(value, fill_defaults=False)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"RETENTION_DAY_KEYS",
|
||||||
|
"RETENTION_POLICY_FIELD_KEYS",
|
||||||
|
"PrivacyRetentionPolicyItem",
|
||||||
|
"PrivacyRetentionPolicyPatchItem",
|
||||||
|
"default_allow_lower_level_limits",
|
||||||
|
"normalize_allow_lower_level_limits",
|
||||||
|
]
|
||||||
563
src/govoplan_core/security/credential_envelopes.py
Normal file
563
src/govoplan_core/security/credential_envelopes.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
105
src/govoplan_core/security/http_fetch.py
Normal file
105
src/govoplan_core/security/http_fetch.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
from govoplan_core.security.outbound_http import (
|
||||||
|
bounded_response_bytes,
|
||||||
|
build_outbound_http_opener,
|
||||||
|
validate_outbound_http_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class HttpFetchResponse:
|
||||||
|
status: int
|
||||||
|
headers: dict[str, str]
|
||||||
|
body: bytes
|
||||||
|
|
||||||
|
def text(self, encoding: str = "utf-8") -> str:
|
||||||
|
return self.body.decode(encoding)
|
||||||
|
|
||||||
|
|
||||||
|
def is_http_url(value: str) -> bool:
|
||||||
|
try:
|
||||||
|
validate_http_url(value)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def validate_http_url(value: str, *, label: str = "URL") -> str:
|
||||||
|
parsed = urllib.parse.urlparse(str(value).strip())
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||||
|
raise ValueError(f"{label} must be an absolute HTTP(S) URL.")
|
||||||
|
if parsed.username or parsed.password:
|
||||||
|
raise ValueError(f"{label} must not include embedded credentials.")
|
||||||
|
return urllib.parse.urlunparse(parsed)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_http(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
timeout: float = 15,
|
||||||
|
label: str = "URL",
|
||||||
|
method: str = "GET",
|
||||||
|
headers: Mapping[str, str] | None = None,
|
||||||
|
max_bytes: int | None = None,
|
||||||
|
) -> HttpFetchResponse:
|
||||||
|
validated_url = validate_outbound_http_url(url, label=label)
|
||||||
|
request = urllib.request.Request( # noqa: S310 - URL is restricted to validated HTTP(S).
|
||||||
|
validated_url,
|
||||||
|
headers=dict(headers or {}),
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
opener = build_outbound_http_opener(_PolicyRedirectHandler(label=label))
|
||||||
|
with opener.open(request, timeout=timeout) as response: # noqa: S310 - URL and every redirect are policy-validated. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
||||||
|
response_headers = dict(response.headers.items())
|
||||||
|
return HttpFetchResponse(
|
||||||
|
status=int(getattr(response, "status", 0)),
|
||||||
|
headers=response_headers,
|
||||||
|
body=bounded_response_bytes(
|
||||||
|
response,
|
||||||
|
headers=response_headers,
|
||||||
|
max_bytes=max_bytes,
|
||||||
|
label=f"{label} response",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_http_text(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
timeout: float = 15,
|
||||||
|
label: str = "URL",
|
||||||
|
method: str = "GET",
|
||||||
|
headers: Mapping[str, str] | None = None,
|
||||||
|
encoding: str = "utf-8",
|
||||||
|
max_bytes: int | None = None,
|
||||||
|
) -> str:
|
||||||
|
return fetch_http(url, timeout=timeout, label=label, method=method, headers=headers, max_bytes=max_bytes).text(encoding)
|
||||||
|
|
||||||
|
|
||||||
|
class _PolicyRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||||
|
def __init__(self, *, label: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._label = label
|
||||||
|
|
||||||
|
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
||||||
|
candidate = validate_outbound_http_url(newurl, label=f"{self._label} redirect")
|
||||||
|
previous = urllib.parse.urlparse(req.full_url)
|
||||||
|
redirected = urllib.parse.urlparse(candidate)
|
||||||
|
if previous.scheme.lower() == "https" and redirected.scheme.lower() != "https":
|
||||||
|
return None
|
||||||
|
new_request = super().redirect_request(req, fp, code, msg, headers, candidate)
|
||||||
|
if new_request is not None and _http_origin(previous) != _http_origin(redirected):
|
||||||
|
for header in ("Authorization", "Proxy-Authorization", "Cookie", "Cookie2"):
|
||||||
|
new_request.remove_header(header)
|
||||||
|
return new_request
|
||||||
|
|
||||||
|
|
||||||
|
def _http_origin(parsed: urllib.parse.ParseResult) -> tuple[str, str, int]:
|
||||||
|
scheme = parsed.scheme.lower()
|
||||||
|
return scheme, (parsed.hostname or "").lower(), parsed.port or (443 if scheme == "https" else 80)
|
||||||
@@ -95,6 +95,11 @@ MODULE_SYSTEM_SCOPES = frozenset(
|
|||||||
for legacy, module in LEGACY_TO_MODULE_SCOPES.items()
|
for legacy, module in LEGACY_TO_MODULE_SCOPES.items()
|
||||||
if legacy.startswith("system:")
|
if legacy.startswith("system:")
|
||||||
)
|
)
|
||||||
|
LEGACY_SYSTEM_SCOPES = frozenset(
|
||||||
|
legacy
|
||||||
|
for legacy in LEGACY_TO_MODULE_SCOPES
|
||||||
|
if legacy.startswith("system:")
|
||||||
|
)
|
||||||
MODULE_TENANT_SCOPES = frozenset(LEGACY_TO_MODULE_SCOPES.values()) - MODULE_SYSTEM_SCOPES
|
MODULE_TENANT_SCOPES = frozenset(LEGACY_TO_MODULE_SCOPES.values()) - MODULE_SYSTEM_SCOPES
|
||||||
|
|
||||||
|
|
||||||
@@ -110,7 +115,7 @@ def compatible_required_scopes(required: str) -> tuple[str, ...]:
|
|||||||
|
|
||||||
def scopes_grant_compatible(scopes: Iterable[str], required: str) -> bool:
|
def scopes_grant_compatible(scopes: Iterable[str], required: str) -> bool:
|
||||||
granted = list(scopes)
|
granted = list(scopes)
|
||||||
if required in MODULE_SYSTEM_SCOPES:
|
if required in MODULE_SYSTEM_SCOPES or required in LEGACY_SYSTEM_SCOPES:
|
||||||
return "*" in granted or "system:*" in granted or any(
|
return "*" in granted or "system:*" in granted or any(
|
||||||
scope != "tenant:*" and scopes_grant([scope], alias)
|
scope != "tenant:*" and scopes_grant([scope], alias)
|
||||||
for scope in granted
|
for scope in granted
|
||||||
|
|||||||
408
src/govoplan_core/security/outbound_http.py
Normal file
408
src/govoplan_core/security/outbound_http.py
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import errno
|
||||||
|
import http.client
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import BinaryIO, Final
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_STRUCTURED_RESPONSE_BYTES: Final = 16 * 1024 * 1024
|
||||||
|
DEFAULT_FILE_TRANSFER_BYTES: Final = 512 * 1024 * 1024
|
||||||
|
_READ_CHUNK_BYTES: Final = 64 * 1024
|
||||||
|
_TRUE_VALUES: Final = frozenset({"1", "true", "yes", "on"})
|
||||||
|
_FALSE_VALUES: Final = frozenset({"0", "false", "no", "off"})
|
||||||
|
_IPV4_LIMITED_BROADCAST: Final = ipaddress.IPv4Address("255.255.255.255")
|
||||||
|
_IPV4_COMPATIBLE_NETWORK: Final = ipaddress.IPv6Network("::/96")
|
||||||
|
_NAT64_WELL_KNOWN_NETWORK: Final = ipaddress.IPv6Network("64:ff9b::/96")
|
||||||
|
_KNOWN_METADATA_ADDRESSES: Final = frozenset(
|
||||||
|
{
|
||||||
|
ipaddress.ip_address("100.100.100.200"),
|
||||||
|
ipaddress.ip_address("169.254.169.254"),
|
||||||
|
ipaddress.ip_address("fd00:ec2::254"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OutboundHttpError(RuntimeError):
|
||||||
|
"""Base error for deployment-wide outbound HTTP policy failures."""
|
||||||
|
|
||||||
|
|
||||||
|
class OutboundHttpBlocked(OutboundHttpError):
|
||||||
|
"""Raised when a URL targets an address forbidden by deployment policy."""
|
||||||
|
|
||||||
|
|
||||||
|
class OutboundResponseTooLarge(OutboundHttpError):
|
||||||
|
"""Raised before a connector can retain an oversized remote response."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OutboundHttpPolicy:
|
||||||
|
allow_private_networks: bool
|
||||||
|
structured_response_bytes: int
|
||||||
|
file_transfer_bytes: int
|
||||||
|
|
||||||
|
|
||||||
|
def outbound_http_policy(environ: Mapping[str, str] | None = None) -> OutboundHttpPolicy:
|
||||||
|
env = os.environ if environ is None else environ
|
||||||
|
return OutboundHttpPolicy(
|
||||||
|
allow_private_networks=_private_network_default(env),
|
||||||
|
structured_response_bytes=_positive_int(
|
||||||
|
env.get("GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES"),
|
||||||
|
default=DEFAULT_STRUCTURED_RESPONSE_BYTES,
|
||||||
|
name="GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
|
||||||
|
),
|
||||||
|
file_transfer_bytes=_positive_int(
|
||||||
|
env.get("GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES"),
|
||||||
|
default=DEFAULT_FILE_TRANSFER_BYTES,
|
||||||
|
name="GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_outbound_http_url(
|
||||||
|
value: str,
|
||||||
|
*,
|
||||||
|
label: str = "Connector URL",
|
||||||
|
policy: OutboundHttpPolicy | None = None,
|
||||||
|
) -> str:
|
||||||
|
parsed = urllib.parse.urlparse(str(value).strip())
|
||||||
|
if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc or not parsed.hostname:
|
||||||
|
raise OutboundHttpBlocked(f"{label} must be an absolute HTTP(S) URL")
|
||||||
|
if parsed.username or parsed.password:
|
||||||
|
raise OutboundHttpBlocked(f"{label} must not include embedded credentials")
|
||||||
|
try:
|
||||||
|
port = parsed.port or (443 if parsed.scheme.lower() == "https" else 80)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise OutboundHttpBlocked(f"{label} has an invalid port") from exc
|
||||||
|
validate_outbound_host(parsed.hostname, port=port, label=label, policy=policy)
|
||||||
|
return urllib.parse.urlunparse(parsed)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_unpinned_sdk_http_url(
|
||||||
|
value: str,
|
||||||
|
*,
|
||||||
|
label: str,
|
||||||
|
policy: OutboundHttpPolicy | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Fail closed when an SDK cannot connect to a prevalidated DNS answer."""
|
||||||
|
|
||||||
|
active_policy = policy or outbound_http_policy()
|
||||||
|
validate_outbound_http_url(value, label=label, policy=active_policy)
|
||||||
|
_raise_unpinned_transport(label)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_unpinned_sdk_host(
|
||||||
|
hostname: str,
|
||||||
|
*,
|
||||||
|
port: int,
|
||||||
|
label: str,
|
||||||
|
policy: OutboundHttpPolicy | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Validate a host and then reject an SDK that may select another peer."""
|
||||||
|
|
||||||
|
active_policy = policy or outbound_http_policy()
|
||||||
|
validate_outbound_host(hostname, port=port, label=label, policy=active_policy)
|
||||||
|
_raise_unpinned_transport(label)
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_unpinned_transport(label: str) -> None:
|
||||||
|
raise OutboundHttpBlocked(
|
||||||
|
f"{label} uses an SDK that cannot pin every connection peer or revalidate SDK-managed redirects/referrals; "
|
||||||
|
"it is disabled until that transport supports connection-time DNS/IP pinning"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_outbound_host(
|
||||||
|
hostname: str,
|
||||||
|
*,
|
||||||
|
port: int,
|
||||||
|
label: str = "Connector host",
|
||||||
|
policy: OutboundHttpPolicy | None = None,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
active_policy = policy or outbound_http_policy()
|
||||||
|
records = _resolved_address_records(hostname, port=port, label=label, policy=active_policy)
|
||||||
|
addresses = tuple(dict.fromkeys(str(item[4][0]).split("%", 1)[0] for item in records if item[4]))
|
||||||
|
return addresses
|
||||||
|
|
||||||
|
|
||||||
|
def create_outbound_connection(
|
||||||
|
hostname: str,
|
||||||
|
port: int,
|
||||||
|
timeout: float | object | None = None,
|
||||||
|
source_address: tuple[str, int] | None = None,
|
||||||
|
socket_options: Iterable[tuple[object, ...]] | None = None,
|
||||||
|
*,
|
||||||
|
label: str = "Connector host",
|
||||||
|
policy: OutboundHttpPolicy | None = None,
|
||||||
|
) -> socket.socket:
|
||||||
|
"""Resolve, validate, and connect to the exact approved address records.
|
||||||
|
|
||||||
|
Hostname resolution happens exactly once for this connection attempt. The
|
||||||
|
returned socket connects directly to one of those validated sockaddr
|
||||||
|
records, while higher protocol layers retain the original hostname for
|
||||||
|
HTTP Host, TLS SNI, and certificate verification.
|
||||||
|
"""
|
||||||
|
|
||||||
|
active_policy = policy or outbound_http_policy()
|
||||||
|
records = _resolved_address_records(hostname, port=port, label=label, policy=active_policy)
|
||||||
|
effective_timeout = None if timeout is socket._GLOBAL_DEFAULT_TIMEOUT else timeout # type: ignore[attr-defined]
|
||||||
|
last_error: OSError | None = None
|
||||||
|
for family, socktype, proto, _canonname, sockaddr in records:
|
||||||
|
sock: socket.socket | None = None
|
||||||
|
try:
|
||||||
|
sock = socket.socket(family, socktype, proto)
|
||||||
|
sock.settimeout(effective_timeout) # type: ignore[arg-type]
|
||||||
|
if source_address is not None:
|
||||||
|
bind_address: tuple[object, ...] = source_address
|
||||||
|
if family == socket.AF_INET6 and len(source_address) == 2:
|
||||||
|
bind_address = (source_address[0], source_address[1], 0, 0)
|
||||||
|
sock.bind(bind_address)
|
||||||
|
for option in socket_options or ():
|
||||||
|
sock.setsockopt(*option)
|
||||||
|
try:
|
||||||
|
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno != errno.ENOPROTOOPT:
|
||||||
|
raise
|
||||||
|
sock.connect(sockaddr)
|
||||||
|
return sock
|
||||||
|
except OSError as exc:
|
||||||
|
last_error = exc
|
||||||
|
if sock is not None:
|
||||||
|
sock.close()
|
||||||
|
if last_error is not None:
|
||||||
|
raise last_error
|
||||||
|
raise OutboundHttpBlocked(f"{label} hostname did not resolve to a usable address")
|
||||||
|
|
||||||
|
|
||||||
|
def build_outbound_http_opener(*handlers: urllib.request.BaseHandler) -> urllib.request.OpenerDirector:
|
||||||
|
"""Build a proxy-free urllib opener whose sockets use approved addresses."""
|
||||||
|
|
||||||
|
return urllib.request.build_opener(
|
||||||
|
urllib.request.ProxyHandler({}),
|
||||||
|
_OutboundHTTPHandler(),
|
||||||
|
_OutboundHTTPSHandler(),
|
||||||
|
*handlers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def response_limit(kind: str, *, policy: OutboundHttpPolicy | None = None) -> int:
|
||||||
|
active_policy = policy or outbound_http_policy()
|
||||||
|
if kind == "structured":
|
||||||
|
return active_policy.structured_response_bytes
|
||||||
|
if kind == "file":
|
||||||
|
return active_policy.file_transfer_bytes
|
||||||
|
raise ValueError("Response kind must be 'structured' or 'file'")
|
||||||
|
|
||||||
|
|
||||||
|
def bounded_response_bytes(
|
||||||
|
stream: BinaryIO,
|
||||||
|
*,
|
||||||
|
headers: Mapping[str, str] | None = None,
|
||||||
|
max_bytes: int | None = None,
|
||||||
|
kind: str = "structured",
|
||||||
|
label: str = "Connector response",
|
||||||
|
) -> bytes:
|
||||||
|
configured_limit = response_limit(kind)
|
||||||
|
limit = configured_limit if max_bytes is None else min(int(max_bytes), configured_limit)
|
||||||
|
if limit <= 0:
|
||||||
|
raise ValueError("max_bytes must be positive")
|
||||||
|
declared_size = _content_length(headers or {})
|
||||||
|
if declared_size is not None and declared_size > limit:
|
||||||
|
raise OutboundResponseTooLarge(f"{label} exceeds the configured limit of {limit} bytes")
|
||||||
|
body = bytearray()
|
||||||
|
while len(body) <= limit:
|
||||||
|
chunk = stream.read(min(_READ_CHUNK_BYTES, limit + 1 - len(body)))
|
||||||
|
if not chunk:
|
||||||
|
return bytes(body)
|
||||||
|
body.extend(chunk)
|
||||||
|
raise OutboundResponseTooLarge(f"{label} exceeds the configured limit of {limit} bytes")
|
||||||
|
|
||||||
|
|
||||||
|
def bounded_chunks_bytes(
|
||||||
|
chunks: Iterable[bytes],
|
||||||
|
*,
|
||||||
|
headers: Mapping[str, str] | None = None,
|
||||||
|
max_bytes: int | None = None,
|
||||||
|
kind: str = "structured",
|
||||||
|
label: str = "Connector response",
|
||||||
|
) -> bytes:
|
||||||
|
configured_limit = response_limit(kind)
|
||||||
|
limit = configured_limit if max_bytes is None else min(int(max_bytes), configured_limit)
|
||||||
|
if limit <= 0:
|
||||||
|
raise ValueError("max_bytes must be positive")
|
||||||
|
declared_size = _content_length(headers or {})
|
||||||
|
if declared_size is not None and declared_size > limit:
|
||||||
|
raise OutboundResponseTooLarge(f"{label} exceeds the configured limit of {limit} bytes")
|
||||||
|
body = bytearray()
|
||||||
|
for chunk in chunks:
|
||||||
|
if len(chunk) > limit - len(body):
|
||||||
|
raise OutboundResponseTooLarge(f"{label} exceeds the configured limit of {limit} bytes")
|
||||||
|
body.extend(chunk)
|
||||||
|
return bytes(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _private_network_default(environ: Mapping[str, str]) -> bool:
|
||||||
|
configured = environ.get("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS")
|
||||||
|
if configured is not None and configured.strip():
|
||||||
|
value = configured.strip().lower()
|
||||||
|
if value in _TRUE_VALUES:
|
||||||
|
return True
|
||||||
|
if value in _FALSE_VALUES:
|
||||||
|
return False
|
||||||
|
raise ValueError("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS must be true or false")
|
||||||
|
return environ.get("APP_ENV", "dev").strip().lower() in {"dev", "development", "test"}
|
||||||
|
|
||||||
|
|
||||||
|
def _positive_int(value: str | None, *, default: int, name: str) -> int:
|
||||||
|
if value is None or not value.strip():
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"{name} must be a positive integer") from exc
|
||||||
|
if parsed <= 0:
|
||||||
|
raise ValueError(f"{name} must be a positive integer")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _content_length(headers: Mapping[str, str]) -> int | None:
|
||||||
|
value = next((item for key, item in headers.items() if key.casefold() == "content-length"), None)
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return parsed if parsed >= 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_public_address(value: str) -> bool:
|
||||||
|
try:
|
||||||
|
address = ipaddress.ip_address(value)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if address.is_reserved or getattr(address, "is_site_local", False) or not address.is_global:
|
||||||
|
return False
|
||||||
|
return all(
|
||||||
|
embedded.is_global and not embedded.is_reserved
|
||||||
|
for embedded in _embedded_ipv4_addresses(address)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_forbidden_special_address(value: str) -> bool:
|
||||||
|
"""Keep connector access away from host-local and non-unicast address space.
|
||||||
|
|
||||||
|
Enabling private-network connectors deliberately permits internal and
|
||||||
|
loopback destinations, but it must not expose link-local metadata services
|
||||||
|
or addresses that cannot identify a single remote peer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
address = ipaddress.ip_address(value)
|
||||||
|
except ValueError:
|
||||||
|
return True
|
||||||
|
candidates = (address, *_embedded_ipv4_addresses(address))
|
||||||
|
return any(
|
||||||
|
candidate in _KNOWN_METADATA_ADDRESSES
|
||||||
|
or candidate == _IPV4_LIMITED_BROADCAST
|
||||||
|
or candidate.is_link_local
|
||||||
|
or candidate.is_multicast
|
||||||
|
or candidate.is_unspecified
|
||||||
|
for candidate in candidates
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _embedded_ipv4_addresses(
|
||||||
|
address: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
||||||
|
) -> tuple[ipaddress.IPv4Address, ...]:
|
||||||
|
"""Return IPv4 destinations encoded by standard IPv6 transition forms."""
|
||||||
|
|
||||||
|
if isinstance(address, ipaddress.IPv4Address):
|
||||||
|
return ()
|
||||||
|
candidates: list[ipaddress.IPv4Address] = []
|
||||||
|
if address.ipv4_mapped is not None:
|
||||||
|
candidates.append(address.ipv4_mapped)
|
||||||
|
elif address in _IPV4_COMPATIBLE_NETWORK:
|
||||||
|
candidates.append(ipaddress.IPv4Address(int(address) & 0xFFFFFFFF))
|
||||||
|
if address in _NAT64_WELL_KNOWN_NETWORK:
|
||||||
|
candidates.append(ipaddress.IPv4Address(int(address) & 0xFFFFFFFF))
|
||||||
|
if address.sixtofour is not None:
|
||||||
|
candidates.append(address.sixtofour)
|
||||||
|
if address.teredo is not None:
|
||||||
|
candidates.extend(address.teredo)
|
||||||
|
return tuple(dict.fromkeys(candidates))
|
||||||
|
|
||||||
|
|
||||||
|
def _resolved_address_records(
|
||||||
|
hostname: str,
|
||||||
|
*,
|
||||||
|
port: int,
|
||||||
|
label: str,
|
||||||
|
policy: OutboundHttpPolicy,
|
||||||
|
) -> tuple[tuple[int, int, int, str, tuple[object, ...]], ...]:
|
||||||
|
host = str(hostname).strip().rstrip(".")
|
||||||
|
if not host:
|
||||||
|
raise OutboundHttpBlocked(f"{label} must include a hostname")
|
||||||
|
if not 1 <= int(port) <= 65535:
|
||||||
|
raise OutboundHttpBlocked(f"{label} has an invalid port")
|
||||||
|
try:
|
||||||
|
results = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
||||||
|
except socket.gaierror as exc:
|
||||||
|
raise OutboundHttpBlocked(f"{label} hostname could not be resolved") from exc
|
||||||
|
records = tuple(results)
|
||||||
|
if not records:
|
||||||
|
raise OutboundHttpBlocked(f"{label} hostname did not resolve to an address")
|
||||||
|
addresses = tuple(str(item[4][0]).split("%", 1)[0] for item in records if item[4])
|
||||||
|
if not addresses:
|
||||||
|
raise OutboundHttpBlocked(f"{label} hostname did not resolve to an address")
|
||||||
|
if any(_is_forbidden_special_address(address) for address in addresses):
|
||||||
|
raise OutboundHttpBlocked(f"{label} resolves to a forbidden special-purpose network")
|
||||||
|
if not policy.allow_private_networks and any(not _is_public_address(address) for address in addresses):
|
||||||
|
raise OutboundHttpBlocked(
|
||||||
|
f"{label} resolves to a non-public network; "
|
||||||
|
"set GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true for this deployment to permit it"
|
||||||
|
)
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def _create_outbound_connection_compat(
|
||||||
|
address: tuple[str, int],
|
||||||
|
timeout: float | object | None = socket._GLOBAL_DEFAULT_TIMEOUT, # type: ignore[attr-defined]
|
||||||
|
source_address: tuple[str, int] | None = None,
|
||||||
|
) -> socket.socket:
|
||||||
|
return create_outbound_connection(
|
||||||
|
address[0],
|
||||||
|
address[1],
|
||||||
|
timeout=timeout,
|
||||||
|
source_address=source_address,
|
||||||
|
label="Outbound HTTP connection",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _OutboundHTTPConnection(http.client.HTTPConnection):
|
||||||
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||||
|
super().__init__(*args, **kwargs) # type: ignore[arg-type]
|
||||||
|
self._create_connection = _create_outbound_connection_compat
|
||||||
|
|
||||||
|
|
||||||
|
class _OutboundHTTPSConnection(http.client.HTTPSConnection):
|
||||||
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||||
|
super().__init__(*args, **kwargs) # type: ignore[arg-type]
|
||||||
|
self._create_connection = _create_outbound_connection_compat
|
||||||
|
|
||||||
|
|
||||||
|
class _OutboundHTTPHandler(urllib.request.HTTPHandler):
|
||||||
|
def http_open(self, req): # type: ignore[no-untyped-def]
|
||||||
|
return self.do_open(_OutboundHTTPConnection, req)
|
||||||
|
|
||||||
|
|
||||||
|
class _OutboundHTTPSHandler(urllib.request.HTTPSHandler):
|
||||||
|
def https_open(self, req): # type: ignore[no-untyped-def]
|
||||||
|
return self.do_open(_OutboundHTTPSConnection, req, context=self._context)
|
||||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
|
from govoplan_core.security.scope_aliases import LEGACY_SCOPE_ALIASES
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class PermissionDefinition:
|
class PermissionDefinition:
|
||||||
@@ -96,28 +98,6 @@ KNOWN_SCOPES = frozenset(item.scope for item in ALL_PERMISSIONS)
|
|||||||
TENANT_SCOPES = frozenset(item.scope for item in TENANT_PERMISSIONS)
|
TENANT_SCOPES = frozenset(item.scope for item in TENANT_PERMISSIONS)
|
||||||
SYSTEM_SCOPES = frozenset(item.scope for item in SYSTEM_PERMISSIONS)
|
SYSTEM_SCOPES = frozenset(item.scope for item in SYSTEM_PERMISSIONS)
|
||||||
|
|
||||||
LEGACY_SCOPE_ALIASES: dict[str, frozenset[str]] = {
|
|
||||||
# Only names that are no longer canonical remain runtime aliases. Canonical
|
|
||||||
# permissions keep their narrow meaning; the Alembic migration expands old
|
|
||||||
# role records once so upgraded installations do not lose prior access.
|
|
||||||
"campaign:write": frozenset({
|
|
||||||
"campaign:create", "campaign:update", "campaign:copy", "campaign:archive", "campaign:delete", "campaign:share",
|
|
||||||
"recipients:read", "recipients:write", "recipients:import",
|
|
||||||
}),
|
|
||||||
"attachments:read": frozenset({"files:read", "files:download"}),
|
|
||||||
"attachments:write": frozenset({"files:upload", "files:organize", "files:share", "files:delete"}),
|
|
||||||
"admin:users": frozenset({
|
|
||||||
"admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend",
|
|
||||||
"admin:groups:read", "admin:groups:write", "admin:groups:manage_members",
|
|
||||||
"admin:roles:read", "admin:roles:write", "admin:roles:assign",
|
|
||||||
}),
|
|
||||||
"admin:users:write": frozenset({"admin:users:create", "admin:users:update", "admin:users:suspend", "admin:roles:assign", "admin:groups:manage_members"}),
|
|
||||||
"admin:api_keys:write": frozenset({"admin:api_keys:create", "admin:api_keys:revoke"}),
|
|
||||||
"admin:settings": frozenset({"admin:settings:read", "admin:settings:write", "admin:api_keys:read", "admin:api_keys:create", "admin:api_keys:revoke"}),
|
|
||||||
"system:tenants:write": frozenset({"system:tenants:create", "system:tenants:update", "system:tenants:suspend"}),
|
|
||||||
"system:access:write": frozenset({"system:access:assign", "system:roles:assign", "system:accounts:create", "system:accounts:update", "system:accounts:suspend"}),
|
|
||||||
}
|
|
||||||
|
|
||||||
DEFAULT_TENANT_ROLES: dict[str, dict[str, object]] = {
|
DEFAULT_TENANT_ROLES: dict[str, dict[str, object]] = {
|
||||||
"owner": {"name": "Owner", "description": "Full tenant access, including administration and delivery.", "permissions": ["tenant:*"], "is_builtin": True, "is_assignable": True},
|
"owner": {"name": "Owner", "description": "Full tenant access, including administration and delivery.", "permissions": ["tenant:*"], "is_builtin": True, "is_assignable": True},
|
||||||
"tenant_admin": {"name": "Tenant administrator", "description": "Manage tenant settings, users, groups and roles. Real delivery remains separately delegable.", "permissions": [
|
"tenant_admin": {"name": "Tenant administrator", "description": "Manage tenant settings, users, groups and roles. Real delivery remains separately delegable.", "permissions": [
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
def sensitive_key_tokens(key: object) -> set[str]:
|
def sensitive_key_tokens(key: object) -> set[str]:
|
||||||
value = str(key).strip()
|
value = str(key).strip()
|
||||||
@@ -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": ""}):
|
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
|
||||||
|
|||||||
55
src/govoplan_core/security/scope_aliases.py
Normal file
55
src/govoplan_core/security/scope_aliases.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
LEGACY_SCOPE_ALIASES: dict[str, frozenset[str]] = {
|
||||||
|
# Only names that are no longer canonical remain runtime aliases. Canonical
|
||||||
|
# permissions keep their narrow meaning; migrations expand old role records
|
||||||
|
# once so upgraded installations do not lose prior access.
|
||||||
|
"campaign:write": frozenset({
|
||||||
|
"campaign:create",
|
||||||
|
"campaign:update",
|
||||||
|
"campaign:copy",
|
||||||
|
"campaign:archive",
|
||||||
|
"campaign:delete",
|
||||||
|
"campaign:share",
|
||||||
|
"recipients:read",
|
||||||
|
"recipients:write",
|
||||||
|
"recipients:import",
|
||||||
|
}),
|
||||||
|
"attachments:read": frozenset({"files:read", "files:download"}),
|
||||||
|
"attachments:write": frozenset({"files:upload", "files:organize", "files:share", "files:delete"}),
|
||||||
|
"admin:users": frozenset({
|
||||||
|
"admin:users:read",
|
||||||
|
"admin:users:create",
|
||||||
|
"admin:users:update",
|
||||||
|
"admin:users:suspend",
|
||||||
|
"admin:groups:read",
|
||||||
|
"admin:groups:write",
|
||||||
|
"admin:groups:manage_members",
|
||||||
|
"admin:roles:read",
|
||||||
|
"admin:roles:write",
|
||||||
|
"admin:roles:assign",
|
||||||
|
}),
|
||||||
|
"admin:users:write": frozenset({
|
||||||
|
"admin:users:create",
|
||||||
|
"admin:users:update",
|
||||||
|
"admin:users:suspend",
|
||||||
|
"admin:roles:assign",
|
||||||
|
"admin:groups:manage_members",
|
||||||
|
}),
|
||||||
|
"admin:api_keys:write": frozenset({"admin:api_keys:create", "admin:api_keys:revoke"}),
|
||||||
|
"admin:settings": frozenset({
|
||||||
|
"admin:settings:read",
|
||||||
|
"admin:settings:write",
|
||||||
|
"admin:api_keys:read",
|
||||||
|
"admin:api_keys:create",
|
||||||
|
"admin:api_keys:revoke",
|
||||||
|
}),
|
||||||
|
"system:tenants:write": frozenset({"system:tenants:create", "system:tenants:update", "system:tenants:suspend"}),
|
||||||
|
"system:access:write": frozenset({
|
||||||
|
"system:access:assign",
|
||||||
|
"system:roles:assign",
|
||||||
|
"system:accounts:create",
|
||||||
|
"system:accounts:update",
|
||||||
|
"system:accounts:suspend",
|
||||||
|
}),
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ class SecretDecryptionError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider"
|
CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider" # noqa: S105 # nosec B105 - capability identifier.
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
@@ -38,8 +38,8 @@ def _normalize_fernet_key(value: str) -> bytes:
|
|||||||
try:
|
try:
|
||||||
Fernet(candidate)
|
Fernet(candidate)
|
||||||
return candidate
|
return candidate
|
||||||
except Exception:
|
except (TypeError, ValueError):
|
||||||
pass
|
raw = None
|
||||||
try:
|
try:
|
||||||
raw = base64.b64decode(candidate)
|
raw = base64.b64decode(candidate)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -8,57 +8,20 @@ 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
|
||||||
|
|
||||||
|
|
||||||
def create_app(config: GovoplanServerConfig | str | None = None):
|
def create_app(config: GovoplanServerConfig | str | None = None):
|
||||||
if isinstance(config, str) or config is None:
|
server_config = _server_config(config)
|
||||||
server_config = load_server_config(config)
|
_configure_app_database(server_config)
|
||||||
else:
|
registry, available_modules = _server_module_registry(server_config)
|
||||||
server_config = config
|
api_router = _server_api_router(server_config, registry)
|
||||||
|
lifecycle = _server_lifecycle(server_config, registry=registry, available_modules=available_modules)
|
||||||
database_url = getattr(server_config.settings, "database_url", None) if server_config.settings is not None else None
|
|
||||||
if database_url:
|
|
||||||
dispose_previous = getattr(server_config.settings, "app_env", None) == "test"
|
|
||||||
configure_database(str(database_url), dispose_previous=dispose_previous)
|
|
||||||
|
|
||||||
raw_enabled_modules = load_startup_enabled_modules(server_config.enabled_modules)
|
|
||||||
candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
|
|
||||||
startup_available_modules = available_module_manifests(
|
|
||||||
server_config.manifest_factories,
|
|
||||||
enabled_modules=candidate_modules,
|
|
||||||
ignore_load_errors=True,
|
|
||||||
)
|
|
||||||
available_modules = available_module_manifests(server_config.manifest_factories, ignore_load_errors=True)
|
|
||||||
enabled_modules = load_startup_enabled_modules(server_config.enabled_modules, available=startup_available_modules)
|
|
||||||
registry = build_platform_registry(enabled_modules, manifest_factories=server_config.manifest_factories)
|
|
||||||
api_router = APIRouter(prefix=server_config.api_prefix)
|
|
||||||
|
|
||||||
for router in server_config.base_routers:
|
|
||||||
api_router.include_router(router)
|
|
||||||
|
|
||||||
lifecycle = ModuleLifecycleManager(
|
|
||||||
registry=registry,
|
|
||||||
available_modules=available_modules,
|
|
||||||
settings=server_config.settings,
|
|
||||||
api_prefix=server_config.api_prefix,
|
|
||||||
manifest_factories=tuple(server_config.manifest_factories),
|
|
||||||
module_context_data=server_config.module_context_data,
|
|
||||||
)
|
|
||||||
lifecycle.configure_runtime()
|
lifecycle.configure_runtime()
|
||||||
|
|
||||||
api_router.include_router(create_platform_router(settings=server_config.settings))
|
|
||||||
|
|
||||||
for router in server_config.post_module_routers:
|
|
||||||
api_router.include_router(router)
|
|
||||||
|
|
||||||
for contribution in server_config.extra_routers:
|
|
||||||
if contribution.should_include(server_config.settings, registry):
|
|
||||||
api_router.include_router(contribution.router)
|
|
||||||
|
|
||||||
validate_no_route_collisions(api_router, owner="server startup routes")
|
|
||||||
|
|
||||||
app = create_govoplan_app(
|
app = create_govoplan_app(
|
||||||
title=server_config.title,
|
title=server_config.title,
|
||||||
version=server_config.version,
|
version=server_config.version,
|
||||||
@@ -74,4 +37,63 @@ def create_app(config: GovoplanServerConfig | str | None = None):
|
|||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def _server_config(config: GovoplanServerConfig | str | None) -> GovoplanServerConfig:
|
||||||
|
if isinstance(config, str) or config is None:
|
||||||
|
return load_server_config(config)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_app_database(server_config: GovoplanServerConfig) -> None:
|
||||||
|
database_url = getattr(server_config.settings, "database_url", None) if server_config.settings is not None else None
|
||||||
|
if database_url:
|
||||||
|
dispose_previous = getattr(server_config.settings, "app_env", None) == "test"
|
||||||
|
configure_database(str(database_url), dispose_previous=dispose_previous)
|
||||||
|
|
||||||
|
|
||||||
|
def _server_module_registry(server_config: GovoplanServerConfig):
|
||||||
|
raw_enabled_modules = load_startup_enabled_modules(server_config.enabled_modules)
|
||||||
|
candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
|
||||||
|
startup_available_modules = available_module_manifests(
|
||||||
|
server_config.manifest_factories,
|
||||||
|
enabled_modules=candidate_modules,
|
||||||
|
ignore_load_errors=True,
|
||||||
|
)
|
||||||
|
available_modules = available_module_manifests(server_config.manifest_factories, ignore_load_errors=True)
|
||||||
|
enabled_modules = load_startup_enabled_modules(server_config.enabled_modules, available=startup_available_modules)
|
||||||
|
registry = build_platform_registry(enabled_modules, manifest_factories=server_config.manifest_factories)
|
||||||
|
return registry, available_modules
|
||||||
|
|
||||||
|
|
||||||
|
def _server_api_router(server_config: GovoplanServerConfig, registry) -> APIRouter:
|
||||||
|
api_router = APIRouter(prefix=server_config.api_prefix)
|
||||||
|
for router in server_config.base_routers:
|
||||||
|
api_router.include_router(router)
|
||||||
|
api_router.include_router(create_platform_router(settings=server_config.settings))
|
||||||
|
api_router.include_router(credential_router)
|
||||||
|
api_router.include_router(ownership_router)
|
||||||
|
for router in server_config.post_module_routers:
|
||||||
|
api_router.include_router(router)
|
||||||
|
for contribution in server_config.extra_routers:
|
||||||
|
if contribution.should_include(server_config.settings, registry):
|
||||||
|
api_router.include_router(contribution.router)
|
||||||
|
validate_no_route_collisions(api_router, owner="server startup routes")
|
||||||
|
return api_router
|
||||||
|
|
||||||
|
|
||||||
|
def _server_lifecycle(
|
||||||
|
server_config: GovoplanServerConfig,
|
||||||
|
*,
|
||||||
|
registry,
|
||||||
|
available_modules,
|
||||||
|
) -> ModuleLifecycleManager:
|
||||||
|
return ModuleLifecycleManager(
|
||||||
|
registry=registry,
|
||||||
|
available_modules=available_modules,
|
||||||
|
settings=server_config.settings,
|
||||||
|
api_prefix=server_config.api_prefix,
|
||||||
|
manifest_factories=tuple(server_config.manifest_factories),
|
||||||
|
module_context_data=server_config.module_context_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
@@ -15,6 +16,10 @@ from govoplan_core.server.fastapi import LifespanFactory
|
|||||||
ManifestFactory = Callable[[], ModuleManifest]
|
ManifestFactory = Callable[[], ModuleManifest]
|
||||||
RouterEnabled = Callable[[object | None, PlatformRegistry], bool]
|
RouterEnabled = Callable[[object | None, PlatformRegistry], bool]
|
||||||
AppConfigurator = Callable[[FastAPI, PlatformRegistry, object | None], None]
|
AppConfigurator = Callable[[FastAPI, PlatformRegistry, object | None], None]
|
||||||
|
DEFAULT_TRUSTED_IMPORT_PREFIXES = ("govoplan_core", "govoplan_", "app")
|
||||||
|
TRUSTED_IMPORT_PREFIXES_ENV = "GOVOPLAN_TRUSTED_IMPORT_PREFIXES"
|
||||||
|
_MODULE_PATH_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$")
|
||||||
|
_ATTRIBUTE_PATH_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)*$")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -49,16 +54,52 @@ class GovoplanServerConfig:
|
|||||||
|
|
||||||
|
|
||||||
def import_object(path: str) -> Any:
|
def import_object(path: str) -> Any:
|
||||||
module_name, separator, attribute = path.partition(":")
|
module_name, attribute = validate_object_path(path)
|
||||||
if not separator:
|
_validate_trusted_import_prefix(module_name, trusted_import_prefixes())
|
||||||
raise ValueError(f"Object path must use module:attribute syntax: {path!r}")
|
module = import_module(module_name) # nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import
|
||||||
module = import_module(module_name)
|
|
||||||
value: Any = module
|
value: Any = module
|
||||||
for part in attribute.split("."):
|
for part in attribute.split("."):
|
||||||
value = getattr(value, part)
|
value = getattr(value, part)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def validate_object_path(path: str) -> tuple[str, str]:
|
||||||
|
module_name, separator, attribute = path.strip().partition(":")
|
||||||
|
if not separator:
|
||||||
|
raise ValueError(f"Object path must use module:attribute syntax: {path!r}")
|
||||||
|
if not _MODULE_PATH_RE.fullmatch(module_name):
|
||||||
|
raise ValueError(f"Object path module is not a valid Python module path: {path!r}")
|
||||||
|
if not _ATTRIBUTE_PATH_RE.fullmatch(attribute) or any(part.startswith("_") for part in attribute.split(".")):
|
||||||
|
raise ValueError(f"Object path attribute is not a public attribute path: {path!r}")
|
||||||
|
return module_name, attribute
|
||||||
|
|
||||||
|
|
||||||
|
def trusted_import_prefixes() -> tuple[str, ...]:
|
||||||
|
configured = os.getenv(TRUSTED_IMPORT_PREFIXES_ENV, "").strip()
|
||||||
|
if not configured:
|
||||||
|
return DEFAULT_TRUSTED_IMPORT_PREFIXES
|
||||||
|
prefixes = tuple(item.strip() for item in configured.split(",") if item.strip())
|
||||||
|
return prefixes or DEFAULT_TRUSTED_IMPORT_PREFIXES
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_trusted_import_prefix(module_name: str, prefixes: Iterable[str]) -> None:
|
||||||
|
if any(_module_matches_prefix(module_name, prefix) for prefix in prefixes):
|
||||||
|
return
|
||||||
|
raise ValueError(
|
||||||
|
f"Object path module {module_name!r} is outside trusted import prefixes. "
|
||||||
|
f"Set {TRUSTED_IMPORT_PREFIXES_ENV} to allow custom deployment config modules."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _module_matches_prefix(module_name: str, prefix: str) -> bool:
|
||||||
|
cleaned = prefix.strip()
|
||||||
|
if not cleaned:
|
||||||
|
return False
|
||||||
|
if cleaned.endswith((".", "_")):
|
||||||
|
return module_name.startswith(cleaned)
|
||||||
|
return module_name == cleaned or module_name.startswith(f"{cleaned}.")
|
||||||
|
|
||||||
|
|
||||||
def load_server_config(path: str | None = None) -> GovoplanServerConfig:
|
def load_server_config(path: str | None = None) -> GovoplanServerConfig:
|
||||||
config_path = path or os.getenv("GOVOPLAN_SERVER_CONFIG") or "govoplan_core.server.default_config:get_server_config"
|
config_path = path or os.getenv("GOVOPLAN_SERVER_CONFIG") or "govoplan_core.server.default_config:get_server_config"
|
||||||
try:
|
try:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user