Compare commits
129 Commits
e6f7c45f0a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -138,11 +138,13 @@ dist
|
||||
|
||||
# Local WebUI test/build scratch directories
|
||||
.component-test-build/
|
||||
.file-drop-test-build/
|
||||
.module-test-build/
|
||||
.policy-test-build/
|
||||
.template-preview-test-build/
|
||||
.import-test-build/
|
||||
webui/.component-test-build/
|
||||
webui/.file-drop-test-build/
|
||||
webui/.module-test-build/
|
||||
webui/.policy-test-build/
|
||||
webui/.template-preview-test-build/
|
||||
|
||||
28
README.md
28
README.md
@@ -4,13 +4,6 @@
|
||||
**Repository type:** system (kernel).
|
||||
<!-- 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.
|
||||
|
||||
## Repository ownership
|
||||
@@ -54,7 +47,7 @@ python3 -m venv .venv
|
||||
./.venv/bin/python -m pip install -r requirements-dev.txt
|
||||
```
|
||||
|
||||
Run the platform server from core through the module-aware development runner. The default config reads `ENABLED_MODULES` and discovers installed module entry points. Local development defaults to `tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops`; set `ENABLED_MODULES` explicitly when testing a smaller module permutation.
|
||||
Run the platform server from core through the module-aware development runner. The default config reads `ENABLED_MODULES` and discovers installed module entry points. Local development defaults to the modules listed by `govoplan_core.settings.Settings.enabled_modules`, including Views when its package is installed; set `ENABLED_MODULES` explicitly when testing a smaller module permutation.
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
@@ -74,6 +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`.
|
||||
|
||||
For focused backend work, keep the complete module graph active while watching
|
||||
only the module being edited. Core/config sources and explicit `--reload-dir`
|
||||
paths remain watched:
|
||||
|
||||
```bash
|
||||
/mnt/DATA/git/govoplan/.venv/bin/python -m govoplan_core.devserver \
|
||||
--reload-module calendar \
|
||||
--reload-module campaign
|
||||
```
|
||||
|
||||
Use `--reload-core-only` when no optional module source tree should trigger a
|
||||
restart. Omitting both options preserves the broad default and watches every
|
||||
enabled module. Startup, migration, and compatibility checks still run against
|
||||
the complete enabled graph whenever the backend restarts.
|
||||
|
||||
The default development database is PostgreSQL at `postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev`. Store the password in `~/.pgpass`. To force the disposable SQLite fallback, run with `GOVOPLAN_DEV_DATABASE_BACKEND=sqlite`; that database lives below `runtime/`.
|
||||
|
||||
Local devserver runs do not require Redis. `CELERY_ENABLED` defaults to `false`, so campaign queue actions update database state without publishing Celery tasks. Use the synchronous send flow for local send tests, or set `CELERY_ENABLED=true` only when a Redis broker and worker are running.
|
||||
@@ -143,6 +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).
|
||||
|
||||
Production builds lazy-load enabled module descriptors and enforce initial and
|
||||
asynchronous JavaScript budgets. See
|
||||
[WEBUI_BUNDLE_BUDGETS.md](docs/WEBUI_BUNDLE_BUDGETS.md).
|
||||
|
||||
## Module contract
|
||||
|
||||
Backend modules register through the `govoplan.modules` entry point and return a `ModuleManifest`. A manifest can contribute:
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""add reusable core credential envelopes
|
||||
|
||||
Revision ID: c91f0a72be34
|
||||
Revises: 0f1e2d3c4b5a
|
||||
Create Date: 2026-07-23 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c91f0a72be34"
|
||||
down_revision = "0f1e2d3c4b5a"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if "core_credential_envelopes" in inspector.get_table_names():
|
||||
return
|
||||
op.create_table(
|
||||
"core_credential_envelopes",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("scope_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("scope_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("credential_kind", sa.String(length=40), nullable=False),
|
||||
sa.Column("public_data", sa.JSON(), nullable=False),
|
||||
sa.Column("secret_data_encrypted", sa.Text(), nullable=True),
|
||||
sa.Column("secret_keys", sa.JSON(), nullable=False),
|
||||
sa.Column("allowed_modules", sa.JSON(), nullable=False),
|
||||
sa.Column("allowed_server_refs", sa.JSON(), nullable=False),
|
||||
sa.Column("inherit_to_lower_scopes", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("revision", sa.String(length=36), nullable=False),
|
||||
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("updated_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["core_scopes.id"],
|
||||
name=op.f("fk_core_credential_envelopes_tenant_id_core_scopes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_credential_envelopes")),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_core_credential_envelopes_scope",
|
||||
"core_credential_envelopes",
|
||||
["tenant_id", "scope_type", "scope_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_core_credential_envelopes_active",
|
||||
"core_credential_envelopes",
|
||||
["tenant_id", "is_active", "deleted_at"],
|
||||
unique=False,
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"scope_type",
|
||||
"scope_id",
|
||||
"credential_kind",
|
||||
"is_active",
|
||||
"created_by_user_id",
|
||||
"updated_by_user_id",
|
||||
"deleted_at",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_core_credential_envelopes_{column}"),
|
||||
"core_credential_envelopes",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if "core_credential_envelopes" in inspector.get_table_names():
|
||||
op.drop_table("core_credential_envelopes")
|
||||
@@ -5,9 +5,14 @@ from logging.config import fileConfig
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
|
||||
try:
|
||||
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate optional access metadata
|
||||
except ModuleNotFoundError as exc:
|
||||
if exc.name != "govoplan_access":
|
||||
raise
|
||||
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
|
||||
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core.migrations import migration_metadata_plan
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.server.default_config import get_server_config
|
||||
@@ -20,7 +25,10 @@ database_url = config.attributes.get("database_url") or settings.database_url
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
|
||||
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():
|
||||
|
||||
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")
|
||||
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
|
||||
- provenance, export source metadata, and signature metadata
|
||||
|
||||
Portable configuration schemas follow `COMPATIBILITY_POLICY.md`: providers
|
||||
write only their current schema version and read that version plus the previous
|
||||
two versions. Older input must produce a version-specific staged-upgrade
|
||||
diagnostic.
|
||||
|
||||
Configuration fragments are interpreted only by the module that owns them. For
|
||||
example, workflow imports workflow definitions; forms imports form schemas;
|
||||
mail imports mail templates and delivery defaults; payments imports payment
|
||||
|
||||
@@ -144,8 +144,12 @@ tools/checks/postgres-integration-check.py \
|
||||
```
|
||||
|
||||
The integration check runs migrations and startup smoke checks across the
|
||||
standard module permutations. `--reset-schema` is destructive and belongs only
|
||||
on throwaway databases.
|
||||
standard module permutations. It first requires the retirement atomicity proof,
|
||||
using Files' real secret-owning provider and Audit's persistent recorder. That
|
||||
proof uses only random, test-owned schemas and cleans them afterward; it does
|
||||
not reset `public`. `--reset-schema` is destructive and belongs only on
|
||||
throwaway databases. Do not pass `--skip-retirement-atomicity` when collecting
|
||||
release evidence.
|
||||
|
||||
### Broker And Workers
|
||||
|
||||
@@ -153,16 +157,26 @@ on throwaway databases.
|
||||
| --- | --- | --- |
|
||||
| `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. |
|
||||
| `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. |
|
||||
| `CELERY_QUEUES` | `send_email,append_sent,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:
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
| Setting | Default | Notes |
|
||||
@@ -184,15 +198,64 @@ prefer `FILE_STORAGE_*`.
|
||||
| Setting | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `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_CSRF_COOKIE_NAME` | configured default | Must match WebUI/API deployment. |
|
||||
| `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_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
|
||||
module settings. Do not hardcode them in core; configuration packages should ask
|
||||
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
|
||||
|
||||
@@ -275,7 +338,7 @@ the checked in `.env.example`. It runs:
|
||||
- explicit `ENABLED_MODULES`
|
||||
- explicit migrations and `--with-dev-data` bootstrap
|
||||
- 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
|
||||
- durable local files under `runtime/production-like/files`
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ operator, and roadmap pages.
|
||||
| Topic | Canonical document | Notes |
|
||||
| --- | --- | --- |
|
||||
| Module architecture and kernel contracts | `MODULE_ARCHITECTURE.md` | Stable module contracts, API efficiency contracts, durable boundary decisions, lifecycle, and WebUI contribution rules. |
|
||||
| Compatibility policy | `COMPATIBILITY_POLICY.md` | Supported database upgrade origins, portable-schema read/write windows, runtime/API alias retirement, and compatibility-code removal criteria. |
|
||||
| RBAC and resource access | `ACCESS_RBAC_MODEL.md` | Current permission, role, API-key, and resource-access model. |
|
||||
| Governance hierarchy | `GOVERNANCE_MODEL.md` | System, tenant, user/group, campaign policy inheritance and admin UI structure. |
|
||||
| Policy decision DTOs and provenance | `POLICY_CONTRACTS.md` | Shared explain/provenance shape; module-specific policy docs should link here. |
|
||||
@@ -25,6 +26,7 @@ operator, and roadmap pages.
|
||||
| Release dependencies and catalogs | `RELEASE_DEPENDENCIES.md` | Release package refs, migration baselines, release lockfiles, catalog trust/licensing, catalog publishing, and release checklist. |
|
||||
| Dependency vulnerability audits | `DEPENDENCY_AUDITS.md` | Local and CI audit commands plus dated audit result notes. |
|
||||
| Remote WebUI bundle design | `REMOTE_WEBUI_BUNDLES.md` | Experimental controlled-deployment design; normal releases still use package builds. |
|
||||
| WebUI loading and bundle budgets | `WEBUI_BUNDLE_BUDGETS.md` | Installed-module lazy boundaries, enforced initial/async budgets, and baseline measurements. |
|
||||
|
||||
## Product And Module Planning
|
||||
|
||||
|
||||
@@ -8,25 +8,32 @@ module reactions, and operator diagnostics.
|
||||
|
||||
## Production Transport Decision
|
||||
|
||||
The first production target is a **database outbox plus in-process immediate
|
||||
dispatch**:
|
||||
The production transport is a **transactional database outbox plus retrying
|
||||
dispatcher**:
|
||||
|
||||
- Use `govoplan_core.core.events.PlatformEvent` for domain and platform events.
|
||||
- Use `EventBus` as the in-process dispatch contract for same-process module
|
||||
reactions that are safe to run inline.
|
||||
- Call `emit_platform_event(session, event)` to bind event delivery to the
|
||||
domain transaction.
|
||||
- The optional `platform.eventOutbox` capability persists events atomically.
|
||||
The Audit module provides the current SQL implementation.
|
||||
- Without an outbox provider, Core publishes to `EventBus` only after the outer
|
||||
transaction commits. This preserves reduced installations but is not durable
|
||||
across process failure or multiple workers.
|
||||
- Use `EventBus` as the in-process dispatch contract for module reactions
|
||||
invoked by the outbox dispatcher or for non-critical fallback reactions.
|
||||
- Use the shared `audit_event` / `audit_from_principal` helper for audited
|
||||
module actions. The helper persists the audit row and immediately publishes a
|
||||
module actions. The helper persists the audit row and transactionally emits a
|
||||
governed `PlatformEvent` whose `type` is the audit action.
|
||||
- Use `record_change` for module delta feeds. It persists the change-sequence
|
||||
row and immediately publishes a generic module change event such as
|
||||
row and transactionally emits a generic module change event such as
|
||||
`mail.profile.updated`.
|
||||
- Persist durable integration/workflow events through a database outbox before
|
||||
acknowledging the state change that produced them.
|
||||
- Drain the outbox through a small dispatcher process. The dispatcher may call
|
||||
in-process handlers in the same deployment first, but its storage contract is
|
||||
database-backed.
|
||||
- Drain the outbox with the Celery `govoplan.events.dispatch_outbox` task on the
|
||||
`events` queue. The periodic schedule also retries pending rows.
|
||||
- The dispatcher invokes Dataflow event ingestion when that capability is
|
||||
active, then publishes to the process-local bus.
|
||||
- Treat Redis/Celery as worker/job infrastructure, not as the authoritative
|
||||
first event transport. A Celery dispatcher can consume the outbox later.
|
||||
first event transport. PostgreSQL remains authoritative until dispatch is
|
||||
recorded.
|
||||
- Keep the dispatch implementation pluggable behind the `PlatformEvent`
|
||||
envelope so a future message broker can be added without changing event
|
||||
producers.
|
||||
@@ -44,17 +51,18 @@ Event producers should write their domain state and outbox event in the same
|
||||
database transaction wherever possible. Handlers must be idempotent because the
|
||||
outbox dispatcher can retry after a crash or timeout.
|
||||
|
||||
Recommended first outbox columns:
|
||||
The current outbox stores:
|
||||
|
||||
- `event_id`, `event_type`, `module_id`
|
||||
- `correlation_id`, `causation_id`
|
||||
- `payload`, `occurred_at`
|
||||
- `available_at`, `attempt_count`, `claimed_at`, `claim_token`
|
||||
- `processed_at`, `last_error`
|
||||
- `classification`, serialized event `payload`
|
||||
- `status`, `attempts`, `next_attempt_at`
|
||||
- `dispatched_at`, `last_error`, timestamps
|
||||
|
||||
Inline `EventBus` handlers are allowed only for non-critical local reactions.
|
||||
Anything that must survive process failure, restart, package update, or worker
|
||||
redeployment belongs in the outbox.
|
||||
Handlers must be idempotent: a worker may complete an external effect and fail
|
||||
before marking its outbox row dispatched. Anything that must survive process
|
||||
failure, restart, package update, or worker redeployment requires the outbox
|
||||
provider and dispatcher.
|
||||
|
||||
## Trace IDs
|
||||
|
||||
|
||||
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
|
||||
|
||||
This roadmap is the durable product north star and sequencing guide for
|
||||
GovOPlaN as a modular platform for administrative operations. It keeps the
|
||||
product moving without turning every possible public-sector need into an
|
||||
immediate implementation track.
|
||||
This roadmap is the technical and module-sequencing companion for GovOPlaN as
|
||||
a modular platform for administrative operations. It translates the
|
||||
cross-product outcome horizons into dependency waves without turning every
|
||||
possible public-sector need into an immediate implementation track.
|
||||
|
||||
Use this document for product direction, sequencing, and module routing. Issues
|
||||
are the active backlog; this document is durable planning context and should be
|
||||
mirrored to the Gitea wiki.
|
||||
Use this document for technical sequencing, module routing, and implementation
|
||||
gates. Issues are the active backlog; this document is durable architecture
|
||||
planning context and should be mirrored to the Gitea wiki.
|
||||
|
||||
The meta repository's
|
||||
[Connected Governance Platform Roadmap](https://git.add-ideas.de/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
|
||||
|
||||
@@ -113,7 +123,8 @@ pattern exists.
|
||||
|
||||
## 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.
|
||||
3. Do not add module-to-module imports for optional behavior.
|
||||
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` |
|
||||
| Appointment proposals and booking | `govoplan-appointments`, `govoplan-calendar` |
|
||||
| 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` |
|
||||
| Service directory/catalog | `govoplan-portal` |
|
||||
| Permit/document generation | `govoplan-templates`, `govoplan-dms` |
|
||||
| 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` |
|
||||
| Recurring extraction and transformation | possible future `govoplan-datasources`, possible future `govoplan-dataflow` |
|
||||
| Reports, BI, and management visibility | `govoplan-reporting` |
|
||||
@@ -189,56 +204,82 @@ an editor applies a high-impact configuration change.
|
||||
|
||||
## 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.
|
||||
2. The applicant uploads required files and submits structured form data.
|
||||
3. Submission creates a case, a workflow instance, and an internal task.
|
||||
4. Completing the task creates a postbox message, a notification, and an email
|
||||
notification with an appointment proposal.
|
||||
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.
|
||||
The technical gate is a pinned Campaign/Mail/Files composition with central
|
||||
UI, adaptive user/admin/operator/integration documentation, target SMTP/IMAP
|
||||
and file-provider evidence, and explicit test/send/resend/retry/reconciliation
|
||||
semantics. Readers must not receive backend paths, worker claims, secrets, or
|
||||
raw provider diagnostics.
|
||||
|
||||
This journey proves the platform can coordinate modules without core knowing
|
||||
module internals.
|
||||
### Journey 2: Function-Bound Postbox Delivery
|
||||
|
||||
### 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.
|
||||
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.
|
||||
### Journey 3: Data-Backed Templates, Reports, And Deep Launch
|
||||
|
||||
This journey keeps `booking`, `resources`, `learning`, and `certificates`
|
||||
focused instead of becoming broad ERP replacements.
|
||||
An authenticated user follows an opaque, short-lived launch reference from HIS
|
||||
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.
|
||||
2. The issue is triaged into helpdesk, facilities, assets, or a case.
|
||||
3. Work is assigned, tracked, and escalated.
|
||||
4. Evidence, communication, and status updates are preserved.
|
||||
5. Reports show workload, SLA, recurring problems, and completion.
|
||||
Starting from the Journey 3 source contract, one bounded university dataset is
|
||||
catalogued, staged by snapshot or watermark, validated, transformed through a
|
||||
versioned lineage graph, and exposed as a policy-aware analytical data product.
|
||||
The result must preserve official-key mappings, organizational and reporting
|
||||
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`
|
||||
from becoming disconnected ticket silos.
|
||||
Reporting consumes the product. Create `govoplan-datasources` or
|
||||
`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
|
||||
|
||||
@@ -248,9 +289,13 @@ Refine:
|
||||
|
||||
- `govoplan-core`: module discovery, capabilities, events, migrations, release
|
||||
catalog, configuration package runtime, WebUI shell.
|
||||
- `govoplan-access`: identities, sessions, API keys, users, groups, roles,
|
||||
memberships, function assignments, delegation, RBAC decisions.
|
||||
- `govoplan-tenancy`: tenant and organizational-unit boundaries.
|
||||
- `govoplan-identity`: canonical identities and account links.
|
||||
- `govoplan-organizations`: organizational structures, units, and functions.
|
||||
- `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
|
||||
directory, assurance, and later encrypted postbox key access.
|
||||
- `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:
|
||||
|
||||
1. `govoplan-issue-reporting`: public/internal reports, categories, intake,
|
||||
1. `govoplan-tickets`: public/internal reports, requests, incidents, queues,
|
||||
location, evidence, and triage.
|
||||
2. `govoplan-helpdesk`: service desk tickets, queues, SLAs, assignments,
|
||||
escalation, and resolution evidence.
|
||||
@@ -530,17 +575,30 @@ Before a module becomes release-included, it needs:
|
||||
- smoke test or permutation test
|
||||
- no required imports from optional modules
|
||||
|
||||
## Priority Order Summary
|
||||
## Technical Dependency Order Summary
|
||||
|
||||
1. Stabilize the platform spine.
|
||||
2. Deliver permit-to-payment MVP.
|
||||
3. Build booking and resource operations.
|
||||
4. Add learning and certificates.
|
||||
5. Add issue reporting and helpdesk.
|
||||
6. Add records, DMS, search, and transparency.
|
||||
7. Add procurement, contracts, grants, and finance handoff.
|
||||
8. Add committee and consultation workflows.
|
||||
9. Expand integration, dataflow, reporting, and operations.
|
||||
Use this active order while respecting the ownership and implementation gates
|
||||
in the capability waves:
|
||||
|
||||
1. Keep the platform/release spine green and extend connector, identity,
|
||||
external-effect, provenance, documentation, focused-view, recovery, and
|
||||
version contracts only as the current journey requires.
|
||||
2. Complete and package Campaign with Mail-owned profiles, Files, target
|
||||
delivery/recovery, central UI, and adaptive documentation.
|
||||
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
|
||||
|
||||
@@ -548,7 +606,8 @@ Defer these until a reference journey proves the need:
|
||||
|
||||
- full ERP replacement
|
||||
- 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
|
||||
- rich LMS behavior beyond training administration
|
||||
- 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 |
|
||||
| --- | --- | --- |
|
||||
| Government operations backbone reference model | `govoplan-core` | `add-ideas/govoplan-core#213` |
|
||||
| Permit-to-payment configuration package | `govoplan-core` plus participating modules | `add-ideas/govoplan-core#214` |
|
||||
| Fully UI-managed configuration with safety controls | `govoplan-admin`, `govoplan-core`, `govoplan-policy`, `govoplan-access`, `govoplan-audit` | `add-ideas/govoplan-core#218` |
|
||||
| Access as a module | `govoplan-access` | `add-ideas/govoplan-access#7` |
|
||||
| Interface ethics and decision-consequence doctrine | `govoplan-core` plus all UI-owning modules | `add-ideas/govoplan-core#227` |
|
||||
| Action/effect automation layer | first `govoplan-workflow`; possible future `govoplan-automation` | `add-ideas/govoplan-workflow#1` |
|
||||
| E2EE role/function postbox architecture | `govoplan-postbox`, `govoplan-identity-trust`, `govoplan-access`, `govoplan-policy`, `govoplan-audit` | `add-ideas/govoplan-postbox#15`, `add-ideas/govoplan-identity-trust#1` |
|
||||
| Identity, account, function, role, right semantic model | `govoplan-access` | `add-ideas/govoplan-access#9` |
|
||||
| Role-based service directory/catalog | `govoplan-portal` | `add-ideas/govoplan-portal#1` |
|
||||
| Unified inbox across tasks, postbox, notifications, and portal | `govoplan-core` coordination plus owning modules | `add-ideas/govoplan-tasks#1`, `add-ideas/govoplan-notifications#1` |
|
||||
| OpenProject API / project management connector | `govoplan-connectors` | `add-ideas/govoplan-connectors#1` |
|
||||
| Native project-management module decision | connector-first through `govoplan-connectors`; no native project module yet | `add-ideas/govoplan-core#196`, `add-ideas/govoplan-connectors#1` |
|
||||
| Datasources for databases, CSV, files, APIs | no repository yet; start with connectors/files/reporting and create `govoplan-datasources` only after the first package proves shared source-catalog ownership | `add-ideas/govoplan-core#197` |
|
||||
| Dataflow for pipelines, BI, publication | no repository yet; start with workflow/reporting/connectors and create `govoplan-dataflow` only after repeated pipeline/lineage contracts emerge | `add-ideas/govoplan-core#198` |
|
||||
| Monthly datasource and transformation workflows | first as configuration package across connectors, files, workflow, reporting, and templates | `add-ideas/govoplan-core#216` |
|
||||
| Templates for letters, emails, forms, reports | `govoplan-templates`, separate from reporting | `add-ideas/govoplan-core#190`, `add-ideas/govoplan-templates#1` |
|
||||
| Reporting and BI | `govoplan-reporting`, separate from templates | `add-ideas/govoplan-core#190`, `add-ideas/govoplan-reporting#1` |
|
||||
| File connectors: Nextcloud, Seafile, SMB, NFS | `govoplan-files` | `add-ideas/govoplan-files#15` |
|
||||
| Public-sector software integration catalogue | `govoplan-connectors` with core strategy index | `add-ideas/govoplan-core#191`, `add-ideas/govoplan-connectors#2` |
|
||||
| Public-sector integration landscape catalogue | `govoplan-connectors` with core tracking | `add-ideas/govoplan-core#215` |
|
||||
| Cases module concept | `govoplan-cases` | `add-ideas/govoplan-core#174` |
|
||||
| Workflow module concept | `govoplan-workflow` | `add-ideas/govoplan-core#175` |
|
||||
| Connectors module concept | `govoplan-connectors` | `add-ideas/govoplan-core#176` |
|
||||
| Adrema-style address and distribution-list management | `govoplan-addresses` | `add-ideas/govoplan-addresses#1` |
|
||||
| Consume sources and become a governed source | `govoplan-connectors` plus possible future `govoplan-dataflow` | `add-ideas/govoplan-connectors#3`, `add-ideas/govoplan-core#198` |
|
||||
| Governed connector configuration, dry-run, and simulation runtime | `govoplan-connectors` | `add-ideas/govoplan-connectors#6` |
|
||||
| Terminfindung and meeting scheduling polls | `govoplan-scheduling`; calendar primitives remain in calendar | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-scheduling#1` |
|
||||
| Terminplaner and calendar primitives | `govoplan-calendar` | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-calendar#1` |
|
||||
| Terminbuchung appointment booking | `govoplan-appointments` | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-appointments#1` |
|
||||
| Collaborative documents | `govoplan-dms` | `add-ideas/govoplan-dms#1` |
|
||||
| Forms | `govoplan-forms` for definitions and `govoplan-forms-runtime` for submissions/runtime behavior | `add-ideas/govoplan-core#194`, `add-ideas/govoplan-forms#1` |
|
||||
| RSS consume and emit | `govoplan-connectors` | `add-ideas/govoplan-connectors#4` |
|
||||
| LDAP, Active Directory, OpenDesk identity | `govoplan-idm` | `add-ideas/govoplan-idm#1` |
|
||||
| OpenDesk stack integration map | integration profile across IDM/access, mail/calendar, files/DMS, and connectors; not a monolithic module | `add-ideas/govoplan-core#195`, `add-ideas/govoplan-connectors#5` |
|
||||
| Open-Xchange mail/groupware | `govoplan-mail` | `add-ideas/govoplan-mail#5` |
|
||||
| Open-Xchange calendar | `govoplan-calendar` | `add-ideas/govoplan-calendar#2` |
|
||||
| Scalability profiles and autoscaling readiness | `govoplan-ops`, `govoplan-core` | `add-ideas/govoplan-core#217` |
|
||||
| Hardware sizing matrix and requirements calculator | `govoplan-ops`, `govoplan-core` | `add-ideas/govoplan-core#219` |
|
||||
| Collaboration suite integration strategy | `govoplan-connectors`, `govoplan-dms`, `govoplan-workflow`, `govoplan-tasks`, `govoplan-appointments`, `govoplan-calendar` | `add-ideas/govoplan-core#220` |
|
||||
| Install/runtime configuration contract | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#19` |
|
||||
| Installer/deployment operator flow | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#26` |
|
||||
| Production-like deployment documentation | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#28` |
|
||||
| Government operations backbone reference model | `govoplan-core` | `GovOPlaN/govoplan-core#213` |
|
||||
| Permit-to-payment configuration package | `govoplan-core` plus participating modules | `GovOPlaN/govoplan-core#214` |
|
||||
| Fully UI-managed configuration with safety controls | `govoplan-admin`, `govoplan-core`, `govoplan-policy`, `govoplan-access`, `govoplan-audit` | `GovOPlaN/govoplan-core#218` |
|
||||
| Access as a module | `govoplan-access` | `GovOPlaN/govoplan-access#7` |
|
||||
| Interface ethics and decision-consequence doctrine | `govoplan-core` plus all UI-owning modules | `GovOPlaN/govoplan-core#227` |
|
||||
| Action/effect automation layer | first `govoplan-workflow`; possible future `govoplan-automation` | `GovOPlaN/govoplan-workflow#1` |
|
||||
| E2EE role/function postbox architecture | `govoplan-postbox`, `govoplan-identity-trust`, `govoplan-access`, `govoplan-policy`, `govoplan-audit` | `GovOPlaN/govoplan-postbox#15`, `GovOPlaN/govoplan-identity-trust#1` |
|
||||
| Identity, account, function, role, right semantic model | `govoplan-access` | `GovOPlaN/govoplan-access#9` |
|
||||
| Role-based service directory/catalog | `govoplan-portal` | `GovOPlaN/govoplan-portal#1` |
|
||||
| Unified inbox across tasks, postbox, notifications, and portal | `govoplan-core` coordination plus owning modules | `GovOPlaN/govoplan-tasks#1`, `GovOPlaN/govoplan-notifications#1` |
|
||||
| OpenProject API / project management connector | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#1` |
|
||||
| Native project-management module decision | connector-first through `govoplan-connectors`; no native project module yet | `GovOPlaN/govoplan-core#196`, `GovOPlaN/govoplan-connectors#1` |
|
||||
| Datasources for databases, CSV, files, APIs | no repository yet; start with connectors/files/reporting and create `govoplan-datasources` only after the first package proves shared source-catalog ownership | `GovOPlaN/govoplan-core#197` |
|
||||
| Dataflow for pipelines, BI, publication | no repository yet; start with workflow/reporting/connectors and create `govoplan-dataflow` only after repeated pipeline/lineage contracts emerge | `GovOPlaN/govoplan-core#198` |
|
||||
| Monthly datasource and transformation workflows | first as configuration package across connectors, files, workflow, reporting, and templates | `GovOPlaN/govoplan-core#216` |
|
||||
| Templates for letters, emails, forms, reports | `govoplan-templates`, separate from reporting | `GovOPlaN/govoplan-core#190`, `GovOPlaN/govoplan-templates#1` |
|
||||
| Reporting and BI | `govoplan-reporting`, separate from templates | `GovOPlaN/govoplan-core#190`, `GovOPlaN/govoplan-reporting#1` |
|
||||
| File connectors: Nextcloud, Seafile, SMB, NFS | `govoplan-files` | `GovOPlaN/govoplan-files#15` |
|
||||
| Public-sector software integration catalogue | `govoplan-connectors` with core strategy index | `GovOPlaN/govoplan-core#191`, `GovOPlaN/govoplan-connectors#2` |
|
||||
| Public-sector integration landscape catalogue | `govoplan-connectors` with core tracking | `GovOPlaN/govoplan-core#215` |
|
||||
| Cases module concept | `govoplan-cases` | `GovOPlaN/govoplan-core#174` |
|
||||
| Workflow module concept | `govoplan-workflow` | `GovOPlaN/govoplan-core#175` |
|
||||
| Connectors module concept | `govoplan-connectors` | `GovOPlaN/govoplan-core#176` |
|
||||
| Adrema-style address and distribution-list management | `govoplan-addresses` | `GovOPlaN/govoplan-addresses#1` |
|
||||
| Consume sources and become a governed source | `govoplan-connectors` plus possible future `govoplan-dataflow` | `GovOPlaN/govoplan-connectors#3`, `GovOPlaN/govoplan-core#198` |
|
||||
| Governed connector configuration, dry-run, and simulation runtime | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#6` |
|
||||
| Terminfindung and meeting scheduling polls | `govoplan-scheduling`; calendar primitives remain in calendar | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-scheduling#1` |
|
||||
| Terminplaner and calendar primitives | `govoplan-calendar` | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-calendar#1` |
|
||||
| Terminbuchung appointment booking | `govoplan-appointments` | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-appointments#1` |
|
||||
| Collaborative documents | `govoplan-dms` | `GovOPlaN/govoplan-dms#1` |
|
||||
| Forms | `govoplan-forms` for definitions and `govoplan-forms-runtime` for submissions/runtime behavior | `GovOPlaN/govoplan-core#194`, `GovOPlaN/govoplan-forms#1` |
|
||||
| RSS consume and emit | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#4` |
|
||||
| LDAP, Active Directory, OpenDesk identity | `govoplan-idm` | `GovOPlaN/govoplan-idm#1` |
|
||||
| OpenDesk stack integration map | integration profile across IDM/access, mail/calendar, files/DMS, and connectors; not a monolithic module | `GovOPlaN/govoplan-core#195`, `GovOPlaN/govoplan-connectors#5` |
|
||||
| Open-Xchange mail/groupware | `govoplan-mail` | `GovOPlaN/govoplan-mail#5` |
|
||||
| Open-Xchange calendar | `govoplan-calendar` | `GovOPlaN/govoplan-calendar#2` |
|
||||
| Scalability profiles and autoscaling readiness | `govoplan-ops`, `govoplan-core` | `GovOPlaN/govoplan-core#217` |
|
||||
| Hardware sizing matrix and requirements calculator | `govoplan-ops`, `govoplan-core` | `GovOPlaN/govoplan-core#219` |
|
||||
| Collaboration suite integration strategy | `govoplan-connectors`, `govoplan-dms`, `govoplan-workflow`, `govoplan-tasks`, `govoplan-appointments`, `govoplan-calendar` | `GovOPlaN/govoplan-core#220` |
|
||||
| Install/runtime configuration contract | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#19` |
|
||||
| Installer/deployment operator flow | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#26` |
|
||||
| Production-like deployment documentation | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#28` |
|
||||
|
||||
Boundary rationale lives in `MODULE_ARCHITECTURE.md`. Current decisions:
|
||||
|
||||
@@ -645,19 +704,24 @@ Release composition and tag-only repository handling are documented in
|
||||
|
||||
## Next Practical Work
|
||||
|
||||
The next planning step should create or update Gitea issues for Wave 0 and Wave
|
||||
1 only. Later waves should stay as roadmap context until the permit-to-payment
|
||||
MVP is demonstrable.
|
||||
The active cross-product story is
|
||||
[`GovOPlaN/govoplan#14`](https://git.add-ideas.de/GovOPlaN/govoplan/issues/14).
|
||||
Module repositories own implementation issues; do not clone their state here.
|
||||
|
||||
Recommended immediate issue buckets:
|
||||
Immediate issue buckets:
|
||||
|
||||
- platform spine hardening
|
||||
- configuration package preflight and rollback
|
||||
- forms-runtime MVP
|
||||
- portal submission MVP
|
||||
- cases/workflow/tasks integration MVP
|
||||
- template-generated decision document
|
||||
- postbox/notification handoff
|
||||
- appointment/booking handoff
|
||||
- payment evidence handoff
|
||||
- configured documentation for the reference process
|
||||
- fail-closed connector destination pinning and private-network deployment
|
||||
control for every real transport
|
||||
- Mail-profile-only Campaign authoring/build/delivery and safe legacy failure
|
||||
- immediate audited secret deletion when a provider/profile is removed
|
||||
- Campaign central-component and role-safe UI acceptance
|
||||
- adaptive Campaign, Mail, and Files task/process/admin/operator/integration/
|
||||
security/acceptance documentation
|
||||
- target SMTP/IMAP, file-provider, queue/reconciliation, install/upgrade, and
|
||||
restore proof for the pinned Campaign reference composition
|
||||
|
||||
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,
|
||||
or public module APIs
|
||||
|
||||
The retention windows and removal checklist for database bridges,
|
||||
configuration/export schemas, and runtime/API aliases are defined in
|
||||
`COMPATIBILITY_POLICY.md`.
|
||||
|
||||
## Stable Kernel Contracts
|
||||
|
||||
The following contracts are the baseline API that modules can rely on:
|
||||
@@ -87,7 +91,8 @@ The following contracts are the baseline API that modules can rely on:
|
||||
- capability factory contract
|
||||
- access DTO/protocol contracts in `govoplan_core.core.access`
|
||||
- resource ACL provider contract
|
||||
- tenant summary provider contract
|
||||
- bounded reference-option search provider contract
|
||||
- single-tenant and optional batched tenant summary provider contracts
|
||||
- tenant delete-veto provider contract
|
||||
- WebUI module contribution contract
|
||||
- navigation metadata contract
|
||||
@@ -96,6 +101,15 @@ The following contracts are the baseline API that modules can rely on:
|
||||
|
||||
Changes to these contracts must be versioned or accompanied by compatibility shims.
|
||||
|
||||
Tenant list pages prefer `tenant_summary_batch_providers`. A batch provider
|
||||
receives the unique tenant IDs on the current page and returns count mappings
|
||||
keyed by tenant ID. Missing tenant keys mean that the provider has no counts for
|
||||
that tenant; provider errors remain visible. Modules that expose only the
|
||||
single-tenant contract remain compatible through a per-tenant fallback.
|
||||
Destructive tenant lifecycle planning deliberately continues to use the
|
||||
single-tenant path so it invokes every registered provider for the target
|
||||
tenant, independent of ordinary list-page projections.
|
||||
|
||||
This list is the Milestone A kernel-contract freeze baseline. New module work
|
||||
may extend the kernel by adding explicit contracts, but existing contracts must
|
||||
remain source-compatible through the 0.1.x split line unless a migration shim
|
||||
@@ -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 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
|
||||
|
||||
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
|
||||
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`
|
||||
- `campaigns.access`
|
||||
- `campaigns.delivery_tasks`
|
||||
- `campaigns.mail_policy_context`
|
||||
- `campaigns.policy_context`
|
||||
- `campaigns.retention`
|
||||
- `notifications.dispatch`
|
||||
- `poll.availability_matrix`, `poll.option_selection`,
|
||||
`poll.response_collection`, `poll.signed_participation`,
|
||||
`poll.workflow_context`
|
||||
- `rest.function_publication`
|
||||
- `scheduling.candidate_slots`, `scheduling.decision_handoff`
|
||||
- `soap.operation_publication`
|
||||
|
||||
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
|
||||
changed inside a collection.
|
||||
|
||||
### Mutation Preconditions
|
||||
|
||||
Weak response ETags are cache validators only. Mutable aggregates expose a
|
||||
separate positive, monotonic revision and an opaque strong ETag generated by
|
||||
`govoplan_core.core.concurrency.strong_resource_etag`. HTTP mutations send that
|
||||
strong ETag in `If-Match`; capability and worker calls carry the equivalent
|
||||
typed `expected_revision`.
|
||||
|
||||
Core's compare-and-set primitive advances the revision in the same transaction
|
||||
as the domain mutation. A missing HTTP precondition is `428 Precondition
|
||||
Required`, a stale HTTP precondition is `412 Precondition Failed`, and a
|
||||
domain/reconciliation conflict is `409 Conflict`. Conflict responses contain
|
||||
bounded resource and revision metadata rather than the complete current
|
||||
object. Modules may opt into the conservative three-way merge helper, but must
|
||||
declare protected workflow, delivery, ownership, lock, evidence, signature,
|
||||
and cryptographic paths that can never be merged automatically.
|
||||
|
||||
### Delta Collections
|
||||
|
||||
Collection endpoints that can expose row-level changes should use the shared
|
||||
@@ -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
|
||||
global sequence.
|
||||
|
||||
### Bounded Reference Selectors
|
||||
|
||||
Cross-module selectors use the module-neutral contract in
|
||||
`govoplan_core.core.references`; consumers must not load an optional module's
|
||||
complete directory and filter it in memory.
|
||||
|
||||
- Providers receive a normalized `ReferenceSearchRequest` with `kind`,
|
||||
`tenant_id`, `query`, `selected_values`, `limit`, optional opaque `cursor`,
|
||||
and policy context.
|
||||
- Providers apply visibility and text filtering before materializing rows and
|
||||
return `ReferenceSearchPage(options, next_cursor, has_more)`.
|
||||
- A page contains at most the requested bounded search results. Already-selected
|
||||
references are retained in addition to that bound so historical values remain
|
||||
readable and removable even when they are inactive, deleted, or outside the
|
||||
current search page.
|
||||
- API consumers expose `next_cursor` and `has_more`. The current searchable
|
||||
selector requests the first bounded page for each query; later load-more UI
|
||||
can use the same cursor without changing the provider contract.
|
||||
- `access.reference_options` supplies SQL-backed account, membership, and group
|
||||
searches. When it is absent, Core degrades to the legacy Access directory or
|
||||
to principal-only/unavailable references without importing Access.
|
||||
- The shared WebUI `apiReferenceOptionProvider` resolves selected values in
|
||||
chunks of at most 200, preventing a large existing selection from turning
|
||||
into an unbounded request.
|
||||
|
||||
### Cursor/Keyset Pages
|
||||
|
||||
Offset pagination remains supported for compatibility and for first page loads,
|
||||
@@ -434,6 +509,18 @@ The manifest should declare:
|
||||
- navigation metadata using serializable icon names
|
||||
- uninstall guard providers for data, migration, worker, or scheduler vetoes
|
||||
|
||||
A tenant-level managed `RoleTemplate` may set `default_authenticated=True`
|
||||
only when every authenticated tenant member must receive that narrow baseline
|
||||
while the contributing module is installed. Access derives the explicit grant
|
||||
from the active manifest set during authorization without mutating the request
|
||||
transaction. It may materialize a non-assignable role row for administration,
|
||||
but no per-user assignment is required and role edits cannot remove the
|
||||
baseline.
|
||||
This is not a shortcut for feature authorization: keep the template narrow and
|
||||
continue to enforce each domain action's own permission and resource policy.
|
||||
System-level, unmanaged, wildcard-bearing, or slug-colliding automatic
|
||||
templates are rejected by registry validation.
|
||||
|
||||
Backend nav metadata must use icon-name strings, not frontend components:
|
||||
|
||||
```python
|
||||
@@ -554,11 +641,18 @@ Uninstall remains non-destructive unless the operator explicitly requests
|
||||
|
||||
## WebUI Contract
|
||||
|
||||
A WebUI module exports a `PlatformWebModule` from its package. The object contributes local/fallback metadata and route render functions.
|
||||
A WebUI module exports a `PlatformWebModule` from its package. The object
|
||||
contributes local/fallback metadata and route render functions. The package
|
||||
must ship `src/module.ts` with the default contribution export: Core's Vite
|
||||
host imports that descriptor directly after the backend reports the module as
|
||||
enabled. This keeps package-root re-exports from pulling page implementations
|
||||
into the initial shell.
|
||||
|
||||
Example:
|
||||
|
||||
```ts
|
||||
const FilesPage = lazy(() => import("./features/files/FilesPage"));
|
||||
|
||||
export const filesModule: PlatformWebModule = {
|
||||
id: "files",
|
||||
label: "Files",
|
||||
@@ -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:
|
||||
|
||||
- `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. |
|
||||
| Governance defaults | `govoplan-admin` plus `govoplan-access` materializer | admin settings, governance template routes, access materialization capability | System governance can block tenant-local groups, roles, and API keys. |
|
||||
| Delegation and ownership policy | access/campaign/mail/files modules | capability checks and owner-scoped APIs | Source provenance should use this contract when policies become externally explainable. |
|
||||
| Definition governance | `govoplan-policy` | capability `policy.definitionGovernance` | Resolves view, edit, run/start, reuse, derive, and automate for system, tenant, group, and user Dataflow/Workflow definitions. |
|
||||
|
||||
## Policy Decision
|
||||
|
||||
@@ -111,6 +112,20 @@ matching compatibility DTOs only for its legacy admin surface. Admin overview
|
||||
responses remain module-local because the same counters are exposed from
|
||||
different menu contexts and are not yet a separately versioned platform API.
|
||||
|
||||
## Definition Governance
|
||||
|
||||
Dataflow and Workflow submit a `DefinitionGovernanceRequest` using only stable
|
||||
scope, principal, status, definition-kind, and limit fields. Policy returns a
|
||||
standard `PolicyDecision`. System definitions may be inherited as read-only;
|
||||
group and user definitions are visible only in matching contexts. Templates
|
||||
may be viewed and derived but never run or automated. A derived definition
|
||||
passes its pinned ancestor limits back through the request context, and Policy
|
||||
applies those limits as ceilings rather than defaults that can be broadened.
|
||||
|
||||
When the capability is absent, modules must not silently emulate cross-scope
|
||||
inheritance. Their conservative fallback is limited to local tenant
|
||||
definitions and disables reuse, derivation, and automation.
|
||||
|
||||
## Frontend Contract
|
||||
|
||||
Policy UIs must:
|
||||
|
||||
@@ -57,6 +57,11 @@ The trust layer should provide:
|
||||
- key rotation and epoch tracking
|
||||
- recovery policy hooks
|
||||
|
||||
Recovery must be organizationally governed. A server-held universal plaintext
|
||||
key would defeat the E2EE claim; any escrow, threshold recovery, or emergency
|
||||
grant needs an explicit assurance profile, authority/quorum, audit trail, and
|
||||
user-visible consequence.
|
||||
|
||||
## Role And Function Postboxes
|
||||
|
||||
Role-bound access needs special handling. A postbox can be bound to an
|
||||
@@ -75,6 +80,30 @@ rewrapping service:
|
||||
Key epochs are required when role membership changes. Older messages may remain
|
||||
readable according to policy, but new access must use the current epoch.
|
||||
|
||||
The function-bound container exists independently of membership. It may remain
|
||||
vacant and continue to receive ciphertext without falling back to an unrelated
|
||||
personal mailbox. Zero, one, or several incumbents are valid states. Each
|
||||
incumbent receives an independently auditable, device-bound wrapped-key path;
|
||||
the postbox is never copied into their account ownership.
|
||||
|
||||
A new assignment or hand-over rotates the function/postbox key epoch. Envelope
|
||||
encryption permits the normal rotation path to rewrap per-message data keys
|
||||
rather than rewrite large ciphertext objects; a security policy may require
|
||||
full content re-encryption for selected compromise or cryptographic-profile
|
||||
events. The history available to a new incumbent must be selected policy (all
|
||||
retained history, a bounded historical window, or assignment-time content) and
|
||||
recorded with the grant.
|
||||
|
||||
Delegation is a time-bounded represented-function grant, not a copy or
|
||||
substitution of the postbox. Expiry or withdrawal stops future key release and
|
||||
actions. It cannot revoke plaintext already decrypted, printed, exported, or
|
||||
captured outside the platform. Multiple simultaneous incumbents and delegates
|
||||
remain distinguishable in key-fetch and action evidence.
|
||||
|
||||
Postbox content and signed manifests are immutable. Correction or replacement
|
||||
creates a linked new object/version; it never silently substitutes ciphertext
|
||||
or evidence that another actor may already have inspected.
|
||||
|
||||
## External Recipients
|
||||
|
||||
External recipients may need one-time or time-limited access without a full
|
||||
|
||||
@@ -5,6 +5,9 @@ before deciding to replace specialist workflows. This document is the core
|
||||
strategy index. The executable connector catalogue lives in
|
||||
`govoplan-connectors/docs/PUBLIC_SECTOR_INTEGRATION_CATALOGUE.md`.
|
||||
|
||||
The canonical cumulative maturity model and external-object DTO are documented
|
||||
in [EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md](EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md).
|
||||
|
||||
## Strategy Labels
|
||||
|
||||
Use one or more of these labels for every external system family:
|
||||
|
||||
@@ -40,17 +40,26 @@ cd /mnt/DATA/git/govoplan
|
||||
Update those refs when cutting a release:
|
||||
|
||||
```text
|
||||
govoplan-access git@git.add-ideas.de:add-ideas/govoplan-access.git v0.1.6
|
||||
govoplan-admin git@git.add-ideas.de:add-ideas/govoplan-admin.git v0.1.6
|
||||
govoplan-tenancy git@git.add-ideas.de:add-ideas/govoplan-tenancy.git v0.1.6
|
||||
govoplan-organizations git@git.add-ideas.de:add-ideas/govoplan-organizations.git v0.1.6
|
||||
govoplan-identity git@git.add-ideas.de:add-ideas/govoplan-identity.git v0.1.6
|
||||
govoplan-policy git@git.add-ideas.de:add-ideas/govoplan-policy.git v0.1.6
|
||||
govoplan-audit git@git.add-ideas.de:add-ideas/govoplan-audit.git v0.1.6
|
||||
govoplan-files git@git.add-ideas.de:add-ideas/govoplan-files.git v0.1.6
|
||||
govoplan-mail git@git.add-ideas.de:add-ideas/govoplan-mail.git v0.1.6
|
||||
govoplan-campaign git@git.add-ideas.de:add-ideas/govoplan-campaign.git v0.1.6
|
||||
govoplan-calendar git@git.add-ideas.de:add-ideas/govoplan-calendar.git v0.1.6
|
||||
govoplan-tenancy git@git.add-ideas.de:GovOPlaN/govoplan-tenancy.git v0.1.8
|
||||
govoplan-organizations git@git.add-ideas.de:GovOPlaN/govoplan-organizations.git v0.1.8
|
||||
govoplan-identity git@git.add-ideas.de:GovOPlaN/govoplan-identity.git v0.1.8
|
||||
govoplan-idm git@git.add-ideas.de:GovOPlaN/govoplan-idm.git v0.1.8
|
||||
govoplan-access git@git.add-ideas.de:GovOPlaN/govoplan-access.git v0.1.8
|
||||
govoplan-admin git@git.add-ideas.de:GovOPlaN/govoplan-admin.git v0.1.8
|
||||
govoplan-policy git@git.add-ideas.de:GovOPlaN/govoplan-policy.git v0.1.8
|
||||
govoplan-audit git@git.add-ideas.de:GovOPlaN/govoplan-audit.git v0.1.8
|
||||
govoplan-dashboard git@git.add-ideas.de:GovOPlaN/govoplan-dashboard.git v0.1.8
|
||||
govoplan-addresses git@git.add-ideas.de:GovOPlaN/govoplan-addresses.git v0.1.8
|
||||
govoplan-files git@git.add-ideas.de:GovOPlaN/govoplan-files.git v0.1.8
|
||||
govoplan-mail git@git.add-ideas.de:GovOPlaN/govoplan-mail.git v0.1.8
|
||||
govoplan-campaign git@git.add-ideas.de:GovOPlaN/govoplan-campaign.git v0.1.8
|
||||
govoplan-calendar git@git.add-ideas.de:GovOPlaN/govoplan-calendar.git v0.1.8
|
||||
govoplan-poll git@git.add-ideas.de:GovOPlaN/govoplan-poll.git v0.1.8
|
||||
govoplan-scheduling git@git.add-ideas.de:GovOPlaN/govoplan-scheduling.git v0.1.8
|
||||
govoplan-notifications git@git.add-ideas.de:GovOPlaN/govoplan-notifications.git v0.1.8
|
||||
govoplan-evaluation git@git.add-ideas.de:GovOPlaN/govoplan-evaluation.git v0.1.8
|
||||
govoplan-docs git@git.add-ideas.de:GovOPlaN/govoplan-docs.git v0.1.8
|
||||
govoplan-ops git@git.add-ideas.de:GovOPlaN/govoplan-ops.git v0.1.8
|
||||
```
|
||||
|
||||
## WebUI Packages
|
||||
@@ -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
|
||||
```
|
||||
|
||||
The module repositories include root-level npm package manifests so git
|
||||
installs can resolve `@govoplan/access-webui`, `@govoplan/admin-webui`,
|
||||
`@govoplan/files-webui`, `@govoplan/mail-webui`,
|
||||
`@govoplan/campaign-webui`, and `@govoplan/calendar-webui` from repository
|
||||
roots even though their source lives below `webui/src`.
|
||||
Module repositories with a frontend include root-level npm package manifests
|
||||
so the `@govoplan/*-webui` dependencies in `webui/package.release.json` can be
|
||||
resolved from repository roots even though their source lives below
|
||||
`webui/src`.
|
||||
|
||||
### Release Lockfile Strategy
|
||||
|
||||
The supported release composition currently is the full GovOPlaN product: core
|
||||
plus access, admin, tenancy, organizations, identity, policy, audit,
|
||||
dashboard, files, mail, campaign, calendar, docs, and ops. Keep one committed
|
||||
full-product release lockfile at
|
||||
The supported backend release composition is the set pinned in the meta
|
||||
repository's `requirements-release.txt`. The supported frontend composition
|
||||
is the independently buildable module set pinned in
|
||||
`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.release.json` in a clean release workspace. Development
|
||||
`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
|
||||
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
|
||||
@@ -134,13 +143,11 @@ Current tag-only module repositories:
|
||||
- `govoplan-fit-connect`
|
||||
- `govoplan-forms`
|
||||
- `govoplan-identity-trust`
|
||||
- `govoplan-idm`
|
||||
- `govoplan-ledger`
|
||||
- `govoplan-notifications`
|
||||
- `govoplan-payments`
|
||||
- `govoplan-portal`
|
||||
- `govoplan-postbox`
|
||||
- `govoplan-reporting`
|
||||
- `govoplan-scheduling`
|
||||
- `govoplan-search`
|
||||
- `govoplan-tasks`
|
||||
- `govoplan-templates`
|
||||
@@ -781,10 +788,27 @@ tools/checks/postgres-integration-check.py \
|
||||
The script checks migrations and `/health` startup for core-only, files-only,
|
||||
mail-only, campaign-only, campaign+files, campaign+mail, and full-product
|
||||
module sets. `--reset-schema` is destructive and must only be used against a
|
||||
throwaway database.
|
||||
throwaway database. Before those permutations, the required Core proof runs in
|
||||
random, test-owned schemas without modifying `public`. It exercises Files' real
|
||||
credential-owning retirement provider and proves that credential scrubbing,
|
||||
non-secret audit
|
||||
insertion, and table retirement commit together; database-injected audit and
|
||||
DDL failures roll the entire unit back. A 500 ms PostgreSQL `lock_timeout` and
|
||||
captured backend process IDs also prove that each `DROP TABLE` uses the
|
||||
installer Session connection instead of waiting through a second connection.
|
||||
The meta check enables the release-gate flag so missing PostgreSQL configuration
|
||||
or full-stack test packages are a hard failure; ordinary Core-only test discovery
|
||||
skips this integration proof. Do not pass `--skip-retirement-atomicity` when
|
||||
collecting release evidence.
|
||||
|
||||
## Migration Baselines
|
||||
|
||||
Release migration support follows `COMPATIBILITY_POLICY.md`: every tagged
|
||||
`0.1.x` installation is a supported upgrade origin, released revision IDs are
|
||||
immutable, and migration-only reconciliation remains available for at least one
|
||||
subsequent major release cycle after the matching runtime compatibility path is
|
||||
removed.
|
||||
|
||||
Development migrations may be small and numerous while a feature is moving.
|
||||
GovOPlaN keeps those detailed migrations on an explicit development track and
|
||||
publishes reviewed release shortcuts on the release track. Before a stable
|
||||
@@ -874,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
|
||||
release baseline and add a new release-track step-up instead of replacing prior
|
||||
release shortcuts. The tracking issue is
|
||||
`add-ideas/govoplan-core#223`.
|
||||
`GovOPlaN/govoplan-core#223`.
|
||||
|
||||
## Related Operator Documents
|
||||
|
||||
|
||||
@@ -40,10 +40,16 @@ set +a
|
||||
|
||||
The command reports all known blockers at once. Production-like/self-hosted
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
to match.
|
||||
|
||||
Active tracking issue: `add-ideas/govoplan-core#225`.
|
||||
Active tracking issue: `GovOPlaN/govoplan-core#225`.
|
||||
|
||||
## Operating Rule
|
||||
|
||||
@@ -39,6 +39,17 @@ contestability, responsibility, and traceability at the point of action.
|
||||
| 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-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
|
||||
|
||||
@@ -156,6 +167,104 @@ shared CSS tokens and persisted user preference selection.
|
||||
- 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
|
||||
|
||||
| Phase | Scope | Output |
|
||||
@@ -218,6 +327,22 @@ Every new or changed admin/configuration surface should answer:
|
||||
- Does the action surface show consequence, reversibility, and audit evidence
|
||||
when rights, duties, records, money, communication, external systems, or
|
||||
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,
|
||||
observed effects, and failure/manual-intervention state?
|
||||
- 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.
|
||||
@@ -8,7 +8,7 @@
|
||||
"description": "Portal form, case workflow, task creation, mail notification, payment setup, and access roles for a simple application process.",
|
||||
"publisher": "ADD ideas",
|
||||
"category": "workflow",
|
||||
"artifact_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-configuration-packages.git@application-handling-basic-v0.1.0",
|
||||
"artifact_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-configuration-packages.git@application-handling-basic-v0.1.0",
|
||||
"artifact_sha256": "<sha256>",
|
||||
"required_modules": [
|
||||
{ "module_id": "portal", "version": ">=0.1.0" },
|
||||
|
||||
@@ -81,8 +81,588 @@
|
||||
],
|
||||
"recorded_at": "2026-07-11T01:39:45Z",
|
||||
"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
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
"version": "0.1.4",
|
||||
"action": "install",
|
||||
"python_package": "govoplan-files",
|
||||
"python_ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.4",
|
||||
"python_ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.4",
|
||||
"webui_package": "@govoplan/files-webui",
|
||||
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4",
|
||||
"webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.4",
|
||||
"migration_safety": "forward_only",
|
||||
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
|
||||
"migration_after": ["access"],
|
||||
@@ -54,14 +54,14 @@
|
||||
],
|
||||
"artifact_integrity": {
|
||||
"python": {
|
||||
"ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.4",
|
||||
"ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.4",
|
||||
"sha256": "<sha256 of the resolved Python artifact>",
|
||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-0.1.4.spdx.json",
|
||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-0.1.4.intoto.jsonl",
|
||||
"git_ref": "refs/tags/v0.1.4"
|
||||
},
|
||||
"webui": {
|
||||
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4",
|
||||
"ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.4",
|
||||
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-webui-0.1.4.spdx.json",
|
||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-webui-0.1.4.intoto.jsonl",
|
||||
@@ -78,25 +78,33 @@
|
||||
"version": "0.1.4",
|
||||
"action": "install",
|
||||
"python_package": "govoplan-mail",
|
||||
"python_ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.4",
|
||||
"python_ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.4",
|
||||
"webui_package": "@govoplan/mail-webui",
|
||||
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4",
|
||||
"webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.4",
|
||||
"provides_interfaces": [
|
||||
{
|
||||
"name": "mail.campaign_delivery",
|
||||
"version": "1.4.0"
|
||||
"version": "0.2.0"
|
||||
}
|
||||
],
|
||||
"requires_interfaces": [
|
||||
{
|
||||
"name": "campaigns.access",
|
||||
"version_min": "0.1.0",
|
||||
"version_max_exclusive": "0.2.0",
|
||||
"optional": true
|
||||
}
|
||||
],
|
||||
"artifact_integrity": {
|
||||
"python": {
|
||||
"ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.4",
|
||||
"ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.4",
|
||||
"sha256": "<sha256 of the resolved Python artifact>",
|
||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-0.1.4.spdx.json",
|
||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-0.1.4.intoto.jsonl",
|
||||
"git_ref": "refs/tags/v0.1.4"
|
||||
},
|
||||
"webui": {
|
||||
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4",
|
||||
"ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.4",
|
||||
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-webui-0.1.4.spdx.json",
|
||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-webui-0.1.4.intoto.jsonl",
|
||||
@@ -113,19 +121,19 @@
|
||||
"version": "0.1.6",
|
||||
"action": "install",
|
||||
"python_package": "govoplan-dashboard",
|
||||
"python_ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git@v0.1.6",
|
||||
"python_ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.6",
|
||||
"webui_package": "@govoplan/dashboard-webui",
|
||||
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.6",
|
||||
"webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.6",
|
||||
"artifact_integrity": {
|
||||
"python": {
|
||||
"ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git@v0.1.6",
|
||||
"ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.6",
|
||||
"sha256": "<sha256 of the resolved Python artifact>",
|
||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-0.1.6.spdx.json",
|
||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-0.1.6.intoto.jsonl",
|
||||
"git_ref": "refs/tags/v0.1.6"
|
||||
},
|
||||
"webui": {
|
||||
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.6",
|
||||
"ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.6",
|
||||
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-webui-0.1.6.spdx.json",
|
||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-webui-0.1.6.intoto.jsonl",
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-core"
|
||||
version = "0.1.8"
|
||||
version = "0.1.14"
|
||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -27,6 +27,12 @@ where = ["src"]
|
||||
[tool.setuptools.package-data]
|
||||
govoplan_core = ["py.typed"]
|
||||
|
||||
[tool.setuptools.data-files]
|
||||
"govoplan_core_runtime" = ["alembic.ini"]
|
||||
"govoplan_core_runtime/alembic" = ["alembic/env.py", "alembic/script.py.mako"]
|
||||
"govoplan_core_runtime/alembic/versions" = ["alembic/versions/*.py"]
|
||||
"govoplan_core_runtime/alembic/dev_versions" = ["alembic/dev_versions/*.py"]
|
||||
|
||||
[project.scripts]
|
||||
govoplan-config = "govoplan_core.commands.config:main"
|
||||
govoplan-devserver = "govoplan_core.devserver:main"
|
||||
@@ -42,3 +48,7 @@ dev = [
|
||||
"httpx==0.28.1",
|
||||
"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
|
||||
|
||||
|
||||
class ReferenceOptionResponse(BaseModel):
|
||||
value: str
|
||||
label: str
|
||||
description: str | None = None
|
||||
kind: str | None = None
|
||||
availability: Literal["available", "inactive", "unavailable"] = "available"
|
||||
disabled: bool = False
|
||||
source_module: str | None = None
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ReferenceOptionListResponse(BaseModel):
|
||||
options: list[ReferenceOptionResponse] = Field(default_factory=list)
|
||||
provider_available: bool = True
|
||||
next_cursor: str | None = None
|
||||
has_more: bool = False
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -201,7 +219,7 @@ class AuthGroupsResponse(BaseModel):
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
token_type: str = "bearer" # noqa: S105 - OAuth token type, not a credential.
|
||||
expires_at: datetime
|
||||
user: UserInfo
|
||||
# Backwards-compatible alias for the active tenant.
|
||||
|
||||
@@ -16,7 +16,7 @@ from govoplan_core.core.events import (
|
||||
PlatformEvent,
|
||||
current_event_trace,
|
||||
normalize_trace_id,
|
||||
publish_platform_event,
|
||||
emit_platform_event,
|
||||
)
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
from govoplan_core.privacy.retention import sanitize_audit_details_for_policy
|
||||
@@ -171,9 +171,13 @@ class _NullAuditRecorder:
|
||||
)
|
||||
|
||||
|
||||
def _publish_audit_platform_event(item: AuditRecordRef) -> None:
|
||||
def _publish_audit_platform_event(
|
||||
session: Session,
|
||||
item: AuditRecordRef,
|
||||
) -> None:
|
||||
trace = _compact_trace(item.details.get("_trace") if isinstance(item.details, Mapping) else None)
|
||||
publish_platform_event(
|
||||
emit_platform_event(
|
||||
session,
|
||||
PlatformEvent(
|
||||
type=item.action,
|
||||
module_id=_module_id_for_audit_action(item.action),
|
||||
@@ -252,7 +256,7 @@ def audit_event(
|
||||
actor_id=user_id or api_key_id,
|
||||
payload={"scope": scope, "action": action, "object_type": object_type, "object_id": object_id},
|
||||
)
|
||||
_publish_audit_platform_event(item)
|
||||
_publish_audit_platform_event(session, item)
|
||||
if commit:
|
||||
session.commit()
|
||||
return item
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Core auth dependency facade.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
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:
|
||||
registry = _registry_from_request(request)
|
||||
if registry is None or not registry.has_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Auth provider is not available")
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Auth provider is not available")
|
||||
capability = registry.require_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER)
|
||||
if not isinstance(capability, ApiPrincipalProvider):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid auth provider capability")
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from celery import Celery
|
||||
|
||||
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider
|
||||
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_OUTBOX, CalendarOutboxProvider
|
||||
from govoplan_core.core.dataflows import (
|
||||
CAPABILITY_DATAFLOW_RUN_WORKER,
|
||||
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER,
|
||||
DataflowRunWorker,
|
||||
DataflowTriggerDispatcher,
|
||||
)
|
||||
from govoplan_core.core.events import (
|
||||
CAPABILITY_PLATFORM_EVENT_OUTBOX,
|
||||
DurableEventConsumer,
|
||||
PlatformEvent,
|
||||
PlatformEventOutbox,
|
||||
publish_platform_event,
|
||||
)
|
||||
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
||||
from govoplan_core.core.mail import CAPABILITY_MAIL_DELIVERY_OUTBOX, MailDeliveryOutboxProvider
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
|
||||
from govoplan_core.core.postbox import (
|
||||
CAPABILITY_POSTBOX_ROUTING,
|
||||
PostboxRoutingProvider,
|
||||
)
|
||||
from govoplan_core.core.workflows import (
|
||||
CAPABILITY_WORKFLOW_RUNTIME_WORKER,
|
||||
WorkflowRuntimeWorker,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.runtime import configure_runtime
|
||||
from govoplan_core.settings import settings
|
||||
@@ -27,10 +52,72 @@ celery.conf.update(
|
||||
"govoplan.campaigns.append_sent": {"queue": "append_sent"},
|
||||
"govoplan.notifications.deliver": {"queue": "notifications"},
|
||||
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
|
||||
"govoplan.mail.dispatch_outbox": {"queue": "mail"},
|
||||
"govoplan.mail.purge_outbox": {"queue": "mail"},
|
||||
"govoplan.calendar.dispatch_outbox": {"queue": "calendar"},
|
||||
"govoplan.dataflow.dispatch_runs": {"queue": "dataflow"},
|
||||
"govoplan.dataflow.purge_runs": {"queue": "dataflow"},
|
||||
"govoplan.dataflow.dispatch_triggers": {"queue": "dataflow"},
|
||||
"govoplan.workflow.reconcile": {"queue": "workflow"},
|
||||
"govoplan.postbox.dispatch_routes": {"queue": "postbox"},
|
||||
"govoplan.events.dispatch_outbox": {"queue": "events"},
|
||||
"govoplan.events.purge_outbox": {"queue": "events"},
|
||||
},
|
||||
worker_prefetch_multiplier=1,
|
||||
task_acks_late=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,),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -67,6 +154,90 @@ def _notification_dispatch() -> NotificationDispatchProvider:
|
||||
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):
|
||||
"""Send one explicitly queued campaign job.
|
||||
@@ -102,7 +273,9 @@ def deliver_notification(self, notification_id: str):
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
with get_database().SessionLocal() as session:
|
||||
return dict(_notification_dispatch().deliver_notification(session, notification_id=notification_id))
|
||||
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)
|
||||
@@ -110,4 +283,286 @@ def deliver_pending_notifications(self, tenant_id: str | None = None, limit: int
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
with get_database().SessionLocal() as session:
|
||||
return dict(_notification_dispatch().deliver_pending(session, tenant_id=tenant_id, limit=limit))
|
||||
result = dict(_notification_dispatch().deliver_pending(session, tenant_id=tenant_id, limit=limit))
|
||||
session.commit()
|
||||
return result
|
||||
|
||||
|
||||
@celery.task(name="govoplan.mail.dispatch_outbox", bind=True, max_retries=0)
|
||||
def dispatch_mail_outbox(
|
||||
self,
|
||||
tenant_id: str | None = None,
|
||||
limit: int = 25,
|
||||
):
|
||||
"""Drain durable Mail commands; retry and reconciliation live in Mail."""
|
||||
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
with get_database().SessionLocal() as session:
|
||||
provider = _mail_delivery_outbox()
|
||||
if provider is None:
|
||||
return {
|
||||
"selected": 0,
|
||||
"accepted": 0,
|
||||
"partially_refused": 0,
|
||||
"retrying": 0,
|
||||
"failed": 0,
|
||||
"outcome_unknown": 0,
|
||||
"command_ids": [],
|
||||
}
|
||||
return dict(
|
||||
provider.dispatch_due(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
limit=limit,
|
||||
worker_id=getattr(self.request, "hostname", None),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@celery.task(name="govoplan.mail.purge_outbox", bind=True, max_retries=0)
|
||||
def purge_mail_outbox(self, limit: int = 250):
|
||||
"""Minimize expired Mail payloads while retaining delivery evidence."""
|
||||
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
with get_database().SessionLocal() as session:
|
||||
provider = _mail_delivery_outbox()
|
||||
if provider is None:
|
||||
return {"purged": 0}
|
||||
return dict(provider.purge_expired(session, limit=limit))
|
||||
|
||||
|
||||
@celery.task(name="govoplan.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
|
||||
|
||||
@@ -28,6 +28,9 @@ CAPABILITY_AUDIT_RECORDER = "audit.recorder"
|
||||
CAPABILITY_AUDIT_RETENTION = "audit.retention"
|
||||
|
||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER = "auth.apiPrincipalProvider"
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER = (
|
||||
"auth.automationPrincipalProvider"
|
||||
)
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER = "auth.principalResolver"
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR = "auth.permissionEvaluator"
|
||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER = "auth.tenantContextSwitcher"
|
||||
@@ -48,6 +51,7 @@ ACCESS_CAPABILITY_NAMES = frozenset(
|
||||
CAPABILITY_AUDIT_RECORDER,
|
||||
CAPABILITY_AUDIT_RETENTION,
|
||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
||||
@@ -57,12 +61,19 @@ ACCESS_CAPABILITY_NAMES = frozenset(
|
||||
AUTH_CAPABILITY_NAMES = frozenset(
|
||||
{
|
||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
||||
}
|
||||
)
|
||||
|
||||
DEFAULT_CAPABILITY_PROVIDERS: Mapping[str, str] = {
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER: ACCESS_MODULE_ID,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR: ACCESS_MODULE_ID,
|
||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER: ACCESS_MODULE_ID,
|
||||
}
|
||||
|
||||
AuthMethod = Literal["session", "api_key", "service_account"]
|
||||
AccessSubjectKind = Literal[
|
||||
"identity",
|
||||
|
||||
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.orm import Mapped, Session, mapped_column
|
||||
|
||||
from govoplan_core.core.events import EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, publish_platform_event
|
||||
from govoplan_core.core.events import EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, emit_platform_event
|
||||
from govoplan_core.db.base import Base, utcnow
|
||||
|
||||
WATERMARK_PREFIX = "seq:"
|
||||
@@ -96,13 +96,14 @@ def record_change(
|
||||
payload=payload or {},
|
||||
)
|
||||
session.add(entry)
|
||||
_publish_change_event(entry)
|
||||
_publish_change_event(session, entry)
|
||||
return entry
|
||||
|
||||
|
||||
def _publish_change_event(entry: ChangeSequenceEntry) -> None:
|
||||
def _publish_change_event(session: Session, entry: ChangeSequenceEntry) -> None:
|
||||
event_type = _change_event_type(entry.module_id, entry.resource_type, entry.operation)
|
||||
publish_platform_event(
|
||||
emit_platform_event(
|
||||
session,
|
||||
PlatformEvent(
|
||||
type=event_type,
|
||||
module_id=entry.module_id,
|
||||
|
||||
559
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",
|
||||
]
|
||||
@@ -1,8 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Literal
|
||||
from typing import Literal
|
||||
|
||||
from govoplan_core.security.permissions import scopes_grant
|
||||
from govoplan_core.security.redaction import contains_plain_secret
|
||||
@@ -22,7 +21,7 @@ class ConfigurationFieldSafety:
|
||||
storage: str
|
||||
ui_managed: bool
|
||||
risk: ConfigurationRisk
|
||||
secret_handling: SecretHandling = "none"
|
||||
secret_handling: SecretHandling = "none" # noqa: S105 - policy vocabulary.
|
||||
required_scopes: tuple[str, ...] = ()
|
||||
dry_run_required: bool = False
|
||||
validation_required: bool = True
|
||||
@@ -69,7 +68,7 @@ class ConfigurationChangeSafetyPlan:
|
||||
maintenance_required: bool = False
|
||||
maintenance_satisfied: bool = False
|
||||
rollback_history_required: bool = False
|
||||
secret_handling: SecretHandling = "none"
|
||||
secret_handling: SecretHandling = "none" # noqa: S105 - policy vocabulary.
|
||||
audit_event: str | None = None
|
||||
policy_explanation: str | None = None
|
||||
blockers: tuple[str, ...] = ()
|
||||
@@ -240,7 +239,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||
storage="module_settings",
|
||||
ui_managed=True,
|
||||
risk="high",
|
||||
secret_handling="reference_only",
|
||||
secret_handling="reference_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
required_scopes=("mail_servers:manage_credentials",),
|
||||
validation_required=True,
|
||||
audit_event="mail_server_profile.credential_updated",
|
||||
@@ -256,14 +255,14 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||
storage="module_settings",
|
||||
ui_managed=True,
|
||||
risk="high",
|
||||
secret_handling="reference_only",
|
||||
secret_handling="reference_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
required_scopes=("files:file:admin",),
|
||||
dry_run_required=True,
|
||||
policy_explanation_required=True,
|
||||
audit_event="files.connector_profile.updated",
|
||||
two_person_approval_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(
|
||||
key="DATABASE_URL",
|
||||
@@ -273,7 +272,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="destructive",
|
||||
secret_handling="env_only",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="Database connectivity remains deployment-managed and must not be changed from the running UI.",
|
||||
),
|
||||
@@ -285,11 +284,179 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="destructive",
|
||||
secret_handling="env_only",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
two_person_approval_required=True,
|
||||
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(
|
||||
key="GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS",
|
||||
label="Module package catalog trusted keys",
|
||||
@@ -298,7 +465,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
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.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
@@ -309,7 +476,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
notes="Configuration package trust roots are deployment-managed.",
|
||||
),
|
||||
)
|
||||
@@ -414,9 +581,9 @@ def _configuration_change_safety_state(
|
||||
approval_satisfied = not approval_required or approval_count >= 2
|
||||
if approval_required and not approval_satisfied:
|
||||
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")
|
||||
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")
|
||||
if field.rollback_history_required:
|
||||
warnings.append("rollback_history_required")
|
||||
@@ -466,7 +633,7 @@ def _policy_explanation(field: ConfigurationFieldSafety) -> str:
|
||||
parts.append("requires two-person approval")
|
||||
if field.maintenance_required:
|
||||
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")
|
||||
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 collections import defaultdict
|
||||
from collections.abc import Callable, Mapping
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
from typing import Any, Literal
|
||||
from typing import Any, Literal, Protocol, runtime_checkable
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import event as sqlalchemy_event
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
_TRACE_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
|
||||
_CONSUMER_ID_RE = re.compile(r"^[a-z][a-z0-9_.:-]{0,127}$")
|
||||
_PENDING_EVENTS_KEY = "govoplan.pending_platform_events"
|
||||
CAPABILITY_PLATFORM_EVENT_OUTBOX = "platform.eventOutbox"
|
||||
|
||||
|
||||
def new_event_id() -> str:
|
||||
@@ -100,6 +106,105 @@ class PlatformEvent:
|
||||
|
||||
|
||||
EventHandler = Callable[[PlatformEvent], None]
|
||||
DurableEventHandler = Callable[[PlatformEvent, str], None]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DurableEventConsumer:
|
||||
"""Allowlisted durable consumer with an explicit disclosure boundary."""
|
||||
|
||||
consumer_id: str
|
||||
handler: DurableEventHandler
|
||||
event_types: frozenset[str] = field(
|
||||
default_factory=lambda: frozenset({"*"})
|
||||
)
|
||||
classifications: frozenset[EventClassification] = field(
|
||||
default_factory=lambda: frozenset({"public", "internal"})
|
||||
)
|
||||
policy_decision_ref: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not _CONSUMER_ID_RE.fullmatch(self.consumer_id):
|
||||
raise ValueError("Durable event consumer id is invalid")
|
||||
if not self.event_types or any(
|
||||
item != "*" and not _TRACE_ID_RE.fullmatch(item)
|
||||
for item in self.event_types
|
||||
):
|
||||
raise ValueError(
|
||||
"Durable event consumers require valid event-type allowlists"
|
||||
)
|
||||
invalid_classifications = set(self.classifications) - {
|
||||
"public",
|
||||
"internal",
|
||||
"confidential",
|
||||
"restricted",
|
||||
}
|
||||
if not self.classifications or invalid_classifications:
|
||||
raise ValueError(
|
||||
"Durable event consumer classifications are invalid"
|
||||
)
|
||||
if (
|
||||
self.classifications & {"confidential", "restricted"}
|
||||
and not normalize_trace_id(self.policy_decision_ref)
|
||||
):
|
||||
raise ValueError(
|
||||
"Confidential or restricted event subscriptions require "
|
||||
"an explicit policy decision reference"
|
||||
)
|
||||
|
||||
def accepts(self, event: PlatformEvent) -> bool:
|
||||
return (
|
||||
event.classification in self.classifications
|
||||
and (
|
||||
"*" in self.event_types
|
||||
or event.type in self.event_types
|
||||
)
|
||||
)
|
||||
|
||||
def delivery_key(self, event: PlatformEvent) -> str:
|
||||
return f"{event.event_id}:{self.consumer_id}"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PlatformEventOutbox(Protocol):
|
||||
def enqueue(self, session: object, event: PlatformEvent) -> object:
|
||||
...
|
||||
|
||||
def dispatch_pending(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
consumers: Sequence[DurableEventConsumer] = (),
|
||||
observer: EventHandler | None = None,
|
||||
limit: int = 100,
|
||||
) -> Mapping[str, int]:
|
||||
...
|
||||
|
||||
def replay_delivery(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
event_id: str,
|
||||
consumer_id: str,
|
||||
operator_id: str,
|
||||
reason: str,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
def purge_terminal(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
before: datetime,
|
||||
limit: int = 500,
|
||||
) -> Mapping[str, int]:
|
||||
...
|
||||
|
||||
def delivery_metrics(
|
||||
self,
|
||||
session: object,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
|
||||
def current_event_trace() -> EventTrace | None:
|
||||
@@ -189,5 +294,85 @@ def publish_platform_event(event: PlatformEvent) -> None:
|
||||
platform_event_bus().publish(event)
|
||||
|
||||
|
||||
def platform_event_outbox(
|
||||
registry: object | None = None,
|
||||
) -> PlatformEventOutbox | None:
|
||||
if registry is None:
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
|
||||
registry = get_registry()
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
|
||||
):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
|
||||
return capability if isinstance(capability, PlatformEventOutbox) else None
|
||||
|
||||
|
||||
def emit_platform_event(
|
||||
session: Session,
|
||||
event: PlatformEvent,
|
||||
*,
|
||||
registry: object | None = None,
|
||||
) -> None:
|
||||
"""Persist an event with its transaction or publish it after commit.
|
||||
|
||||
The durable outbox is optional so reduced module combinations remain
|
||||
usable. Without it, the event is kept on the SQLAlchemy session and only
|
||||
delivered to the process-local bus after the outer transaction commits.
|
||||
"""
|
||||
|
||||
traced = ensure_event_trace(event)
|
||||
outbox = platform_event_outbox(registry)
|
||||
if outbox is not None:
|
||||
outbox.enqueue(session, traced)
|
||||
return
|
||||
transaction = (
|
||||
session.get_nested_transaction()
|
||||
or session.get_transaction()
|
||||
or session.begin()
|
||||
)
|
||||
pending_by_transaction = session.info.setdefault(
|
||||
_PENDING_EVENTS_KEY,
|
||||
{},
|
||||
)
|
||||
pending_by_transaction.setdefault(transaction, []).append(
|
||||
(platform_event_bus(), traced)
|
||||
)
|
||||
|
||||
|
||||
@sqlalchemy_event.listens_for(Session, "after_commit")
|
||||
def _publish_committed_events(session: Session) -> None:
|
||||
transaction = session.get_nested_transaction() or session.get_transaction()
|
||||
if transaction is None:
|
||||
return
|
||||
pending_by_transaction = session.info.get(_PENDING_EVENTS_KEY)
|
||||
if not isinstance(pending_by_transaction, dict):
|
||||
return
|
||||
pending = pending_by_transaction.pop(transaction, ())
|
||||
parent = transaction.parent
|
||||
if parent is not None:
|
||||
pending_by_transaction.setdefault(parent, []).extend(pending)
|
||||
else:
|
||||
for bus, event in pending:
|
||||
bus.publish(event)
|
||||
if not pending_by_transaction:
|
||||
session.info.pop(_PENDING_EVENTS_KEY, None)
|
||||
|
||||
|
||||
@sqlalchemy_event.listens_for(Session, "after_rollback")
|
||||
def _discard_rolled_back_events(session: Session) -> None:
|
||||
transaction = session.get_nested_transaction() or session.get_transaction()
|
||||
pending_by_transaction = session.info.get(_PENDING_EVENTS_KEY)
|
||||
if transaction is None or not isinstance(pending_by_transaction, dict):
|
||||
return
|
||||
pending_by_transaction.pop(transaction, None)
|
||||
if transaction.parent is None or not pending_by_transaction:
|
||||
session.info.pop(_PENDING_EVENTS_KEY, None)
|
||||
|
||||
|
||||
def _compact_dict(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {key: item for key, item in value.items() if item is not None}
|
||||
|
||||
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"
|
||||
CAPABILITY_IDENTITY_DIRECTORY = "identity.directory"
|
||||
CAPABILITY_IDENTITY_SEARCH = "identity.search"
|
||||
|
||||
IdentityStatus = Literal["active", "inactive", "suspended"]
|
||||
|
||||
@@ -44,3 +45,15 @@ class IdentityDirectory(Protocol):
|
||||
|
||||
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 collections.abc import Sequence
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
@@ -8,6 +8,7 @@ from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
IDM_MODULE_ID = "idm"
|
||||
CAPABILITY_IDM_DIRECTORY = "idm.directory"
|
||||
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS = "idm.function_assignments"
|
||||
|
||||
IdmStatus = Literal["active", "inactive", "suspended"]
|
||||
OrganizationFunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
|
||||
@@ -30,6 +31,18 @@ class OrganizationFunctionAssignmentRef:
|
||||
status: IdmStatus = "active"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationFunctionIncumbencyRef:
|
||||
tenant_id: str
|
||||
function_id: str
|
||||
assignments: tuple[OrganizationFunctionAssignmentRef, ...] = ()
|
||||
function_active: bool = True
|
||||
|
||||
@property
|
||||
def vacant(self) -> bool:
|
||||
return not self.assignments
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IdmDirectory(Protocol):
|
||||
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
|
||||
@@ -40,6 +53,7 @@ class IdmDirectory(Protocol):
|
||||
identity_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at: datetime | None = None,
|
||||
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
||||
...
|
||||
|
||||
@@ -48,5 +62,47 @@ class IdmDirectory(Protocol):
|
||||
account_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at: datetime | None = None,
|
||||
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
||||
...
|
||||
|
||||
def organization_function_assignments_for_identities(
|
||||
self,
|
||||
identity_ids: Sequence[str],
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at: datetime | None = None,
|
||||
) -> Mapping[str, Sequence[OrganizationFunctionAssignmentRef]]:
|
||||
...
|
||||
|
||||
def organization_function_assignments_for_accounts(
|
||||
self,
|
||||
account_ids: Sequence[str],
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at: datetime | None = None,
|
||||
) -> Mapping[str, Sequence[OrganizationFunctionAssignmentRef]]:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IdmFunctionAssignmentDirectory(Protocol):
|
||||
"""Reverse lookup for effective incumbency and vacancy decisions."""
|
||||
|
||||
def organization_function_assignments_for_function(
|
||||
self,
|
||||
function_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at: datetime | None = None,
|
||||
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
||||
...
|
||||
|
||||
def organization_function_incumbencies(
|
||||
self,
|
||||
function_ids: Sequence[str],
|
||||
*,
|
||||
tenant_id: str,
|
||||
effective_at: datetime | None = None,
|
||||
) -> Mapping[str, OrganizationFunctionIncumbencyRef]:
|
||||
...
|
||||
|
||||
@@ -3,9 +3,11 @@ from __future__ import annotations
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from sqlalchemy.engine import make_url
|
||||
@@ -142,6 +144,7 @@ def validate_runtime_configuration(
|
||||
_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))
|
||||
|
||||
@@ -224,6 +227,14 @@ def _validate_cors_settings(env: Mapping[str, str], runtime: _RuntimeProfile, co
|
||||
collector.add("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.")
|
||||
elif runtime.production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
|
||||
collector.add("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.")
|
||||
trusted_hosts = _csv(env.get("GOVOPLAN_TRUSTED_HOSTS"))
|
||||
if runtime.production_like and not trusted_hosts:
|
||||
collector.add("error", "GOVOPLAN_TRUSTED_HOSTS", "Trusted HTTP hosts are not configured.", "Set GOVOPLAN_TRUSTED_HOSTS to the exact API host names accepted by this deployment.")
|
||||
elif "*" in trusted_hosts and runtime.production_like:
|
||||
collector.add("error", "GOVOPLAN_TRUSTED_HOSTS", "Wildcard trusted hosts are not allowed for production-like installs.", "Replace `*` with exact host names or narrowly scoped `*.example.org` entries.")
|
||||
forwarded_allow_ips = _csv(env.get("FORWARDED_ALLOW_IPS"))
|
||||
if runtime.production_like and "*" in forwarded_allow_ips:
|
||||
collector.add("error", "FORWARDED_ALLOW_IPS", "Proxy headers must not be trusted from every address.", "Set FORWARDED_ALLOW_IPS to the reverse proxy address or network passed to Uvicorn.")
|
||||
|
||||
|
||||
def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||
@@ -249,6 +260,59 @@ def _validate_s3_file_storage(env: Mapping[str, str], collector: _ConfigIssueCol
|
||||
collector.add("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
|
||||
|
||||
|
||||
def _validate_outbound_connector_policy(
|
||||
env: Mapping[str, str],
|
||||
runtime: _RuntimeProfile,
|
||||
collector: _ConfigIssueCollector,
|
||||
) -> None:
|
||||
private_networks = _clean(env.get("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS")).lower()
|
||||
if runtime.production_like and private_networks not in {"true", "false", "1", "0", "yes", "no", "on", "off"}:
|
||||
collector.add(
|
||||
"error",
|
||||
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
|
||||
"Private-network connector access must be an explicit deployment decision.",
|
||||
"Set GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false for public-only egress, or true when this deployment must reach internal services.",
|
||||
)
|
||||
for key in (
|
||||
"GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
|
||||
"GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES",
|
||||
"GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES",
|
||||
):
|
||||
value = _clean(env.get(key))
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
parsed = int(value)
|
||||
except ValueError:
|
||||
parsed = 0
|
||||
if parsed <= 0:
|
||||
collector.add("error", key, f"{key} must be a positive byte count.", "Use a positive integer byte limit.")
|
||||
secret_env_names = [
|
||||
item.strip()
|
||||
for item in env.get("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST", "").split(",")
|
||||
if item.strip()
|
||||
]
|
||||
if any(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", item) is None for item in secret_env_names):
|
||||
collector.add(
|
||||
"error",
|
||||
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
|
||||
"Connector secret environment allowlist contains an invalid variable name.",
|
||||
"Use a comma-separated list of exact environment variable names, or leave the setting empty.",
|
||||
)
|
||||
ca_bundle_paths = [
|
||||
item.strip()
|
||||
for item in env.get("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST", "").split(",")
|
||||
if item.strip()
|
||||
]
|
||||
if any(not Path(item).is_absolute() for item in ca_bundle_paths):
|
||||
collector.add(
|
||||
"error",
|
||||
"GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST",
|
||||
"Connector CA bundle allowlist contains a non-absolute path.",
|
||||
"Use comma-separated absolute paths mounted on every API and connector worker, or leave the setting empty.",
|
||||
)
|
||||
|
||||
|
||||
def _validate_module_catalog_trust(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG"))
|
||||
if not runtime.production or not catalog_source:
|
||||
@@ -274,9 +338,34 @@ ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,aud
|
||||
|
||||
CELERY_ENABLED=true
|
||||
REDIS_URL=redis://127.0.0.1:6379/0
|
||||
CELERY_QUEUES=send_email,append_sent,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
|
||||
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_SAMESITE=lax
|
||||
AUTH_COOKIE_DOMAIN=
|
||||
@@ -317,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
|
||||
REDIS_URL=redis://127.0.0.1:56379/0
|
||||
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
|
||||
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
|
||||
FILE_STORAGE_BACKEND=local
|
||||
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
|
||||
|
||||
@@ -67,6 +67,17 @@ class ModuleLifecycleManager:
|
||||
def mounted_module_ids(self) -> tuple[str, ...]:
|
||||
return tuple(sorted(self._mounted_modules))
|
||||
|
||||
def live_apply_enabled(self) -> bool:
|
||||
configured = getattr(
|
||||
self.settings,
|
||||
"module_live_apply_enabled",
|
||||
None,
|
||||
)
|
||||
if configured is not None:
|
||||
return bool(configured)
|
||||
app_env = str(getattr(self.settings, "app_env", "dev")).casefold()
|
||||
return app_env in {"dev", "development", "local", "test", "testing"}
|
||||
|
||||
def apply_enabled_modules(
|
||||
self,
|
||||
requested_enabled: Sequence[str],
|
||||
|
||||
30
src/govoplan_core/core/mail.py
Normal file
30
src/govoplan_core/core/mail.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
CAPABILITY_MAIL_DELIVERY_OUTBOX = "mail.delivery_outbox"
|
||||
|
||||
|
||||
@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]:
|
||||
...
|
||||
@@ -79,15 +79,19 @@ def drop_table_retirement_provider(
|
||||
warnings.append("Tables not present and therefore skipped: " + ", ".join(missing_names))
|
||||
|
||||
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.")
|
||||
execute_bind = execute_session.get_bind() # type: ignore[attr-defined]
|
||||
live_inspector = inspect(execute_bind)
|
||||
# Enlist schema retirement in the caller's active transaction. An
|
||||
# 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)]
|
||||
if not live_tables:
|
||||
return
|
||||
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(
|
||||
supported=True,
|
||||
|
||||
@@ -15,7 +15,8 @@ import re
|
||||
import shlex
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import stat
|
||||
import subprocess # nosec B404 - installer commands are structured and policy-validated before execution.
|
||||
import sys
|
||||
import tomllib
|
||||
from typing import Any, Literal
|
||||
@@ -579,7 +580,7 @@ def _prepare_module_install_run(
|
||||
) -> _ModuleInstallRunState:
|
||||
run_id = _run_id()
|
||||
run_dir = effective_runtime_dir / "runs" / run_id
|
||||
run_dir.mkdir(parents=True, exist_ok=False)
|
||||
_create_private_installer_run_dir(run_dir)
|
||||
commands = structured_install_commands(
|
||||
plan,
|
||||
webui_root=webui_root,
|
||||
@@ -2445,8 +2446,8 @@ def _migration_provider_modules(
|
||||
target_metadata: Mapping[str, Mapping[str, object]],
|
||||
) -> dict[str, tuple[tuple[str, str], ...]]:
|
||||
providers: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
||||
for module_id, metadata in target_metadata.items():
|
||||
for provided in metadata.get("provides_interfaces", ()):
|
||||
for module_id, module_metadata in target_metadata.items():
|
||||
for provided in module_metadata.get("provides_interfaces", ()):
|
||||
if not isinstance(provided, Mapping):
|
||||
continue
|
||||
name = provided.get("name") if isinstance(provided.get("name"), str) else None
|
||||
@@ -3327,12 +3328,6 @@ def _wait_for_health_urls(urls: Iterable[str], *, timeout_seconds: float, interv
|
||||
return payload
|
||||
|
||||
|
||||
def _run_restart_command_legacy(command: str | None) -> dict[str, object] | None:
|
||||
if not command:
|
||||
return None
|
||||
return _run_restart_command(command)
|
||||
|
||||
|
||||
def _planned_python_install_packages(record: Mapping[str, object]) -> tuple[str, ...]:
|
||||
raw_plan = record.get("plan")
|
||||
if not isinstance(raw_plan, list):
|
||||
@@ -3828,14 +3823,47 @@ def _restore_external_database_snapshot(
|
||||
def _write_installer_secret(path: Path, value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
path.write_text(value, encoding="utf-8")
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
flags |= getattr(os, "O_CLOEXEC", 0)
|
||||
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
path.chmod(0o600)
|
||||
descriptor = os.open(path, flags, 0o600)
|
||||
except OSError as exc:
|
||||
logger.debug("Could not restrict installer secret file permissions for %s: %s", path, exc, exc_info=True)
|
||||
raise ModuleInstallerError(f"Could not create private installer secret file: {path.name}") from exc
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
||||
handle.write(value)
|
||||
except OSError as exc:
|
||||
path.unlink(missing_ok=True)
|
||||
raise ModuleInstallerError(f"Could not write private installer secret file: {path.name}") from exc
|
||||
|
||||
try:
|
||||
mode = stat.S_IMODE(path.stat().st_mode)
|
||||
except OSError as exc:
|
||||
path.unlink(missing_ok=True)
|
||||
raise ModuleInstallerError(f"Could not verify private installer secret file: {path.name}") from exc
|
||||
if mode != 0o600:
|
||||
path.unlink(missing_ok=True)
|
||||
raise ModuleInstallerError(f"Installer secret file permissions are not private: {path.name}")
|
||||
return path.name
|
||||
|
||||
|
||||
def _create_private_installer_run_dir(path: Path) -> None:
|
||||
path.mkdir(parents=True, mode=0o700, exist_ok=False)
|
||||
try:
|
||||
# mkdir's requested mode is still reduced by the process umask. Set the
|
||||
# exact owner-only mode so the directory remains usable with a strict
|
||||
# deployment umask while never retaining group/other access.
|
||||
path.chmod(0o700)
|
||||
mode = stat.S_IMODE(path.stat().st_mode)
|
||||
except OSError as exc:
|
||||
path.rmdir()
|
||||
raise ModuleInstallerError("Could not secure the installer run directory") from exc
|
||||
if mode != 0o700:
|
||||
path.rmdir()
|
||||
raise ModuleInstallerError("Installer run directory permissions are not exactly owner-only")
|
||||
|
||||
|
||||
def _database_url_from_external_snapshot(run_dir: Path, raw: Mapping[str, object]) -> str | None:
|
||||
secret_name = raw.get("database_url_secret")
|
||||
if isinstance(secret_name, str) and secret_name:
|
||||
|
||||
@@ -106,7 +106,8 @@ def installer_notification_body(event_kind: str, request: Mapping[str, object])
|
||||
run_id = _result_run_id(request)
|
||||
if run_id:
|
||||
return ". Run: ".join((sentence, run_id))
|
||||
return sentence + "."
|
||||
# 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:
|
||||
|
||||
@@ -6,7 +6,6 @@ from datetime import UTC, datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID, get_system_settings
|
||||
from govoplan_core.core.access import DEFAULT_CAPABILITY_PROVIDERS
|
||||
from govoplan_core.core.discovery import iter_module_entry_points
|
||||
from govoplan_core.core.modules import ModuleManifest
|
||||
from govoplan_core.db.session import get_database
|
||||
@@ -17,8 +18,8 @@ from govoplan_core.server.registry import parse_enabled_modules
|
||||
|
||||
MODULE_SETTINGS_KEY = "module_management"
|
||||
INSTALL_PLAN_KEY = "install_plan"
|
||||
REQUIRED_PLATFORM_MODULES = ("access",)
|
||||
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin")
|
||||
REQUIRED_PLATFORM_MODULES: tuple[str, ...] = ()
|
||||
PROTECTED_MODULES = ("admin",)
|
||||
INSTALL_PLAN_ACTIONS = ("install", "update", "uninstall")
|
||||
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
|
||||
INSTALL_PLAN_SOURCES = ("manual", "catalog")
|
||||
@@ -109,6 +110,7 @@ def startup_candidate_module_ids(
|
||||
if desired is not None:
|
||||
candidates.extend(str(item).strip() for item in desired if str(item).strip())
|
||||
candidates.extend(REQUIRED_PLATFORM_MODULES)
|
||||
candidates.extend(DEFAULT_CAPABILITY_PROVIDERS.values())
|
||||
if "admin" in fallback:
|
||||
candidates.append("admin")
|
||||
return tuple(dict.fromkeys(candidates))
|
||||
@@ -404,46 +406,148 @@ def plan_desired_enabled_modules(
|
||||
*,
|
||||
protected_modules: Iterable[str] = PROTECTED_MODULES,
|
||||
) -> ModuleStatePlan:
|
||||
requested = {str(item).strip() for item in requested_enabled if str(item).strip()}
|
||||
protected = {str(item).strip() for item in protected_modules if str(item).strip()}
|
||||
requested.update(protected)
|
||||
requested = _normalized_module_ids(requested_enabled)
|
||||
requested.update(_normalized_module_ids(protected_modules))
|
||||
missing = sorted(module_id for module_id in requested if module_id not in available)
|
||||
if missing:
|
||||
raise ModuleManagementError("Unknown or uninstalled modules: " + ", ".join(missing))
|
||||
|
||||
added_dependencies: set[str] = set()
|
||||
visiting: set[str] = set()
|
||||
visited: set[str] = set()
|
||||
ordered: list[str] = []
|
||||
|
||||
def visit(module_id: str) -> None:
|
||||
if module_id in visited:
|
||||
return
|
||||
if module_id in visiting:
|
||||
raise ModuleManagementError(f"Module dependency cycle includes {module_id!r}.")
|
||||
manifest = available.get(module_id)
|
||||
if manifest is None:
|
||||
raise ModuleManagementError(f"Module {module_id!r} is required but not installed.")
|
||||
visiting.add(module_id)
|
||||
for dependency_id in manifest.dependencies:
|
||||
if dependency_id not in available:
|
||||
raise ModuleManagementError(f"Module {module_id!r} depends on uninstalled module {dependency_id!r}.")
|
||||
if dependency_id not in requested:
|
||||
added_dependencies.add(dependency_id)
|
||||
requested.add(dependency_id)
|
||||
visit(dependency_id)
|
||||
visiting.remove(module_id)
|
||||
visited.add(module_id)
|
||||
ordered.append(module_id)
|
||||
|
||||
for module_id in sorted(requested):
|
||||
visit(module_id)
|
||||
added_dependencies = _expand_enabled_module_closure(requested, available)
|
||||
ordered = _order_enabled_modules(requested, available, added_dependencies)
|
||||
return ModuleStatePlan(
|
||||
enabled_modules=tuple(dict.fromkeys(ordered)),
|
||||
added_dependencies=tuple(sorted(added_dependencies)),
|
||||
)
|
||||
|
||||
|
||||
def _normalized_module_ids(values: Iterable[str]) -> set[str]:
|
||||
return {str(item).strip() for item in values if str(item).strip()}
|
||||
|
||||
|
||||
def _expand_enabled_module_closure(
|
||||
requested: set[str],
|
||||
available: Mapping[str, ModuleManifest],
|
||||
) -> set[str]:
|
||||
added: set[str] = set()
|
||||
while True:
|
||||
dependencies = {
|
||||
dependency
|
||||
for module_id in requested
|
||||
for dependency in available[module_id].dependencies
|
||||
}
|
||||
_require_installed_modules(
|
||||
dependencies,
|
||||
available,
|
||||
message="Required modules are not installed: ",
|
||||
)
|
||||
dependency_additions = dependencies - requested
|
||||
added.update(dependency_additions)
|
||||
requested.update(dependency_additions)
|
||||
|
||||
providers = _missing_capability_providers(requested, available)
|
||||
_require_installed_modules(
|
||||
providers,
|
||||
available,
|
||||
message="Required capability providers are not installed: ",
|
||||
)
|
||||
additions = providers - requested
|
||||
if not additions:
|
||||
return added
|
||||
added.update(additions)
|
||||
requested.update(additions)
|
||||
|
||||
|
||||
def _missing_capability_providers(
|
||||
requested: set[str],
|
||||
available: Mapping[str, ModuleManifest],
|
||||
) -> set[str]:
|
||||
provided = {
|
||||
capability
|
||||
for module_id in requested
|
||||
for capability in available[module_id].capability_factories
|
||||
}
|
||||
return {
|
||||
provider_id
|
||||
for module_id in requested
|
||||
for capability in available[module_id].required_capabilities
|
||||
if capability not in provided
|
||||
for provider_id in (DEFAULT_CAPABILITY_PROVIDERS.get(capability),)
|
||||
if provider_id is not None
|
||||
}
|
||||
|
||||
|
||||
def _require_installed_modules(
|
||||
module_ids: set[str],
|
||||
available: Mapping[str, ModuleManifest],
|
||||
*,
|
||||
message: str,
|
||||
) -> None:
|
||||
missing = sorted(module_id for module_id in module_ids if module_id not in available)
|
||||
if missing:
|
||||
raise ModuleManagementError(message + ", ".join(missing))
|
||||
|
||||
|
||||
def _order_enabled_modules(
|
||||
requested: set[str],
|
||||
available: Mapping[str, ModuleManifest],
|
||||
added_dependencies: set[str],
|
||||
) -> list[str]:
|
||||
visiting: set[str] = set()
|
||||
visited: set[str] = set()
|
||||
ordered: list[str] = []
|
||||
for module_id in sorted(requested):
|
||||
_visit_enabled_module(
|
||||
module_id,
|
||||
requested=requested,
|
||||
available=available,
|
||||
added_dependencies=added_dependencies,
|
||||
visiting=visiting,
|
||||
visited=visited,
|
||||
ordered=ordered,
|
||||
)
|
||||
return ordered
|
||||
|
||||
|
||||
def _visit_enabled_module(
|
||||
module_id: str,
|
||||
*,
|
||||
requested: set[str],
|
||||
available: Mapping[str, ModuleManifest],
|
||||
added_dependencies: set[str],
|
||||
visiting: set[str],
|
||||
visited: set[str],
|
||||
ordered: list[str],
|
||||
) -> None:
|
||||
if module_id in visited:
|
||||
return
|
||||
if module_id in visiting:
|
||||
raise ModuleManagementError(f"Module dependency cycle includes {module_id!r}.")
|
||||
manifest = available.get(module_id)
|
||||
if manifest is None:
|
||||
raise ModuleManagementError(f"Module {module_id!r} is required but not installed.")
|
||||
visiting.add(module_id)
|
||||
for dependency_id in manifest.dependencies:
|
||||
if dependency_id not in available:
|
||||
raise ModuleManagementError(
|
||||
f"Module {module_id!r} depends on uninstalled module {dependency_id!r}."
|
||||
)
|
||||
if dependency_id not in requested:
|
||||
added_dependencies.add(dependency_id)
|
||||
requested.add(dependency_id)
|
||||
_visit_enabled_module(
|
||||
dependency_id,
|
||||
requested=requested,
|
||||
available=available,
|
||||
added_dependencies=added_dependencies,
|
||||
visiting=visiting,
|
||||
visited=visited,
|
||||
ordered=ordered,
|
||||
)
|
||||
visiting.remove(module_id)
|
||||
visited.add(module_id)
|
||||
ordered.append(module_id)
|
||||
|
||||
|
||||
def module_dependents(available: Mapping[str, ModuleManifest]) -> dict[str, tuple[str, ...]]:
|
||||
dependents: dict[str, list[str]] = {module_id: [] for module_id in available}
|
||||
for manifest in available.values():
|
||||
|
||||
@@ -4,8 +4,14 @@ from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal, Protocol, TYPE_CHECKING
|
||||
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import APIRouter
|
||||
from govoplan_core.core.search import (
|
||||
SearchProviderRegistration,
|
||||
SearchSourceProviderRegistration,
|
||||
)
|
||||
|
||||
|
||||
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
|
||||
@@ -45,6 +51,7 @@ class RoleTemplate:
|
||||
level: PermissionLevel = "tenant"
|
||||
managed: bool = True
|
||||
protected: bool = False
|
||||
default_authenticated: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -56,6 +63,7 @@ class NavItem:
|
||||
required_all: tuple[str, ...] = ()
|
||||
required_any: tuple[str, ...] = ()
|
||||
order: int = 100
|
||||
surface_id: str | None = None
|
||||
|
||||
|
||||
|
||||
@@ -67,6 +75,16 @@ class FrontendRoute:
|
||||
required_all: tuple[str, ...] = ()
|
||||
required_any: tuple[str, ...] = ()
|
||||
order: int = 100
|
||||
surface_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PublicFrontendRoute:
|
||||
"""Explicitly allowlisted route that can render without authentication."""
|
||||
|
||||
path: str
|
||||
component: str
|
||||
order: int = 100
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -79,8 +97,10 @@ class FrontendModule:
|
||||
asset_manifest_integrity: str | None = None
|
||||
asset_manifest_contract_version: str = "1"
|
||||
routes: tuple[FrontendRoute, ...] = ()
|
||||
public_routes: tuple[PublicFrontendRoute, ...] = ()
|
||||
nav_items: tuple[NavItem, ...] = ()
|
||||
settings_routes: tuple[FrontendRoute, ...] = ()
|
||||
view_surfaces: tuple[ViewSurface, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -195,6 +215,17 @@ class ModuleContext:
|
||||
DocumentationLayer = Literal["always", "configured", "available", "evidence"]
|
||||
DocumentationLinkKind = Literal["runtime", "api", "repository", "wiki", "public"]
|
||||
DocumentationType = Literal["admin", "user"]
|
||||
DocumentationConfigurationState = Literal["enabled", "disabled", "inherited", "unavailable"]
|
||||
DocumentationSourceKind = Literal[
|
||||
"manifest",
|
||||
"route",
|
||||
"capability",
|
||||
"policy",
|
||||
"configuration_package",
|
||||
"wiki",
|
||||
"repository",
|
||||
]
|
||||
DocumentationSourceState = Literal["configured", "disabled", "unavailable"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -236,6 +267,35 @@ class DocumentationTopic:
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str, ...]:
|
||||
"""Return fail-closed authoring issues for a user-facing workflow topic.
|
||||
|
||||
Topic conditions are alternatives. Every alternative therefore needs an
|
||||
explicit scope constraint; otherwise one unscoped alternative would make
|
||||
the workflow visible regardless of the actor's task authority.
|
||||
"""
|
||||
|
||||
raw_kind = topic.metadata.get("kind")
|
||||
kind = raw_kind.strip().lower().replace("_", "-") if isinstance(raw_kind, str) else ""
|
||||
if kind != "workflow" or "user" not in topic.documentation_types:
|
||||
return ()
|
||||
if not topic.conditions:
|
||||
return ("user workflow topics must declare at least one scope-conditioned alternative",)
|
||||
|
||||
unscoped_alternatives = tuple(
|
||||
index
|
||||
for index, condition in enumerate(topic.conditions, start=1)
|
||||
if not any(scope.strip() for scope in (*condition.required_scopes, *condition.any_scopes))
|
||||
)
|
||||
if not unscoped_alternatives:
|
||||
return ()
|
||||
alternatives = ", ".join(str(index) for index in unscoped_alternatives)
|
||||
return (
|
||||
"every user workflow condition alternative must declare required_scopes or any_scopes; "
|
||||
f"unscoped alternative(s): {alternatives}",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DocumentationContext:
|
||||
registry: object
|
||||
@@ -247,6 +307,40 @@ class DocumentationContext:
|
||||
data: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DocumentationConfigurationDecision:
|
||||
key: str
|
||||
state: DocumentationConfigurationState
|
||||
source: str | None = None
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
DocumentationConfigurationResolver = Callable[
|
||||
[DocumentationContext, tuple[str, ...]],
|
||||
Mapping[str, DocumentationConfigurationDecision],
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DocumentationConfigurationProviderRegistration:
|
||||
keys: tuple[str, ...]
|
||||
resolve: DocumentationConfigurationResolver
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DocumentationSourceDefinition:
|
||||
id: str
|
||||
kind: DocumentationSourceKind
|
||||
label: str
|
||||
documentation_types: tuple[DocumentationType, ...] = ("admin",)
|
||||
condition: DocumentationCondition = field(default_factory=DocumentationCondition)
|
||||
state: DocumentationSourceState = "configured"
|
||||
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):
|
||||
resource_type: str
|
||||
|
||||
@@ -261,6 +355,10 @@ class ResourceAclProvider(Protocol):
|
||||
|
||||
|
||||
TenantSummaryProvider = Callable[[object, str], Mapping[str, int]]
|
||||
TenantSummaryBatchProvider = Callable[
|
||||
[object, Sequence[str]],
|
||||
Mapping[str, Mapping[str, int]],
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -308,11 +406,19 @@ class ModuleManifest:
|
||||
frontend: FrontendModule | None = None
|
||||
resource_acl_providers: tuple[ResourceAclProvider, ...] = ()
|
||||
tenant_summary_providers: tuple[TenantSummaryProvider, ...] = ()
|
||||
tenant_summary_batch_providers: tuple[TenantSummaryBatchProvider, ...] = ()
|
||||
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
|
||||
uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = ()
|
||||
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
|
||||
search_providers: tuple["SearchProviderRegistration", ...] = ()
|
||||
search_sources: tuple["SearchSourceProviderRegistration", ...] = ()
|
||||
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
|
||||
on_activate: LifecycleHook | None = None
|
||||
on_deactivate: LifecycleHook | None = None
|
||||
documentation: tuple[DocumentationTopic, ...] = ()
|
||||
documentation_providers: tuple[DocumentationProvider, ...] = ()
|
||||
documentation_configuration_providers: tuple[
|
||||
DocumentationConfigurationProviderRegistration,
|
||||
...,
|
||||
] = ()
|
||||
documentation_sources: tuple[DocumentationSourceDefinition, ...] = ()
|
||||
|
||||
@@ -2,13 +2,57 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
ORGANIZATIONS_MODULE_ID = "organizations"
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY = "organizations.directory"
|
||||
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY = (
|
||||
"organizations.hierarchyDirectory"
|
||||
)
|
||||
|
||||
OrganizationStatus = Literal["active", "inactive", "suspended"]
|
||||
OrganizationResolutionStatus = Literal[
|
||||
"active",
|
||||
"inactive",
|
||||
"missing",
|
||||
"unreachable",
|
||||
"invalid",
|
||||
]
|
||||
OrganizationHierarchyDirection = Literal["ancestors", "descendants"]
|
||||
OrganizationLifecycleResource = Literal[
|
||||
"unit_type",
|
||||
"structure",
|
||||
"relation_type",
|
||||
"unit",
|
||||
"relation",
|
||||
"function_type",
|
||||
"function",
|
||||
]
|
||||
OrganizationLifecycleAction = Literal[
|
||||
"created",
|
||||
"updated",
|
||||
"moved",
|
||||
"deactivated",
|
||||
]
|
||||
ORGANIZATION_LIFECYCLE_EVENT_SCHEMA_VERSION = 1
|
||||
ORGANIZATION_LIFECYCLE_RESOURCES: tuple[
|
||||
OrganizationLifecycleResource,
|
||||
...,
|
||||
] = (
|
||||
"unit_type",
|
||||
"structure",
|
||||
"relation_type",
|
||||
"unit",
|
||||
"relation",
|
||||
"function_type",
|
||||
"function",
|
||||
)
|
||||
ORGANIZATION_LIFECYCLE_ACTIONS: tuple[
|
||||
OrganizationLifecycleAction,
|
||||
...,
|
||||
] = ("created", "updated", "moved", "deactivated")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -37,6 +81,141 @@ class OrganizationFunctionRef:
|
||||
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
|
||||
class OrganizationDirectory(Protocol):
|
||||
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
|
||||
@@ -55,3 +234,156 @@ class OrganizationDirectory(Protocol):
|
||||
include_subunits: bool = False,
|
||||
) -> Sequence[OrganizationFunctionRef]:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class OrganizationHierarchyDirectory(Protocol):
|
||||
def hierarchy_catalog(
|
||||
self,
|
||||
tenant_id: str,
|
||||
) -> OrganizationHierarchyCatalogRef:
|
||||
...
|
||||
|
||||
def get_unit_type(
|
||||
self,
|
||||
tenant_id: str,
|
||||
unit_type_id: str,
|
||||
) -> OrganizationUnitTypeRef | None:
|
||||
...
|
||||
|
||||
def get_function_type(
|
||||
self,
|
||||
tenant_id: str,
|
||||
function_type_id: str,
|
||||
) -> OrganizationFunctionTypeRef | None:
|
||||
...
|
||||
|
||||
def resolve_functions_by_type(
|
||||
self,
|
||||
tenant_id: str,
|
||||
function_type_id: str,
|
||||
*,
|
||||
organization_unit_ids: Sequence[str] = (),
|
||||
) -> OrganizationFunctionTypeResolution:
|
||||
...
|
||||
|
||||
def resolve_units_by_type(
|
||||
self,
|
||||
tenant_id: str,
|
||||
unit_type_id: str,
|
||||
*,
|
||||
structure_id: str | None = None,
|
||||
root_unit_id: str | None = None,
|
||||
relation_type_ids: Sequence[str] = (),
|
||||
direction: OrganizationHierarchyDirection = "descendants",
|
||||
max_depth: int = 10,
|
||||
) -> OrganizationUnitTypeResolution:
|
||||
...
|
||||
|
||||
def resolve_hierarchy_relatives(
|
||||
self,
|
||||
tenant_id: str,
|
||||
organization_unit_ids: Sequence[str],
|
||||
*,
|
||||
structure_id: str,
|
||||
relation_type_ids: Sequence[str] = (),
|
||||
direction: OrganizationHierarchyDirection = "ancestors",
|
||||
max_depth: int = 10,
|
||||
) -> Sequence[OrganizationHierarchyResolution]:
|
||||
...
|
||||
|
||||
def resolve_hierarchy_paths(
|
||||
self,
|
||||
tenant_id: str,
|
||||
unit_pairs: Sequence[tuple[str, str]],
|
||||
*,
|
||||
structure_id: str,
|
||||
relation_type_ids: Sequence[str] = (),
|
||||
direction: OrganizationHierarchyDirection = "descendants",
|
||||
max_depth: int = 10,
|
||||
) -> Sequence[OrganizationHierarchyPathResolution]:
|
||||
...
|
||||
|
||||
|
||||
def organization_directory(
|
||||
registry: object | None,
|
||||
) -> OrganizationDirectory | None:
|
||||
capability = _capability(registry, CAPABILITY_ORGANIZATION_DIRECTORY)
|
||||
return capability if isinstance(capability, OrganizationDirectory) else None
|
||||
|
||||
|
||||
def organization_hierarchy_directory(
|
||||
registry: object | None,
|
||||
) -> OrganizationHierarchyDirectory | None:
|
||||
capability = _capability(
|
||||
registry,
|
||||
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY,
|
||||
)
|
||||
return (
|
||||
capability
|
||||
if isinstance(capability, OrganizationHierarchyDirectory)
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def organization_lifecycle_event_type(
|
||||
resource: OrganizationLifecycleResource,
|
||||
action: OrganizationLifecycleAction,
|
||||
) -> str:
|
||||
if resource not in ORGANIZATION_LIFECYCLE_RESOURCES:
|
||||
raise ValueError("Unsupported organization lifecycle resource.")
|
||||
if action not in ORGANIZATION_LIFECYCLE_ACTIONS:
|
||||
raise ValueError("Unsupported organization lifecycle action.")
|
||||
return f"organizations.{resource}.{action}.v1"
|
||||
|
||||
|
||||
ORGANIZATION_DIRECTORY_INVALIDATION_EVENT_TYPES = frozenset(
|
||||
organization_lifecycle_event_type(resource, action)
|
||||
for resource in ORGANIZATION_LIFECYCLE_RESOURCES
|
||||
for action in ORGANIZATION_LIFECYCLE_ACTIONS
|
||||
)
|
||||
|
||||
|
||||
def _capability(registry: object | None, name: str) -> object | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(name)
|
||||
):
|
||||
return None
|
||||
return registry.capability(name)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_ORGANIZATION_DIRECTORY",
|
||||
"CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY",
|
||||
"ORGANIZATION_DIRECTORY_INVALIDATION_EVENT_TYPES",
|
||||
"ORGANIZATION_LIFECYCLE_ACTIONS",
|
||||
"ORGANIZATION_LIFECYCLE_EVENT_SCHEMA_VERSION",
|
||||
"ORGANIZATION_LIFECYCLE_RESOURCES",
|
||||
"ORGANIZATIONS_MODULE_ID",
|
||||
"OrganizationDirectory",
|
||||
"OrganizationFunctionRef",
|
||||
"OrganizationFunctionTypeRef",
|
||||
"OrganizationFunctionTypeResolution",
|
||||
"OrganizationHierarchyDirection",
|
||||
"OrganizationHierarchyCatalogRef",
|
||||
"OrganizationHierarchyDirectory",
|
||||
"OrganizationHierarchyEdgeRef",
|
||||
"OrganizationHierarchyMatchRef",
|
||||
"OrganizationHierarchyPathResolution",
|
||||
"OrganizationHierarchyResolution",
|
||||
"OrganizationLifecycleAction",
|
||||
"OrganizationLifecycleResource",
|
||||
"OrganizationRelationTypeRef",
|
||||
"OrganizationResolutionStatus",
|
||||
"OrganizationStatus",
|
||||
"OrganizationStructureRef",
|
||||
"OrganizationUnitRef",
|
||||
"OrganizationUnitTypeRef",
|
||||
"OrganizationUnitTypeResolution",
|
||||
"organization_directory",
|
||||
"organization_hierarchy_directory",
|
||||
"organization_lifecycle_event_type",
|
||||
]
|
||||
|
||||
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 urllib.parse import quote, unquote
|
||||
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
|
||||
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
|
||||
SchedulingParticipantVisibility = Literal["aggregates_only", "names_and_statuses"]
|
||||
DefinitionScopeType = Literal["system", "tenant", "group", "user"]
|
||||
DefinitionKind = Literal["flow", "template"]
|
||||
DefinitionGovernanceAction = Literal[
|
||||
"view",
|
||||
"edit",
|
||||
"run",
|
||||
"reuse",
|
||||
"derive",
|
||||
"automate",
|
||||
]
|
||||
|
||||
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")
|
||||
|
||||
@@ -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
|
||||
class PrivacyRetentionService(Protocol):
|
||||
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 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,29 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
PublicFrontendRoute,
|
||||
ResourceAclProvider,
|
||||
RoleTemplate,
|
||||
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
|
||||
SUPPORTED_MANIFEST_CONTRACT_VERSION,
|
||||
TenantSummaryBatchProvider,
|
||||
TenantSummaryProvider,
|
||||
user_workflow_scope_condition_issues,
|
||||
)
|
||||
from govoplan_core.core.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_]*$")
|
||||
_NPM_PACKAGE_RE = re.compile(r"^(?:@[a-z0-9][a-z0-9_.-]*/)?[a-z0-9][a-z0-9_.-]*$")
|
||||
@@ -47,10 +63,17 @@ class PlatformRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._manifests: dict[str, ModuleManifest] = {}
|
||||
self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {}
|
||||
self._tenant_summary_batch_providers: dict[str, TenantSummaryBatchProvider] = {}
|
||||
self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = defaultdict(list)
|
||||
self._capability_factories: dict[str, CapabilityFactory] = {}
|
||||
self._capabilities: dict[str, object] = {}
|
||||
self._capability_context: ModuleContext | None = None
|
||||
self._search_provider_registrations: list[RegisteredSearchProvider] = []
|
||||
self._search_providers: dict[str, SearchProvider] = {}
|
||||
self._search_source_registrations: list[
|
||||
RegisteredSearchSourceProvider
|
||||
] = []
|
||||
self._search_sources: dict[str, SearchSourceProvider] = {}
|
||||
|
||||
def register(self, manifest: ModuleManifest) -> ModuleManifest:
|
||||
if manifest.id in self._manifests:
|
||||
@@ -58,11 +81,27 @@ class PlatformRegistry:
|
||||
self._manifests[manifest.id] = manifest
|
||||
for provider in manifest.tenant_summary_providers:
|
||||
self.register_tenant_summary_provider(manifest.id, provider)
|
||||
for provider in manifest.tenant_summary_batch_providers:
|
||||
self.register_tenant_summary_batch_provider(manifest.id, provider)
|
||||
for resource_type, providers in manifest.delete_veto_providers.items():
|
||||
for provider in providers:
|
||||
self.register_delete_veto(manifest.id, resource_type, provider)
|
||||
for name, factory in manifest.capability_factories.items():
|
||||
self.register_capability_factory(manifest.id, name, factory)
|
||||
for registration in manifest.search_providers:
|
||||
self._search_provider_registrations.append(
|
||||
RegisteredSearchProvider(
|
||||
module_id=manifest.id,
|
||||
registration=registration,
|
||||
)
|
||||
)
|
||||
for registration in manifest.search_sources:
|
||||
self._search_source_registrations.append(
|
||||
RegisteredSearchSourceProvider(
|
||||
module_id=manifest.id,
|
||||
registration=registration,
|
||||
)
|
||||
)
|
||||
return manifest
|
||||
|
||||
def replace(self, manifests: Iterable[ModuleManifest]) -> RegistrySnapshot:
|
||||
@@ -75,12 +114,23 @@ class PlatformRegistry:
|
||||
|
||||
self._manifests = dict(replacement._manifests)
|
||||
self._tenant_summary_providers = dict(replacement._tenant_summary_providers)
|
||||
self._tenant_summary_batch_providers = dict(
|
||||
replacement._tenant_summary_batch_providers
|
||||
)
|
||||
self._delete_veto_providers = defaultdict(list, {
|
||||
resource_type: list(providers)
|
||||
for resource_type, providers in replacement._delete_veto_providers.items()
|
||||
})
|
||||
self._capability_factories = dict(replacement._capability_factories)
|
||||
self._search_provider_registrations = list(
|
||||
replacement._search_provider_registrations
|
||||
)
|
||||
self._search_source_registrations = list(
|
||||
replacement._search_source_registrations
|
||||
)
|
||||
self._capabilities.clear()
|
||||
self._search_providers.clear()
|
||||
self._search_sources.clear()
|
||||
return snapshot
|
||||
|
||||
def get(self, module_id: str) -> ModuleManifest | None:
|
||||
@@ -111,12 +161,21 @@ class PlatformRegistry:
|
||||
def nav_items(self) -> tuple[NavItem, ...]:
|
||||
return tuple(sorted((item for manifest in self.manifests() for item in manifest.nav_items), key=lambda item: item.order))
|
||||
|
||||
def view_surfaces(self) -> tuple[ViewSurface, ...]:
|
||||
return tuple(
|
||||
surface
|
||||
for manifest in self.manifests()
|
||||
for surface in manifest_view_surfaces(manifest)
|
||||
)
|
||||
|
||||
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
|
||||
return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers)
|
||||
|
||||
def configure_capability_context(self, context: ModuleContext) -> None:
|
||||
self._capability_context = context
|
||||
self._capabilities.clear()
|
||||
self._search_providers.clear()
|
||||
self._search_sources.clear()
|
||||
|
||||
def register_capability_factory(self, module_id: str, name: str, factory: CapabilityFactory) -> None:
|
||||
if name in self._capability_factories:
|
||||
@@ -144,12 +203,95 @@ class PlatformRegistry:
|
||||
raise RegistryError(f"Required capability is not available: {name}")
|
||||
return capability
|
||||
|
||||
def search_provider_registrations(
|
||||
self,
|
||||
) -> tuple[RegisteredSearchProvider, ...]:
|
||||
return tuple(
|
||||
sorted(
|
||||
self._search_provider_registrations,
|
||||
key=lambda item: (
|
||||
item.registration.order,
|
||||
item.module_id,
|
||||
item.registration.id,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def search_providers(
|
||||
self,
|
||||
) -> tuple[tuple[RegisteredSearchProvider, SearchProvider], ...]:
|
||||
if self._capability_context is None:
|
||||
if self._search_provider_registrations:
|
||||
raise RegistryError("Search provider context is not configured.")
|
||||
return ()
|
||||
providers: list[tuple[RegisteredSearchProvider, SearchProvider]] = []
|
||||
for registered in self.search_provider_registrations():
|
||||
key = f"{registered.module_id}:{registered.registration.id}"
|
||||
provider = self._search_providers.get(key)
|
||||
if provider is None:
|
||||
provider = registered.registration.create(self._capability_context)
|
||||
self._search_providers[key] = provider
|
||||
providers.append((registered, provider))
|
||||
return tuple(providers)
|
||||
|
||||
def search_source_registrations(
|
||||
self,
|
||||
) -> tuple[RegisteredSearchSourceProvider, ...]:
|
||||
return tuple(
|
||||
sorted(
|
||||
self._search_source_registrations,
|
||||
key=lambda item: (
|
||||
item.registration.order,
|
||||
item.module_id,
|
||||
item.registration.id,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def search_sources(
|
||||
self,
|
||||
) -> tuple[
|
||||
tuple[RegisteredSearchSourceProvider, SearchSourceProvider],
|
||||
...,
|
||||
]:
|
||||
if self._capability_context is None:
|
||||
if self._search_source_registrations:
|
||||
raise RegistryError(
|
||||
"Search source context is not configured."
|
||||
)
|
||||
return ()
|
||||
providers: list[
|
||||
tuple[RegisteredSearchSourceProvider, SearchSourceProvider]
|
||||
] = []
|
||||
for registered in self.search_source_registrations():
|
||||
key = f"{registered.module_id}:{registered.registration.id}"
|
||||
provider = self._search_sources.get(key)
|
||||
if provider is None:
|
||||
provider = registered.registration.create(
|
||||
self._capability_context
|
||||
)
|
||||
self._search_sources[key] = provider
|
||||
providers.append((registered, provider))
|
||||
return tuple(providers)
|
||||
|
||||
def register_tenant_summary_provider(self, module_id: str, provider: TenantSummaryProvider) -> None:
|
||||
self._tenant_summary_providers[module_id] = provider
|
||||
|
||||
def tenant_summary_providers(self) -> Mapping[str, TenantSummaryProvider]:
|
||||
return dict(self._tenant_summary_providers)
|
||||
|
||||
def register_tenant_summary_batch_provider(
|
||||
self,
|
||||
module_id: str,
|
||||
provider: TenantSummaryBatchProvider,
|
||||
) -> None:
|
||||
self._tenant_summary_batch_providers[module_id] = provider
|
||||
|
||||
def tenant_summary_batch_providers(
|
||||
self,
|
||||
) -> Mapping[str, TenantSummaryBatchProvider]:
|
||||
return dict(self._tenant_summary_batch_providers)
|
||||
|
||||
def register_delete_veto(self, module_id: str, resource_type: str, provider: DeleteVetoProvider) -> None:
|
||||
self._delete_veto_providers[resource_type].append(DeleteVetoProviderRegistration(
|
||||
module_id=module_id,
|
||||
@@ -194,6 +336,7 @@ class PlatformRegistry:
|
||||
available_capabilities=available_capabilities,
|
||||
)
|
||||
permissions = _collect_manifest_permissions(ordered)
|
||||
_validate_public_frontend_route_uniqueness(ordered)
|
||||
_validate_interface_closure(ordered)
|
||||
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
|
||||
|
||||
@@ -227,6 +370,53 @@ class PlatformRegistry:
|
||||
return (self._manifests[module_id] for module_id in ordered)
|
||||
|
||||
|
||||
def manifest_view_surfaces(manifest: ModuleManifest) -> tuple[ViewSurface, ...]:
|
||||
frontend = manifest.frontend
|
||||
if frontend is None:
|
||||
return ()
|
||||
root_id = module_view_surface_id(manifest.id)
|
||||
surfaces = [
|
||||
ViewSurface(
|
||||
id=root_id,
|
||||
module_id=manifest.id,
|
||||
kind="module",
|
||||
label=manifest.name,
|
||||
order=min((item.order for item in frontend.nav_items), default=100),
|
||||
)
|
||||
]
|
||||
surfaces.extend(
|
||||
ViewSurface(
|
||||
id=item.surface_id or navigation_view_surface_id(manifest.id, item.path),
|
||||
module_id=manifest.id,
|
||||
kind="navigation",
|
||||
label=item.label,
|
||||
parent_id=root_id,
|
||||
order=item.order,
|
||||
)
|
||||
for item in frontend.nav_items
|
||||
)
|
||||
surfaces.extend(
|
||||
ViewSurface(
|
||||
id=route.surface_id or route_view_surface_id(manifest.id, route.path),
|
||||
module_id=manifest.id,
|
||||
kind="route",
|
||||
label=route.component,
|
||||
parent_id=root_id,
|
||||
description=route.path,
|
||||
order=route.order,
|
||||
)
|
||||
for route in (*frontend.routes, *frontend.settings_routes)
|
||||
)
|
||||
surfaces.extend(
|
||||
replace(
|
||||
surface,
|
||||
parent_id=surface.parent_id or root_id,
|
||||
)
|
||||
for surface in frontend.view_surfaces
|
||||
)
|
||||
return tuple(surfaces)
|
||||
|
||||
|
||||
def _normalize_delete_veto_result(
|
||||
result: object,
|
||||
*,
|
||||
@@ -320,9 +510,34 @@ def _validate_role_template_scopes(
|
||||
*,
|
||||
known_scopes: set[str],
|
||||
) -> None:
|
||||
seen_templates: dict[tuple[str, str], str] = {}
|
||||
for manifest in manifests:
|
||||
for template in manifest.role_templates:
|
||||
template_key = (template.level, template.slug)
|
||||
previous_module = seen_templates.get(template_key)
|
||||
if previous_module is not None:
|
||||
raise RegistryError(
|
||||
f"Duplicate {template.level} role template slug {template.slug!r} "
|
||||
f"in modules {previous_module!r} and {manifest.id!r}"
|
||||
)
|
||||
seen_templates[template_key] = manifest.id
|
||||
if template.default_authenticated and template.level != "tenant":
|
||||
raise RegistryError(
|
||||
f"Default authenticated role template {template.slug!r} must be tenant-level"
|
||||
)
|
||||
if template.default_authenticated and not template.managed:
|
||||
raise RegistryError(
|
||||
f"Default authenticated role template {template.slug!r} must be managed"
|
||||
)
|
||||
for scope in template.permissions:
|
||||
if template.default_authenticated and (
|
||||
scope in {"*", "tenant:*", "system:*"}
|
||||
or _WILDCARD_RE.match(scope)
|
||||
):
|
||||
raise RegistryError(
|
||||
f"Default authenticated role template {template.slug!r} "
|
||||
"must use explicit permissions, not wildcard scopes"
|
||||
)
|
||||
if _role_template_scope_known(scope, known_scopes):
|
||||
continue
|
||||
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
|
||||
@@ -344,6 +559,48 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
||||
_validate_manifest_frontend(manifest)
|
||||
for item in manifest.nav_items:
|
||||
_validate_nav_item(manifest.id, item)
|
||||
for topic in manifest.documentation:
|
||||
for issue in user_workflow_scope_condition_issues(topic):
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
|
||||
)
|
||||
_validate_documentation_extensions(manifest)
|
||||
|
||||
|
||||
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:
|
||||
@@ -368,6 +625,32 @@ def _validate_manifest_contract_lists(manifest: ModuleManifest) -> None:
|
||||
_validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities)
|
||||
_validate_interface_providers(manifest.id, manifest.provides_interfaces)
|
||||
_validate_interface_requirements(manifest.id, manifest.requires_interfaces)
|
||||
provider_ids: set[str] = set()
|
||||
for registration in manifest.search_providers:
|
||||
if not _INTERFACE_NAME_RE.match(registration.id):
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} search provider id must be namespaced: "
|
||||
f"{registration.id!r}"
|
||||
)
|
||||
if registration.id in provider_ids:
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} declares duplicate search provider "
|
||||
f"{registration.id!r}"
|
||||
)
|
||||
provider_ids.add(registration.id)
|
||||
source_ids: set[str] = set()
|
||||
for registration in manifest.search_sources:
|
||||
if not _INTERFACE_NAME_RE.match(registration.id):
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} search source id must be "
|
||||
f"namespaced: {registration.id!r}"
|
||||
)
|
||||
if registration.id in source_ids:
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} declares duplicate search source "
|
||||
f"{registration.id!r}"
|
||||
)
|
||||
source_ids.add(registration.id)
|
||||
|
||||
|
||||
def _validate_manifest_overlaps(manifest: ModuleManifest) -> None:
|
||||
@@ -403,10 +686,119 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
|
||||
)
|
||||
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
|
||||
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
|
||||
for route in (*frontend.routes, *frontend.settings_routes):
|
||||
for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes):
|
||||
_validate_frontend_route(manifest.id, route.path, route.component)
|
||||
for route in (*frontend.routes, *frontend.settings_routes):
|
||||
if route.surface_id is not None:
|
||||
_validate_view_surface_id(manifest.id, route.surface_id)
|
||||
for item in frontend.nav_items:
|
||||
_validate_nav_item(manifest.id, item)
|
||||
if item.surface_id is not None:
|
||||
_validate_view_surface_id(manifest.id, item.surface_id)
|
||||
_validate_view_surfaces(manifest)
|
||||
|
||||
|
||||
def _validate_view_surfaces(manifest: ModuleManifest) -> None:
|
||||
frontend = manifest.frontend
|
||||
if frontend is None:
|
||||
return
|
||||
root_id = module_view_surface_id(manifest.id)
|
||||
known_ids = {root_id}
|
||||
_validate_view_surface_id(manifest.id, root_id)
|
||||
for item in frontend.nav_items:
|
||||
surface_id = item.surface_id or navigation_view_surface_id(manifest.id, item.path)
|
||||
_register_view_surface_id(manifest.id, surface_id, known_ids)
|
||||
for route in (*frontend.routes, *frontend.settings_routes):
|
||||
surface_id = route.surface_id or route_view_surface_id(manifest.id, route.path)
|
||||
_register_view_surface_id(manifest.id, surface_id, known_ids)
|
||||
for surface in frontend.view_surfaces:
|
||||
_validate_declared_view_surface(manifest.id, surface)
|
||||
_register_view_surface_id(manifest.id, surface.id, known_ids)
|
||||
for surface in frontend.view_surfaces:
|
||||
_validate_view_surface_definition(
|
||||
manifest.id,
|
||||
surface,
|
||||
root_id=root_id,
|
||||
known_ids=known_ids,
|
||||
)
|
||||
parent_by_id = {
|
||||
surface.id: surface.parent_id or root_id
|
||||
for surface in frontend.view_surfaces
|
||||
}
|
||||
_validate_view_surface_hierarchy(manifest.id, parent_by_id)
|
||||
|
||||
|
||||
def _register_view_surface_id(
|
||||
module_id: str,
|
||||
surface_id: str,
|
||||
known_ids: set[str],
|
||||
) -> None:
|
||||
_validate_view_surface_id(module_id, surface_id)
|
||||
if surface_id in known_ids:
|
||||
raise RegistryError(
|
||||
f"Duplicate view surface id {surface_id!r} in module {module_id!r}"
|
||||
)
|
||||
known_ids.add(surface_id)
|
||||
|
||||
|
||||
def _validate_declared_view_surface(
|
||||
module_id: str,
|
||||
surface: ViewSurface,
|
||||
) -> None:
|
||||
if surface.module_id != module_id:
|
||||
raise RegistryError(
|
||||
f"View surface {surface.id!r} belongs to {surface.module_id!r}, "
|
||||
f"not module {module_id!r}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_view_surface_definition(
|
||||
module_id: str,
|
||||
surface: ViewSurface,
|
||||
*,
|
||||
root_id: str,
|
||||
known_ids: set[str],
|
||||
) -> None:
|
||||
parent_id = surface.parent_id or root_id
|
||||
if parent_id not in known_ids:
|
||||
raise RegistryError(
|
||||
f"View surface {surface.id!r} in module {module_id!r} "
|
||||
f"references unknown parent {parent_id!r}"
|
||||
)
|
||||
if surface.required and not surface.default_visible:
|
||||
raise RegistryError(
|
||||
f"Required view surface {surface.id!r} in module {module_id!r} "
|
||||
"must be visible by default"
|
||||
)
|
||||
|
||||
|
||||
def _validate_view_surface_hierarchy(
|
||||
module_id: str,
|
||||
parent_by_id: Mapping[str, str],
|
||||
) -> None:
|
||||
for surface_id in parent_by_id:
|
||||
path: set[str] = set()
|
||||
current_id: str | None = surface_id
|
||||
while current_id in parent_by_id:
|
||||
if current_id in path:
|
||||
raise RegistryError(
|
||||
f"View surface hierarchy in module {module_id!r} "
|
||||
f"contains a cycle at {current_id!r}"
|
||||
)
|
||||
path.add(current_id)
|
||||
current_id = parent_by_id[current_id]
|
||||
|
||||
|
||||
def _validate_view_surface_id(module_id: str, surface_id: str) -> None:
|
||||
if not validate_view_surface_id(surface_id):
|
||||
raise RegistryError(
|
||||
f"View surface id for module {module_id!r} is invalid: {surface_id!r}"
|
||||
)
|
||||
if not surface_id.startswith(f"{module_id}."):
|
||||
raise RegistryError(
|
||||
f"View surface id for module {module_id!r} must start with "
|
||||
f"{module_id!r} followed by '.': {surface_id!r}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
|
||||
@@ -416,6 +808,24 @@ def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
|
||||
raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component")
|
||||
|
||||
|
||||
def _validate_public_frontend_route_uniqueness(
|
||||
manifests: tuple[ModuleManifest, ...],
|
||||
) -> None:
|
||||
owners: dict[str, tuple[str, PublicFrontendRoute]] = {}
|
||||
for manifest in manifests:
|
||||
if manifest.frontend is None:
|
||||
continue
|
||||
for route in manifest.frontend.public_routes:
|
||||
previous = owners.get(route.path)
|
||||
if previous is not None:
|
||||
previous_module, _previous_route = previous
|
||||
raise RegistryError(
|
||||
f"Duplicate public frontend route {route.path!r} in modules "
|
||||
f"{previous_module!r} and {manifest.id!r}"
|
||||
)
|
||||
owners[route.path] = (manifest.id, route)
|
||||
|
||||
|
||||
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
|
||||
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list)
|
||||
for manifest in manifests:
|
||||
|
||||
383
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",
|
||||
]
|
||||
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.
|
||||
from govoplan_core.admin import models as core_admin_models # noqa: F401
|
||||
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401
|
||||
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401
|
||||
|
||||
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
|
||||
candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules)
|
||||
@@ -65,7 +66,7 @@ def bootstrap_dev_data(
|
||||
api_key_secret: str | None = None,
|
||||
tenant_slug: str = "default",
|
||||
user_email: str = "admin@example.local",
|
||||
user_password: str = "dev-admin",
|
||||
user_password: str = "dev-admin", # noqa: S107 - development bootstrap only.
|
||||
) -> BootstrapResult:
|
||||
tenant = session.query(Tenant).filter(Tenant.slug == tenant_slug).one_or_none()
|
||||
if tenant is None:
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass, replace
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sysconfig
|
||||
from typing import Any
|
||||
|
||||
from alembic import command
|
||||
@@ -17,6 +18,7 @@ from sqlalchemy import create_engine, inspect, text
|
||||
|
||||
from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan
|
||||
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
|
||||
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
||||
from govoplan_core.core.modules import ModuleMigrationTaskContext, ModuleMigrationTaskResult
|
||||
@@ -215,7 +217,10 @@ def _registered_module_registry(
|
||||
server_config = get_server_config()
|
||||
active_database_url = database_url or settings.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)
|
||||
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)
|
||||
@@ -459,11 +464,13 @@ def _jsonable_migration_task_details(value: Mapping[str, Any]) -> Mapping[str, A
|
||||
|
||||
def _repo_root() -> Path:
|
||||
packaged_root = Path(__file__).resolve().parents[3]
|
||||
installed_runtime_root = Path(sysconfig.get_path("data")) / "govoplan_core_runtime"
|
||||
configured = os.environ.get("GOVOPLAN_CORE_SOURCE_ROOT")
|
||||
candidates = [
|
||||
Path(configured).expanduser() if configured else None,
|
||||
Path.cwd(),
|
||||
packaged_root,
|
||||
installed_runtime_root,
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate is None:
|
||||
@@ -634,7 +641,7 @@ def _backfill_user_lock_state_for_create_all_schema(database_url: str) -> None:
|
||||
|
||||
def _row_count(connection, table_name: str) -> int:
|
||||
quoted = _quoted_table_name(connection, table_name)
|
||||
statement = text(f"SELECT COUNT(*) FROM {quoted}") # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
|
||||
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())
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator, Mapping
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
@@ -10,6 +11,14 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
from govoplan_core.db.query_metrics import instrument_engine
|
||||
|
||||
|
||||
_POOL_SETTING_BOUNDS: dict[str, tuple[int, int]] = {
|
||||
"GOVOPLAN_DB_POOL_SIZE": (1, 100),
|
||||
"GOVOPLAN_DB_MAX_OVERFLOW": (0, 200),
|
||||
"GOVOPLAN_DB_POOL_TIMEOUT_SECONDS": (1, 300),
|
||||
"GOVOPLAN_DB_POOL_RECYCLE_SECONDS": (0, 86_400),
|
||||
}
|
||||
|
||||
|
||||
def default_connect_args(database_url: str) -> dict[str, Any]:
|
||||
return {"check_same_thread": False} if database_url.startswith("sqlite") else {}
|
||||
|
||||
@@ -24,9 +33,42 @@ def create_database_engine(
|
||||
merged_connect_args = dict(default_connect_args(database_url))
|
||||
if connect_args:
|
||||
merged_connect_args.update(connect_args)
|
||||
if not database_url.startswith("sqlite"):
|
||||
kwargs.setdefault(
|
||||
"pool_size",
|
||||
_pool_setting("GOVOPLAN_DB_POOL_SIZE", 5),
|
||||
)
|
||||
kwargs.setdefault(
|
||||
"max_overflow",
|
||||
_pool_setting("GOVOPLAN_DB_MAX_OVERFLOW", 10),
|
||||
)
|
||||
kwargs.setdefault(
|
||||
"pool_timeout",
|
||||
_pool_setting("GOVOPLAN_DB_POOL_TIMEOUT_SECONDS", 30),
|
||||
)
|
||||
kwargs.setdefault(
|
||||
"pool_recycle",
|
||||
_pool_setting("GOVOPLAN_DB_POOL_RECYCLE_SECONDS", 1800),
|
||||
)
|
||||
return instrument_engine(create_engine(database_url, pool_pre_ping=pool_pre_ping, connect_args=merged_connect_args, **kwargs))
|
||||
|
||||
|
||||
def _pool_setting(name: str, default: int) -> int:
|
||||
raw = os.environ.get(name)
|
||||
if raw is None:
|
||||
return default
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{name} must be an integer") from exc
|
||||
minimum, maximum = _POOL_SETTING_BOUNDS[name]
|
||||
if not minimum <= value <= maximum:
|
||||
raise ValueError(
|
||||
f"{name} must be between {minimum} and {maximum}",
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class DatabaseHandle:
|
||||
def __init__(self, database_url: str, *, engine: Engine | None = None) -> None:
|
||||
self.database_url = database_url
|
||||
|
||||
@@ -10,6 +10,9 @@ from collections.abc import Iterable, Sequence
|
||||
from pathlib import Path
|
||||
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.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
||||
from govoplan_core.core.modules import ModuleManifest
|
||||
@@ -34,6 +37,7 @@ class DevserverState:
|
||||
config: GovoplanServerConfig
|
||||
registry: PlatformRegistry
|
||||
reload_dirs: list[str]
|
||||
reload_module_ids: tuple[str, ...] | None
|
||||
|
||||
|
||||
def _config_module_runtime_root(config_path: str | None) -> Path | None:
|
||||
@@ -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:
|
||||
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:
|
||||
roots.extend(_source_roots_for_object(provider))
|
||||
|
||||
for provider in manifest.tenant_summary_batch_providers:
|
||||
roots.extend(_source_roots_for_object(provider))
|
||||
|
||||
for providers in manifest.delete_veto_providers.values():
|
||||
for provider in providers:
|
||||
roots.extend(_source_roots_for_object(provider))
|
||||
@@ -226,20 +241,44 @@ def build_reload_dirs(
|
||||
config_path: str | None = None,
|
||||
registry: PlatformRegistry | None = None,
|
||||
extra_dirs: Sequence[str] = (),
|
||||
module_ids: Sequence[str] | None = None,
|
||||
) -> list[str]:
|
||||
active_registry = registry or build_platform_registry(config.enabled_modules, manifest_factories=config.manifest_factories)
|
||||
manifests = active_registry.manifests()
|
||||
enabled_module_ids = {manifest.id for manifest in manifests}
|
||||
selected_module_ids = (
|
||||
enabled_module_ids
|
||||
if module_ids is None
|
||||
else {module_id.strip() for module_id in module_ids if module_id.strip()}
|
||||
)
|
||||
unknown_module_ids = selected_module_ids - enabled_module_ids
|
||||
if unknown_module_ids:
|
||||
raise SystemExit(
|
||||
"Reload modules are not enabled: "
|
||||
+ ", ".join(sorted(unknown_module_ids))
|
||||
)
|
||||
|
||||
roots: list[Path | str] = []
|
||||
roots.extend(_config_source_roots(config_path))
|
||||
|
||||
for factory in config.manifest_factories:
|
||||
try:
|
||||
manifest = factory()
|
||||
except TypeError:
|
||||
manifest = None
|
||||
if (
|
||||
module_ids is not None
|
||||
and isinstance(manifest, ModuleManifest)
|
||||
and manifest.id not in selected_module_ids
|
||||
):
|
||||
continue
|
||||
roots.extend(_source_roots_for_object(factory))
|
||||
|
||||
roots.extend(_entry_point_source_roots(enabled_module_ids))
|
||||
roots.extend(_entry_point_source_roots(selected_module_ids))
|
||||
|
||||
for manifest in manifests:
|
||||
if manifest.id not in selected_module_ids:
|
||||
continue
|
||||
roots.extend(_manifest_source_roots(manifest))
|
||||
|
||||
roots.extend(extra_dirs)
|
||||
@@ -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("--no-reload", action="store_true", help="Disable uvicorn reload.")
|
||||
parser.add_argument("--reload-dir", action="append", default=[], help="Additional directory to watch. May be passed multiple times.")
|
||||
reload_scope = parser.add_mutually_exclusive_group()
|
||||
reload_scope.add_argument(
|
||||
"--reload-module",
|
||||
action="append",
|
||||
default=None,
|
||||
metavar="MODULE_ID",
|
||||
help=(
|
||||
"Watch only this enabled module in addition to core/config sources. "
|
||||
"May be passed multiple times."
|
||||
),
|
||||
)
|
||||
reload_scope.add_argument(
|
||||
"--reload-core-only",
|
||||
action="store_true",
|
||||
help="Watch core/config sources but no optional module source trees.",
|
||||
)
|
||||
parser.add_argument("--smoke", action="store_true", help="Prepare runtime paths, run app startup, print effective paths, and exit without uvicorn.")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def prepare_devserver(config_path: str | None, *, extra_reload_dirs: Sequence[str] = ()) -> DevserverState:
|
||||
def prepare_devserver(
|
||||
config_path: str | None,
|
||||
*,
|
||||
extra_reload_dirs: Sequence[str] = (),
|
||||
reload_module_ids: Sequence[str] | None = None,
|
||||
) -> DevserverState:
|
||||
runtime_root = apply_runtime_defaults(config_path)
|
||||
database_url = os.getenv("DATABASE_URL", "")
|
||||
validate_sqlite_database_url(database_url)
|
||||
@@ -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)
|
||||
registry = build_platform_registry(enabled_modules, manifest_factories=config.manifest_factories)
|
||||
reload_dirs = build_reload_dirs(config, config_path=config_path, registry=registry, extra_dirs=extra_reload_dirs)
|
||||
reload_dirs = build_reload_dirs(
|
||||
config,
|
||||
config_path=config_path,
|
||||
registry=registry,
|
||||
extra_dirs=extra_reload_dirs,
|
||||
module_ids=reload_module_ids,
|
||||
)
|
||||
return DevserverState(
|
||||
config_path=config_path,
|
||||
runtime_root=runtime_root,
|
||||
@@ -289,6 +355,11 @@ def prepare_devserver(config_path: str | None, *, extra_reload_dirs: Sequence[st
|
||||
config=config,
|
||||
registry=registry,
|
||||
reload_dirs=reload_dirs,
|
||||
reload_module_ids=(
|
||||
None
|
||||
if reload_module_ids is None
|
||||
else tuple(sorted(set(reload_module_ids)))
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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"Runtime root: {state.runtime_root}")
|
||||
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"):
|
||||
pgtools_url = os.getenv("GOVOPLAN_DATABASE_URL_PGTOOLS")
|
||||
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:
|
||||
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})")
|
||||
@@ -309,6 +380,15 @@ def print_devserver_summary(state: DevserverState, *, app: str, no_reload: bool)
|
||||
if no_reload:
|
||||
print("Reload: disabled")
|
||||
else:
|
||||
if state.reload_module_ids is None:
|
||||
print("Reload scope: core/config plus all enabled modules")
|
||||
elif state.reload_module_ids:
|
||||
print(
|
||||
"Reload scope: core/config plus "
|
||||
+ ", ".join(state.reload_module_ids)
|
||||
)
|
||||
else:
|
||||
print("Reload scope: core/config only")
|
||||
print("Reload dirs:")
|
||||
for directory in state.reload_dirs:
|
||||
print(f" - {directory}")
|
||||
@@ -342,7 +422,14 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
os.environ["GOVOPLAN_SERVER_CONFIG"] = args.config
|
||||
|
||||
config_path = args.config or os.getenv("GOVOPLAN_SERVER_CONFIG")
|
||||
state = prepare_devserver(config_path, extra_reload_dirs=args.reload_dir)
|
||||
reload_module_ids: Sequence[str] | None = args.reload_module
|
||||
if args.reload_core_only:
|
||||
reload_module_ids = ()
|
||||
state = prepare_devserver(
|
||||
config_path,
|
||||
extra_reload_dirs=args.reload_dir,
|
||||
reload_module_ids=reload_module_ids,
|
||||
)
|
||||
print_devserver_summary(state, app=args.app, no_reload=args.no_reload)
|
||||
|
||||
if args.smoke:
|
||||
|
||||
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",
|
||||
]
|
||||
@@ -5,6 +5,12 @@ 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:
|
||||
@@ -40,17 +46,26 @@ def fetch_http(
|
||||
label: str = "URL",
|
||||
method: str = "GET",
|
||||
headers: Mapping[str, str] | None = None,
|
||||
max_bytes: int | None = None,
|
||||
) -> HttpFetchResponse:
|
||||
request = urllib.request.Request(
|
||||
validate_http_url(url, label=label),
|
||||
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,
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - URL is validated by validate_http_url. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
||||
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=dict(response.headers.items()),
|
||||
body=response.read(),
|
||||
headers=response_headers,
|
||||
body=bounded_response_bytes(
|
||||
response,
|
||||
headers=response_headers,
|
||||
max_bytes=max_bytes,
|
||||
label=f"{label} response",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -62,5 +77,29 @@ def fetch_http_text(
|
||||
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).text(encoding)
|
||||
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)
|
||||
|
||||
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)
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
|
||||
def sensitive_key_tokens(key: object) -> set[str]:
|
||||
value = str(key).strip()
|
||||
@@ -30,18 +30,21 @@ def redact_secret_values(value: object, *, placeholder: str = "<redacted>") -> o
|
||||
return result
|
||||
if isinstance(value, list):
|
||||
return [redact_secret_values(item, placeholder=placeholder) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(redact_secret_values(item, placeholder=placeholder) for item in value)
|
||||
return value
|
||||
|
||||
|
||||
def contains_plain_secret(value: object) -> bool:
|
||||
if not isinstance(value, Mapping):
|
||||
return False
|
||||
for key, item in value.items():
|
||||
normalized_key = str(key).strip().casefold().replace("-", "_")
|
||||
if normalized_key in {"credential_ref", "secret_ref", "secret_reference"}:
|
||||
continue
|
||||
if is_sensitive_key(key) and item not in (None, "", {"secret_ref": ""}):
|
||||
return True
|
||||
if isinstance(item, Mapping) and contains_plain_secret(item):
|
||||
return True
|
||||
if isinstance(value, Mapping):
|
||||
for key, item in value.items():
|
||||
normalized_key = str(key).strip().casefold().replace("-", "_")
|
||||
if normalized_key in {"credential_ref", "secret_ref", "secret_reference"}:
|
||||
continue
|
||||
if is_sensitive_key(key) and item not in (None, "", {"secret_ref": ""}): # nosec B105 - empty redaction sentinels.
|
||||
return True
|
||||
if contains_plain_secret(item):
|
||||
return True
|
||||
elif isinstance(value, (list, tuple)):
|
||||
return any(contains_plain_secret(item) for item in value)
|
||||
return False
|
||||
|
||||
@@ -18,7 +18,7 @@ class SecretDecryptionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider"
|
||||
CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider" # noqa: S105 # nosec B105 - capability identifier.
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
|
||||
@@ -8,6 +8,7 @@ from govoplan_core.db.session import configure_database
|
||||
from govoplan_core.server.config import GovoplanServerConfig, load_server_config
|
||||
from govoplan_core.server.fastapi import create_govoplan_app
|
||||
from govoplan_core.server.platform import create_platform_router
|
||||
from govoplan_core.server.credentials import router as credential_router
|
||||
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
||||
from govoplan_core.server.route_validation import validate_no_route_collisions
|
||||
|
||||
@@ -67,6 +68,7 @@ def _server_api_router(server_config: GovoplanServerConfig, registry) -> APIRout
|
||||
for router in server_config.base_routers:
|
||||
api_router.include_router(router)
|
||||
api_router.include_router(create_platform_router(settings=server_config.settings))
|
||||
api_router.include_router(credential_router)
|
||||
for router in server_config.post_module_routers:
|
||||
api_router.include_router(router)
|
||||
for contribution in server_config.extra_routers:
|
||||
|
||||
380
src/govoplan_core/server/credentials.py
Normal file
380
src/govoplan_core/server/credentials.py
Normal file
@@ -0,0 +1,380 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field, SecretStr
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.security.credential_envelopes import (
|
||||
CredentialEnvelope,
|
||||
CredentialEnvelopeError,
|
||||
create_credential_envelope,
|
||||
credential_envelope_summary,
|
||||
get_managed_credential_envelope,
|
||||
list_managed_credential_envelopes,
|
||||
normalize_credential_scope,
|
||||
retire_credential_envelope,
|
||||
update_credential_envelope,
|
||||
)
|
||||
|
||||
|
||||
CredentialScopeType = Literal["system", "tenant", "group", "user", "campaign"]
|
||||
|
||||
|
||||
class CredentialEnvelopeResponse(BaseModel):
|
||||
id: str
|
||||
tenant_id: str | None = None
|
||||
scope_type: CredentialScopeType
|
||||
scope_id: str | None = None
|
||||
name: str
|
||||
description: str | None = None
|
||||
credential_kind: str
|
||||
public_data: dict[str, Any] = Field(default_factory=dict)
|
||||
secret_keys: list[str] = Field(default_factory=list)
|
||||
secret_configured: bool = False
|
||||
allowed_modules: list[str] = Field(default_factory=list)
|
||||
allowed_server_refs: list[str] = Field(default_factory=list)
|
||||
inherit_to_lower_scopes: bool = False
|
||||
is_active: bool = True
|
||||
revision: str
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
deleted_at: datetime | None = None
|
||||
|
||||
|
||||
class CredentialEnvelopeListResponse(BaseModel):
|
||||
credentials: list[CredentialEnvelopeResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CredentialEnvelopeCreateRequest(BaseModel):
|
||||
scope_type: CredentialScopeType = "tenant"
|
||||
scope_id: str | None = Field(default=None, max_length=255)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
credential_kind: str = Field(default="username_password", max_length=40)
|
||||
public_data: dict[str, Any] = Field(default_factory=dict)
|
||||
secret_data: dict[str, SecretStr] = Field(default_factory=dict)
|
||||
allowed_modules: list[str] = Field(default_factory=list)
|
||||
allowed_server_refs: list[str] = Field(default_factory=list)
|
||||
inherit_to_lower_scopes: bool = False
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class CredentialEnvelopeUpdateRequest(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
credential_kind: str | None = Field(default=None, max_length=40)
|
||||
public_data: dict[str, Any] | None = None
|
||||
secret_data: dict[str, SecretStr] | None = None
|
||||
clear_secret: bool = False
|
||||
allowed_modules: list[str] | None = None
|
||||
allowed_server_refs: list[str] | None = None
|
||||
inherit_to_lower_scopes: bool | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
router = APIRouter(prefix="/credentials", tags=["credentials"])
|
||||
|
||||
|
||||
@router.get("", response_model=CredentialEnvelopeListResponse)
|
||||
def list_credentials(
|
||||
scope_type: CredentialScopeType = Query(default="tenant"),
|
||||
scope_id: str | None = Query(default=None),
|
||||
include_inactive: bool = Query(default=False),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
) -> CredentialEnvelopeListResponse:
|
||||
tenant_id, normalized_scope_type, normalized_scope_id = _target_scope(
|
||||
principal,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
write=False,
|
||||
)
|
||||
rows = list_managed_credential_envelopes(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
include_inactive=include_inactive,
|
||||
)
|
||||
return CredentialEnvelopeListResponse(
|
||||
credentials=[
|
||||
_response(row)
|
||||
for row in rows
|
||||
if row.scope_type == normalized_scope_type
|
||||
and row.scope_id == normalized_scope_id
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=CredentialEnvelopeResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_credential(
|
||||
payload: CredentialEnvelopeCreateRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
) -> CredentialEnvelopeResponse:
|
||||
tenant_id, scope_type, scope_id = _target_scope(
|
||||
principal,
|
||||
scope_type=payload.scope_type,
|
||||
scope_id=payload.scope_id,
|
||||
write=True,
|
||||
)
|
||||
try:
|
||||
row = create_credential_envelope(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
credential_kind=payload.credential_kind,
|
||||
public_data=payload.public_data,
|
||||
secret_data=_secret_values(payload.secret_data),
|
||||
allowed_modules=payload.allowed_modules,
|
||||
allowed_server_refs=payload.allowed_server_refs,
|
||||
inherit_to_lower_scopes=payload.inherit_to_lower_scopes,
|
||||
is_active=payload.is_active,
|
||||
user_id=principal.membership_id,
|
||||
metadata={"created_by_module": "core"},
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return _response(row)
|
||||
except CredentialEnvelopeError as exc:
|
||||
session.rollback()
|
||||
raise _credential_error(exc) from exc
|
||||
|
||||
|
||||
@router.patch("/{credential_id}", response_model=CredentialEnvelopeResponse)
|
||||
def update_credential(
|
||||
credential_id: str,
|
||||
payload: CredentialEnvelopeUpdateRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
) -> CredentialEnvelopeResponse:
|
||||
try:
|
||||
row = _managed_row_for_write(session, principal, credential_id)
|
||||
row = update_credential_envelope(
|
||||
session,
|
||||
row,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
description_supplied="description" in payload.model_fields_set,
|
||||
credential_kind=payload.credential_kind,
|
||||
public_data=payload.public_data,
|
||||
secret_data=(
|
||||
_secret_values(payload.secret_data)
|
||||
if payload.secret_data is not None
|
||||
else None
|
||||
),
|
||||
clear_secret=payload.clear_secret,
|
||||
allowed_modules=payload.allowed_modules,
|
||||
allowed_server_refs=payload.allowed_server_refs,
|
||||
inherit_to_lower_scopes=payload.inherit_to_lower_scopes,
|
||||
is_active=payload.is_active,
|
||||
user_id=principal.membership_id,
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return _response(row)
|
||||
except CredentialEnvelopeError as exc:
|
||||
session.rollback()
|
||||
raise _credential_error(exc) from exc
|
||||
|
||||
|
||||
@router.delete("/{credential_id}", response_model=CredentialEnvelopeResponse)
|
||||
def delete_credential(
|
||||
credential_id: str,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
) -> CredentialEnvelopeResponse:
|
||||
try:
|
||||
row = _managed_row_for_write(session, principal, credential_id)
|
||||
retire_credential_envelope(
|
||||
session,
|
||||
row,
|
||||
user_id=principal.membership_id,
|
||||
)
|
||||
response = _response(row)
|
||||
session.commit()
|
||||
return response
|
||||
except CredentialEnvelopeError as exc:
|
||||
session.rollback()
|
||||
raise _credential_error(exc) from exc
|
||||
|
||||
|
||||
def _managed_row_for_write(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
credential_id: str,
|
||||
) -> CredentialEnvelope:
|
||||
system_row = None
|
||||
if _can_manage_system_credentials(principal):
|
||||
try:
|
||||
system_row = get_managed_credential_envelope(
|
||||
session,
|
||||
credential_id=credential_id,
|
||||
tenant_id=None,
|
||||
for_update=True,
|
||||
)
|
||||
except CredentialEnvelopeError:
|
||||
pass
|
||||
row = system_row or get_managed_credential_envelope(
|
||||
session,
|
||||
credential_id=credential_id,
|
||||
tenant_id=principal.tenant_id,
|
||||
for_update=True,
|
||||
)
|
||||
_target_scope(
|
||||
principal,
|
||||
scope_type=row.scope_type,
|
||||
scope_id=row.scope_id,
|
||||
write=True,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def _target_scope(
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
write: bool,
|
||||
) -> tuple[str | None, str, str | None]:
|
||||
requested_type = str(scope_type or "tenant").strip().casefold()
|
||||
requested_id = str(scope_id).strip() if scope_id else None
|
||||
if requested_type == "system":
|
||||
if not (
|
||||
_can_manage_system_credentials(principal)
|
||||
if write
|
||||
else _can_read_system_credentials(principal)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="System credential permission is required.",
|
||||
)
|
||||
return _normalize_target_scope(
|
||||
tenant_id=None,
|
||||
scope_type="system",
|
||||
scope_id=None,
|
||||
)
|
||||
|
||||
if requested_type == "user" and requested_id == principal.membership_id:
|
||||
own_scope = (
|
||||
has_scope(principal, "access:credential:manage_own")
|
||||
or has_scope(principal, "mail:secret:manage_own")
|
||||
)
|
||||
if own_scope:
|
||||
return _normalize_target_scope(
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=requested_type,
|
||||
scope_id=requested_id,
|
||||
)
|
||||
|
||||
allowed = (
|
||||
_can_manage_tenant_credentials(principal)
|
||||
if write
|
||||
else _can_read_tenant_credentials(principal)
|
||||
)
|
||||
if not allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Tenant credential permission is required.",
|
||||
)
|
||||
return _normalize_target_scope(
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=requested_type,
|
||||
scope_id=requested_id,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_target_scope(
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
) -> tuple[str | None, str, str | None]:
|
||||
try:
|
||||
return normalize_credential_scope(
|
||||
tenant_id=tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
except CredentialEnvelopeError as exc:
|
||||
raise _credential_error(exc) from exc
|
||||
|
||||
|
||||
def _can_read_system_credentials(principal: ApiPrincipal) -> bool:
|
||||
return any(
|
||||
has_scope(principal, scope)
|
||||
for scope in (
|
||||
"access:system_credential:read",
|
||||
"access:system_setting:read",
|
||||
"system:settings:read",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _can_manage_system_credentials(principal: ApiPrincipal) -> bool:
|
||||
return any(
|
||||
has_scope(principal, scope)
|
||||
for scope in (
|
||||
"access:system_credential:write",
|
||||
"access:system_setting:write",
|
||||
"system:settings:write",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _can_read_tenant_credentials(principal: ApiPrincipal) -> bool:
|
||||
return any(
|
||||
has_scope(principal, scope)
|
||||
for scope in (
|
||||
"access:credential:read",
|
||||
"access:setting:read",
|
||||
"admin:settings:read",
|
||||
"mail_servers:read",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _can_manage_tenant_credentials(principal: ApiPrincipal) -> bool:
|
||||
return any(
|
||||
has_scope(principal, scope)
|
||||
for scope in (
|
||||
"access:credential:write",
|
||||
"access:setting:write",
|
||||
"admin:settings:write",
|
||||
"mail_servers:manage_credentials",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _secret_values(values: dict[str, SecretStr]) -> dict[str, str]:
|
||||
return {
|
||||
str(key): value.get_secret_value()
|
||||
for key, value in values.items()
|
||||
if str(key).strip() and value.get_secret_value()
|
||||
}
|
||||
|
||||
|
||||
def _response(row: CredentialEnvelope) -> CredentialEnvelopeResponse:
|
||||
return CredentialEnvelopeResponse.model_validate(
|
||||
credential_envelope_summary(row)
|
||||
)
|
||||
|
||||
|
||||
def _credential_error(exc: CredentialEnvelopeError) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -9,14 +9,21 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||
|
||||
from govoplan_core.core.events import event_context, new_event_id, normalize_trace_id
|
||||
from govoplan_core.core.install_config import validate_runtime_configuration
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.db.query_metrics import collect_query_metrics
|
||||
from govoplan_core.server.conditional_requests import conditional_json_get_middleware
|
||||
from govoplan_core.server.request_limits import RequestBodyLimitMiddleware
|
||||
|
||||
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
|
||||
logger = logging.getLogger("govoplan.request")
|
||||
_CONTENT_SECURITY_POLICY = "base-uri 'self'; object-src 'none'; frame-ancestors 'none'"
|
||||
_PRODUCTION_LIKE_ENVIRONMENTS = frozenset(
|
||||
{"prod", "production", "self-hosted", "staging", "production-like", "production-like-dev"}
|
||||
)
|
||||
|
||||
|
||||
def _slow_request_threshold_ms() -> float:
|
||||
@@ -27,6 +34,60 @@ def _slow_request_threshold_ms() -> float:
|
||||
return 500.0
|
||||
|
||||
|
||||
def _hsts_seconds() -> int:
|
||||
default = "31536000" if os.getenv("APP_ENV", "dev").strip().lower() in {"prod", "production"} else "0"
|
||||
raw = os.getenv("GOVOPLAN_HTTP_HSTS_SECONDS", default).strip()
|
||||
try:
|
||||
return max(0, int(raw))
|
||||
except ValueError:
|
||||
return int(default)
|
||||
|
||||
|
||||
def _max_request_body_bytes() -> int:
|
||||
default = 512 * 1024 * 1024
|
||||
raw = os.getenv("GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES", str(default)).strip()
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
return default
|
||||
return value if value > 0 else default
|
||||
|
||||
|
||||
def _trusted_hosts() -> tuple[str, ...]:
|
||||
return tuple(item.strip() for item in os.getenv("GOVOPLAN_TRUSTED_HOSTS", "").split(",") if item.strip())
|
||||
|
||||
|
||||
def _validate_production_startup() -> None:
|
||||
app_env = os.getenv("APP_ENV", "").strip().lower().replace("_", "-")
|
||||
install_profile = os.getenv("GOVOPLAN_INSTALL_PROFILE", "").strip().lower().replace("_", "-")
|
||||
if app_env not in _PRODUCTION_LIKE_ENVIRONMENTS and install_profile not in _PRODUCTION_LIKE_ENVIRONMENTS:
|
||||
return
|
||||
validation = validate_runtime_configuration()
|
||||
if validation.errors:
|
||||
raise RuntimeError(validation.to_text())
|
||||
throttle_enabled = os.getenv("AUTH_LOGIN_THROTTLE_ENABLED", "true").strip().lower() not in {
|
||||
"0",
|
||||
"false",
|
||||
"no",
|
||||
"off",
|
||||
}
|
||||
if throttle_enabled and not os.getenv("REDIS_URL", "").strip():
|
||||
allow_process_local = os.getenv(
|
||||
"GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE",
|
||||
"false",
|
||||
).strip().lower() in {"1", "true", "yes", "on"}
|
||||
if not allow_process_local:
|
||||
raise RuntimeError(
|
||||
"Production login throttling requires REDIS_URL. "
|
||||
"Set GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE=true only "
|
||||
"for an explicitly single-process deployment."
|
||||
)
|
||||
logger.warning(
|
||||
"Redis is not configured; login throttling is explicitly limited "
|
||||
"to this process"
|
||||
)
|
||||
|
||||
|
||||
def create_govoplan_app(
|
||||
*,
|
||||
title: str,
|
||||
@@ -37,9 +98,27 @@ def create_govoplan_app(
|
||||
cors_origins: Iterable[str] = (),
|
||||
health_payload: dict[str, Any] | None = None,
|
||||
) -> FastAPI:
|
||||
_validate_production_startup()
|
||||
app = FastAPI(title=title, version=version, lifespan=lifespan)
|
||||
app.add_middleware(RequestBodyLimitMiddleware, max_bytes=_max_request_body_bytes())
|
||||
trusted_hosts = _trusted_hosts()
|
||||
if trusted_hosts:
|
||||
app.add_middleware(TrustedHostMiddleware, allowed_hosts=list(trusted_hosts))
|
||||
app.state.govoplan_registry = registry
|
||||
slow_request_threshold_ms = _slow_request_threshold_ms()
|
||||
hsts_seconds = _hsts_seconds()
|
||||
|
||||
@app.middleware("http")
|
||||
async def security_response_headers(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
response.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
response.headers.setdefault("Content-Security-Policy", _CONTENT_SECURITY_POLICY)
|
||||
if hsts_seconds and request.url.scheme == "https":
|
||||
response.headers.setdefault("Strict-Transport-Security", f"max-age={hsts_seconds}")
|
||||
return response
|
||||
|
||||
@app.middleware("http")
|
||||
async def request_correlation_context(request: Request, call_next):
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleManifest, NavItem, PublicFrontendRoute
|
||||
from govoplan_core.core.registry import PlatformRegistry, manifest_view_surfaces
|
||||
from govoplan_core.core.views import (
|
||||
VIEW_SURFACE_CONTRACT_VERSION,
|
||||
ViewSurface,
|
||||
navigation_view_surface_id,
|
||||
route_view_surface_id,
|
||||
)
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_core.i18n import system_i18n_payload
|
||||
|
||||
@@ -19,7 +26,7 @@ def _registry(request: Request) -> PlatformRegistry:
|
||||
return registry
|
||||
|
||||
|
||||
def _nav_item_payload(item: NavItem) -> dict[str, object]:
|
||||
def _nav_item_payload(item: NavItem, module_id: str | None = None) -> dict[str, object]:
|
||||
return {
|
||||
"path": item.path,
|
||||
"label": item.label,
|
||||
@@ -28,20 +35,60 @@ def _nav_item_payload(item: NavItem) -> dict[str, object]:
|
||||
"required_all": list(item.required_all),
|
||||
"required_any": list(item.required_any),
|
||||
"order": item.order,
|
||||
"surface_id": (
|
||||
item.surface_id or navigation_view_surface_id(module_id, item.path)
|
||||
if module_id
|
||||
else item.surface_id
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _frontend_route_payload(route: FrontendRoute) -> dict[str, object]:
|
||||
def _frontend_route_payload(route: FrontendRoute, module_id: str | None = None) -> dict[str, object]:
|
||||
return {
|
||||
"path": route.path,
|
||||
"component": route.component,
|
||||
"required_all": list(route.required_all),
|
||||
"required_any": list(route.required_any),
|
||||
"order": route.order,
|
||||
"surface_id": (
|
||||
route.surface_id or route_view_surface_id(module_id, route.path)
|
||||
if module_id
|
||||
else route.surface_id
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | None:
|
||||
def _public_frontend_route_payload(route: PublicFrontendRoute) -> dict[str, object]:
|
||||
return {
|
||||
"path": route.path,
|
||||
"component": route.component,
|
||||
"order": route.order,
|
||||
}
|
||||
|
||||
|
||||
def _view_surface_payload(surface: ViewSurface) -> dict[str, object]:
|
||||
return {
|
||||
"id": surface.id,
|
||||
"module_id": surface.module_id,
|
||||
"kind": surface.kind,
|
||||
"label": surface.label,
|
||||
"parent_id": surface.parent_id,
|
||||
"description": surface.description,
|
||||
"order": surface.order,
|
||||
"default_visible": surface.default_visible,
|
||||
"required": surface.required,
|
||||
}
|
||||
|
||||
|
||||
def _frontend_view_surfaces(manifest: ModuleManifest) -> list[dict[str, object]]:
|
||||
return [
|
||||
_view_surface_payload(surface)
|
||||
for surface in manifest_view_surfaces(manifest)
|
||||
]
|
||||
|
||||
|
||||
def _frontend_payload(manifest: ModuleManifest) -> dict[str, object] | None:
|
||||
frontend = manifest.frontend
|
||||
if frontend is None:
|
||||
return None
|
||||
return {
|
||||
@@ -52,9 +99,31 @@ def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | No
|
||||
"asset_manifest_public_key_id": frontend.asset_manifest_public_key_id,
|
||||
"asset_manifest_integrity": frontend.asset_manifest_integrity,
|
||||
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
|
||||
"routes": [_frontend_route_payload(route) for route in frontend.routes],
|
||||
"nav": [_nav_item_payload(item) for item in frontend.nav_items],
|
||||
"settings_routes": [_frontend_route_payload(route) for route in frontend.settings_routes],
|
||||
"routes": [_frontend_route_payload(route, manifest.id) for route in frontend.routes],
|
||||
"public_routes": [
|
||||
_public_frontend_route_payload(route) for route in frontend.public_routes
|
||||
],
|
||||
"nav": [_nav_item_payload(item, manifest.id) for item in frontend.nav_items],
|
||||
"settings_routes": [_frontend_route_payload(route, manifest.id) for route in frontend.settings_routes],
|
||||
"view_surface_contract_version": VIEW_SURFACE_CONTRACT_VERSION,
|
||||
"view_surfaces": _frontend_view_surfaces(manifest),
|
||||
}
|
||||
|
||||
|
||||
def _public_frontend_payload(frontend: FrontendModule) -> dict[str, object]:
|
||||
"""Return only assets and routes explicitly approved for signed-out use."""
|
||||
|
||||
return {
|
||||
"module_id": frontend.module_id,
|
||||
"package_name": frontend.package_name,
|
||||
"asset_manifest": frontend.asset_manifest,
|
||||
"asset_manifest_signature": frontend.asset_manifest_signature,
|
||||
"asset_manifest_public_key_id": frontend.asset_manifest_public_key_id,
|
||||
"asset_manifest_integrity": frontend.asset_manifest_integrity,
|
||||
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
|
||||
"public_routes": [
|
||||
_public_frontend_route_payload(route) for route in frontend.public_routes
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +154,10 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
||||
}
|
||||
|
||||
@router.get("/modules")
|
||||
def modules(request: Request):
|
||||
def modules(
|
||||
request: Request,
|
||||
_principal: ApiPrincipal = Depends(get_api_principal),
|
||||
):
|
||||
registry = _registry(request)
|
||||
return {
|
||||
"modules": [
|
||||
@@ -97,15 +169,35 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
||||
"optional_dependencies": list(manifest.optional_dependencies),
|
||||
"enabled": True,
|
||||
"runtime_ui_capabilities": _runtime_ui_capabilities(manifest.id, settings, registry),
|
||||
"nav": [_nav_item_payload(item) for item in manifest.nav_items],
|
||||
"frontend": _frontend_payload(manifest.frontend),
|
||||
"nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items],
|
||||
"frontend": _frontend_payload(manifest),
|
||||
}
|
||||
for manifest in registry.manifests()
|
||||
]
|
||||
}
|
||||
|
||||
@router.get("/public-modules")
|
||||
def public_modules(request: Request):
|
||||
registry = _registry(request)
|
||||
return {
|
||||
"modules": [
|
||||
{
|
||||
"id": manifest.id,
|
||||
"name": manifest.name,
|
||||
"version": manifest.version,
|
||||
"frontend": _public_frontend_payload(manifest.frontend),
|
||||
}
|
||||
for manifest in registry.manifests()
|
||||
if manifest.frontend is not None
|
||||
and manifest.frontend.public_routes
|
||||
]
|
||||
}
|
||||
|
||||
@router.get("/permissions")
|
||||
def permissions(request: Request):
|
||||
def permissions(
|
||||
request: Request,
|
||||
_principal: ApiPrincipal = Depends(get_api_principal),
|
||||
):
|
||||
registry = _registry(request)
|
||||
return {
|
||||
"permissions": [
|
||||
@@ -125,7 +217,10 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
||||
}
|
||||
|
||||
@router.get("/navigation")
|
||||
def navigation(request: Request):
|
||||
def navigation(
|
||||
request: Request,
|
||||
_principal: ApiPrincipal = Depends(get_api_principal),
|
||||
):
|
||||
registry = _registry(request)
|
||||
return {
|
||||
"items": [_nav_item_payload(item) for item in registry.nav_items()]
|
||||
|
||||
@@ -3,6 +3,9 @@ from __future__ import annotations
|
||||
from collections.abc import Callable, Iterable, Sequence
|
||||
import importlib
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
DEFAULT_CAPABILITY_PROVIDERS,
|
||||
)
|
||||
from govoplan_core.core.discovery import discover_module_manifests
|
||||
from govoplan_core.core.modules import ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||
@@ -15,7 +18,6 @@ _BUILTIN_MANIFESTS = {
|
||||
"tenancy": "govoplan_tenancy.backend.manifest:get_manifest",
|
||||
}
|
||||
|
||||
|
||||
def parse_enabled_modules(value: str | Iterable[str]) -> list[str]:
|
||||
if isinstance(value, str):
|
||||
items = [item.strip() for item in value.split(",")]
|
||||
@@ -57,22 +59,68 @@ def available_module_manifests(
|
||||
return manifests
|
||||
|
||||
|
||||
def build_platform_registry(enabled_modules: str | Iterable[str], *, manifest_factories: Sequence[ManifestFactory] = ()) -> PlatformRegistry:
|
||||
def build_platform_registry(
|
||||
enabled_modules: str | Iterable[str],
|
||||
*,
|
||||
manifest_factories: Sequence[ManifestFactory] = (),
|
||||
) -> PlatformRegistry:
|
||||
requested = parse_enabled_modules(enabled_modules)
|
||||
if "access" not in requested:
|
||||
requested.insert(0, "access")
|
||||
|
||||
available = available_module_manifests(manifest_factories, enabled_modules=requested)
|
||||
available = _resolve_required_manifests(
|
||||
requested,
|
||||
manifest_factories=manifest_factories,
|
||||
)
|
||||
registry = PlatformRegistry()
|
||||
for module_id in requested:
|
||||
manifest = available.get(module_id)
|
||||
if manifest is None:
|
||||
raise RegistryError(f"Enabled module is not available: {module_id}")
|
||||
for module_id, manifest in available.items():
|
||||
registry.register(manifest)
|
||||
registry.validate()
|
||||
return registry
|
||||
|
||||
|
||||
def _resolve_required_manifests(
|
||||
requested: Sequence[str],
|
||||
*,
|
||||
manifest_factories: Sequence[ManifestFactory],
|
||||
) -> dict[str, ModuleManifest]:
|
||||
manifests: dict[str, ModuleManifest] = {}
|
||||
pending = list(dict.fromkeys(requested))
|
||||
|
||||
while pending:
|
||||
module_ids = [module_id for module_id in pending if module_id not in manifests]
|
||||
pending = []
|
||||
if not module_ids:
|
||||
break
|
||||
|
||||
available = available_module_manifests(
|
||||
manifest_factories,
|
||||
enabled_modules=module_ids,
|
||||
)
|
||||
for module_id in module_ids:
|
||||
manifest = available.get(module_id)
|
||||
if manifest is None:
|
||||
raise RegistryError(f"Enabled module is not available: {module_id}")
|
||||
manifests[module_id] = manifest
|
||||
|
||||
provided_capabilities = {
|
||||
capability
|
||||
for manifest in manifests.values()
|
||||
for capability in manifest.capability_factories
|
||||
}
|
||||
for manifest in tuple(manifests.values()):
|
||||
pending.extend(
|
||||
dependency_id
|
||||
for dependency_id in manifest.dependencies
|
||||
if dependency_id not in manifests
|
||||
)
|
||||
for capability in manifest.required_capabilities:
|
||||
if capability in provided_capabilities:
|
||||
continue
|
||||
provider_id = DEFAULT_CAPABILITY_PROVIDERS.get(capability)
|
||||
if provider_id is not None and provider_id not in manifests:
|
||||
pending.append(provider_id)
|
||||
|
||||
return manifests
|
||||
|
||||
|
||||
def _load_builtin_manifest(module_id: str) -> ModuleManifest:
|
||||
target = _BUILTIN_MANIFESTS[module_id]
|
||||
module_name, function_name = validate_object_path(target)
|
||||
|
||||
71
src/govoplan_core/server/request_limits.py
Normal file
71
src/govoplan_core/server/request_limits.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
|
||||
class _RequestBodyTooLarge(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class RequestBodyLimitMiddleware:
|
||||
def __init__(self, app: Any, *, max_bytes: int) -> None:
|
||||
if max_bytes <= 0:
|
||||
raise ValueError("max_bytes must be positive")
|
||||
self.app = app
|
||||
self.max_bytes = max_bytes
|
||||
|
||||
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
||||
if scope.get("type") != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
declared = _content_length(scope.get("headers", ()))
|
||||
if declared is not None and declared > self.max_bytes:
|
||||
await self._reject(scope, receive, send)
|
||||
return
|
||||
|
||||
received = 0
|
||||
response_started = False
|
||||
|
||||
async def limited_receive() -> dict[str, Any]:
|
||||
nonlocal received
|
||||
message = await receive()
|
||||
if message.get("type") == "http.request":
|
||||
received += len(message.get("body", b""))
|
||||
if received > self.max_bytes:
|
||||
raise _RequestBodyTooLarge
|
||||
return message
|
||||
|
||||
async def tracked_send(message: dict[str, Any]) -> None:
|
||||
nonlocal response_started
|
||||
if message.get("type") == "http.response.start":
|
||||
response_started = True
|
||||
await send(message)
|
||||
|
||||
try:
|
||||
await self.app(scope, limited_receive, tracked_send)
|
||||
except _RequestBodyTooLarge:
|
||||
if response_started:
|
||||
raise
|
||||
await self._reject(scope, receive, send)
|
||||
|
||||
async def _reject(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
||||
response = JSONResponse(
|
||||
{"detail": f"Request body exceeds the deployment limit of {self.max_bytes} bytes"},
|
||||
status_code=413,
|
||||
)
|
||||
await response(scope, receive, send)
|
||||
|
||||
|
||||
def _content_length(headers: Iterable[tuple[bytes, bytes]]) -> int | None:
|
||||
for key, value in headers:
|
||||
if bytes(key).lower() != b"content-length":
|
||||
continue
|
||||
try:
|
||||
parsed = int(bytes(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if parsed >= 0 else None
|
||||
return None
|
||||
@@ -6,18 +6,54 @@ class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=None, extra="ignore")
|
||||
|
||||
app_env: str = Field(default="dev", alias="APP_ENV")
|
||||
module_live_apply_enabled: bool | None = Field(
|
||||
default=None,
|
||||
alias="GOVOPLAN_MODULE_LIVE_APPLY_ENABLED",
|
||||
)
|
||||
|
||||
database_url: str = Field(
|
||||
default="postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev",
|
||||
alias="DATABASE_URL",
|
||||
)
|
||||
database_pool_size: int = Field(
|
||||
default=5,
|
||||
alias="GOVOPLAN_DB_POOL_SIZE",
|
||||
ge=1,
|
||||
le=100,
|
||||
)
|
||||
database_max_overflow: int = Field(
|
||||
default=10,
|
||||
alias="GOVOPLAN_DB_MAX_OVERFLOW",
|
||||
ge=0,
|
||||
le=200,
|
||||
)
|
||||
database_pool_timeout_seconds: int = Field(
|
||||
default=30,
|
||||
alias="GOVOPLAN_DB_POOL_TIMEOUT_SECONDS",
|
||||
ge=1,
|
||||
le=300,
|
||||
)
|
||||
database_pool_recycle_seconds: int = Field(
|
||||
default=1800,
|
||||
alias="GOVOPLAN_DB_POOL_RECYCLE_SECONDS",
|
||||
ge=0,
|
||||
le=86_400,
|
||||
)
|
||||
access_database_url: str | None = Field(default=None, alias="ACCESS_DATABASE_URL")
|
||||
access_db_schema: str | None = Field(default=None, alias="ACCESS_DB_SCHEMA")
|
||||
access_table_prefix: str = Field(default="access_", alias="ACCESS_TABLE_PREFIX")
|
||||
tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE")
|
||||
tenant_db_url_template: str | None = Field(default=None, alias="TENANT_DB_URL_TEMPLATE")
|
||||
tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE")
|
||||
enabled_modules: str = Field(default="tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,notifications,docs,ops", alias="ENABLED_MODULES")
|
||||
enabled_modules: str = Field(
|
||||
default=(
|
||||
"tenancy,organizations,identity,idm,access,admin,dashboard,policy,"
|
||||
"audit,campaigns,files,mail,calendar,poll,scheduling,connectors,"
|
||||
"datasources,dataflow,workflow,views,search,risk_compliance,"
|
||||
"postbox,notifications,docs,ops"
|
||||
),
|
||||
alias="ENABLED_MODULES",
|
||||
)
|
||||
migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK")
|
||||
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
|
||||
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
|
||||
@@ -47,9 +83,85 @@ class Settings(BaseSettings):
|
||||
auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE")
|
||||
auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN")
|
||||
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS")
|
||||
auth_activity_touch_interval_seconds: int = Field(
|
||||
default=5 * 60,
|
||||
ge=0,
|
||||
alias="AUTH_ACTIVITY_TOUCH_INTERVAL_SECONDS",
|
||||
)
|
||||
auth_principal_cache_enabled: bool = Field(
|
||||
default=True,
|
||||
alias="AUTH_PRINCIPAL_CACHE_ENABLED",
|
||||
)
|
||||
auth_principal_cache_session_ttl_seconds: int = Field(
|
||||
default=30,
|
||||
ge=0,
|
||||
le=300,
|
||||
alias="AUTH_PRINCIPAL_CACHE_SESSION_TTL_SECONDS",
|
||||
)
|
||||
auth_principal_cache_api_key_ttl_seconds: int = Field(
|
||||
default=10,
|
||||
ge=0,
|
||||
le=300,
|
||||
alias="AUTH_PRINCIPAL_CACHE_API_KEY_TTL_SECONDS",
|
||||
)
|
||||
auth_principal_cache_max_entries: int = Field(
|
||||
default=2048,
|
||||
ge=1,
|
||||
le=100_000,
|
||||
alias="AUTH_PRINCIPAL_CACHE_MAX_ENTRIES",
|
||||
)
|
||||
auth_login_throttle_enabled: bool = Field(default=True, alias="AUTH_LOGIN_THROTTLE_ENABLED")
|
||||
auth_login_throttle_identity_limit: int = Field(
|
||||
default=10,
|
||||
ge=1,
|
||||
alias="AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT",
|
||||
)
|
||||
auth_login_throttle_client_limit: int = Field(
|
||||
default=100,
|
||||
ge=1,
|
||||
alias="AUTH_LOGIN_THROTTLE_CLIENT_LIMIT",
|
||||
)
|
||||
auth_login_throttle_window_seconds: int = Field(
|
||||
default=15 * 60,
|
||||
ge=1,
|
||||
alias="AUTH_LOGIN_THROTTLE_WINDOW_SECONDS",
|
||||
)
|
||||
auth_login_throttle_redis_retry_seconds: int = Field(
|
||||
default=30,
|
||||
ge=1,
|
||||
alias="AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS",
|
||||
)
|
||||
|
||||
master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64")
|
||||
celery_queues: str = Field(default="send_email,append_sent,notifications,default", alias="CELERY_QUEUES")
|
||||
celery_queues: str = Field(
|
||||
default=(
|
||||
"send_email,append_sent,notifications,calendar,"
|
||||
"dataflow,events,default"
|
||||
),
|
||||
alias="CELERY_QUEUES",
|
||||
)
|
||||
calendar_outbox_terminal_retention_days: int = Field(
|
||||
default=90,
|
||||
ge=0,
|
||||
alias="CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS",
|
||||
)
|
||||
platform_event_outbox_max_attempts: int = Field(
|
||||
default=8,
|
||||
ge=1,
|
||||
le=100,
|
||||
alias="PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS",
|
||||
)
|
||||
platform_event_outbox_terminal_retention_days: int = Field(
|
||||
default=90,
|
||||
ge=0,
|
||||
alias="PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS",
|
||||
)
|
||||
scheduling_cancellation_notice_days: int = Field(
|
||||
default=30,
|
||||
ge=1,
|
||||
le=90,
|
||||
alias="SCHEDULING_CANCELLATION_NOTICE_DAYS",
|
||||
)
|
||||
mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR")
|
||||
|
||||
# Development bootstrap only. Do not use this in production.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -18,6 +20,16 @@ class EffectiveTenantGovernance:
|
||||
allow_api_keys: bool
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class _AccessTenantCountsBatchProvider(Protocol):
|
||||
def tenant_counts_many(
|
||||
self,
|
||||
session: object,
|
||||
tenant_ids: Sequence[str],
|
||||
) -> Mapping[str, Mapping[str, int]]:
|
||||
...
|
||||
|
||||
|
||||
def _narrowing_bool(system_allows: bool, tenant_override: bool | None) -> bool:
|
||||
if not system_allows:
|
||||
return False
|
||||
@@ -46,12 +58,24 @@ def assert_tenant_governance_override_allowed(session: Session, *, field: str, v
|
||||
raise AdminValidationError("Tenant governance cannot explicitly allow a capability denied by system settings.")
|
||||
|
||||
|
||||
def _tenant_module_counts(session: Session, tenant_id: str) -> dict[str, int]:
|
||||
def _tenant_module_counts(
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
*,
|
||||
module_ids: tuple[str, ...] | None = None,
|
||||
) -> dict[str, int]:
|
||||
registry = get_registry()
|
||||
if registry is None or not hasattr(registry, "tenant_summary_providers"):
|
||||
return {}
|
||||
counts: dict[str, int] = {}
|
||||
for provider in registry.tenant_summary_providers().values():
|
||||
providers = registry.tenant_summary_providers()
|
||||
if module_ids is not None:
|
||||
providers = {
|
||||
module_id: provider
|
||||
for module_id, provider in providers.items()
|
||||
if module_id in module_ids
|
||||
}
|
||||
for provider in providers.values():
|
||||
provided = provider(session, tenant_id)
|
||||
counts.update({str(key): int(value) for key, value in provided.items()})
|
||||
return counts
|
||||
@@ -67,17 +91,130 @@ def _access_administration() -> AccessAdministration | None:
|
||||
return capability
|
||||
|
||||
|
||||
def tenant_counts(session: Session, tenant_id: str) -> dict[str, int]:
|
||||
module_counts = _tenant_module_counts(session, tenant_id)
|
||||
def tenant_counts(
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
*,
|
||||
module_ids: tuple[str, ...] | None = None,
|
||||
) -> dict[str, int]:
|
||||
module_counts = _tenant_module_counts(
|
||||
session,
|
||||
tenant_id,
|
||||
module_ids=module_ids,
|
||||
)
|
||||
access_administration = _access_administration()
|
||||
access_counts = access_administration.tenant_counts(session, tenant_id) if access_administration is not None else {}
|
||||
|
||||
return {
|
||||
**module_counts,
|
||||
"users": int(access_counts.get("users", 0)),
|
||||
"active_users": int(access_counts.get("active_users", 0)),
|
||||
"groups": int(access_counts.get("groups", 0)),
|
||||
"campaigns": module_counts.get("campaigns", 0),
|
||||
"files": module_counts.get("files", 0),
|
||||
"campaigns": int(module_counts.get("campaigns", 0)),
|
||||
"files": int(module_counts.get("files", 0)),
|
||||
"api_keys": int(access_counts.get("api_keys", 0)),
|
||||
"active_api_keys": int(access_counts.get("active_api_keys", access_counts.get("api_keys", 0))),
|
||||
}
|
||||
|
||||
|
||||
def tenant_counts_many(
|
||||
session: Session,
|
||||
tenant_ids: Sequence[str],
|
||||
*,
|
||||
module_ids: tuple[str, ...] | None = None,
|
||||
) -> dict[str, dict[str, int]]:
|
||||
"""Collect list-page summaries while preserving legacy provider support."""
|
||||
|
||||
normalized_ids = tuple(
|
||||
dict.fromkeys(
|
||||
str(tenant_id).strip()
|
||||
for tenant_id in tenant_ids
|
||||
if str(tenant_id).strip()
|
||||
)
|
||||
)
|
||||
counts_by_tenant: dict[str, dict[str, int]] = {
|
||||
tenant_id: {} for tenant_id in normalized_ids
|
||||
}
|
||||
if not normalized_ids:
|
||||
return counts_by_tenant
|
||||
|
||||
registry = get_registry()
|
||||
if registry is not None and hasattr(registry, "tenant_summary_providers"):
|
||||
providers = dict(registry.tenant_summary_providers())
|
||||
if module_ids is not None:
|
||||
providers = {
|
||||
module_id: provider
|
||||
for module_id, provider in providers.items()
|
||||
if module_id in module_ids
|
||||
}
|
||||
batch_providers = (
|
||||
dict(registry.tenant_summary_batch_providers())
|
||||
if hasattr(registry, "tenant_summary_batch_providers")
|
||||
else {}
|
||||
)
|
||||
for module_id, provider in providers.items():
|
||||
batch_provider = batch_providers.get(module_id)
|
||||
if batch_provider is None:
|
||||
provided_by_tenant = {
|
||||
tenant_id: provider(session, tenant_id)
|
||||
for tenant_id in normalized_ids
|
||||
}
|
||||
else:
|
||||
provided_by_tenant = batch_provider(session, normalized_ids)
|
||||
_merge_tenant_counts(
|
||||
counts_by_tenant,
|
||||
provided_by_tenant,
|
||||
tenant_ids=normalized_ids,
|
||||
)
|
||||
|
||||
access_administration = _access_administration()
|
||||
if isinstance(access_administration, _AccessTenantCountsBatchProvider):
|
||||
access_counts = access_administration.tenant_counts_many(
|
||||
session,
|
||||
normalized_ids,
|
||||
)
|
||||
elif access_administration is not None:
|
||||
access_counts = {
|
||||
tenant_id: access_administration.tenant_counts(session, tenant_id)
|
||||
for tenant_id in normalized_ids
|
||||
}
|
||||
else:
|
||||
access_counts = {}
|
||||
_merge_tenant_counts(
|
||||
counts_by_tenant,
|
||||
access_counts,
|
||||
tenant_ids=normalized_ids,
|
||||
)
|
||||
|
||||
return {
|
||||
tenant_id: _normalized_tenant_counts(counts_by_tenant[tenant_id])
|
||||
for tenant_id in normalized_ids
|
||||
}
|
||||
|
||||
|
||||
def _merge_tenant_counts(
|
||||
target: dict[str, dict[str, int]],
|
||||
provided_by_tenant: Mapping[str, Mapping[str, int]],
|
||||
*,
|
||||
tenant_ids: Sequence[str],
|
||||
) -> None:
|
||||
for tenant_id in tenant_ids:
|
||||
provided = provided_by_tenant.get(tenant_id, {})
|
||||
target[tenant_id].update(
|
||||
{str(key): int(value) for key, value in provided.items()}
|
||||
)
|
||||
|
||||
|
||||
def _normalized_tenant_counts(counts: Mapping[str, int]) -> dict[str, int]:
|
||||
return {
|
||||
**{str(key): int(value) for key, value in counts.items()},
|
||||
"users": int(counts.get("users", 0)),
|
||||
"active_users": int(counts.get("active_users", 0)),
|
||||
"groups": int(counts.get("groups", 0)),
|
||||
"campaigns": int(counts.get("campaigns", 0)),
|
||||
"files": int(counts.get("files", 0)),
|
||||
"api_keys": int(counts.get("api_keys", 0)),
|
||||
"active_api_keys": int(
|
||||
counts.get("active_api_keys", counts.get("api_keys", 0))
|
||||
),
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ from govoplan_core.core.access import (
|
||||
CAPABILITY_AUDIT_RECORDER,
|
||||
CAPABILITY_AUDIT_RETENTION,
|
||||
CAPABILITY_AUDIT_SINK,
|
||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
||||
CAPABILITY_TENANCY_TENANT_RESOLVER,
|
||||
AccessDecision,
|
||||
AccessAdministration,
|
||||
@@ -83,7 +84,6 @@ from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.runtime import clear_runtime, configure_runtime
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.server.app import create_app
|
||||
from govoplan_core.server.config import GovoplanServerConfig
|
||||
from govoplan_core.tenancy.scope import create_scope_tables
|
||||
from govoplan_access.backend.db.models import (
|
||||
@@ -1083,16 +1083,62 @@ class AccessContractTests(unittest.TestCase):
|
||||
def get_organization_function_assignment(self, requested_assignment_id: str):
|
||||
return assignment if requested_assignment_id == assignment_id else None
|
||||
|
||||
def organization_function_assignments_for_identity(self, requested_identity_id: str, *, tenant_id: str | None = None):
|
||||
def organization_function_assignments_for_identity(
|
||||
self,
|
||||
requested_identity_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at=None,
|
||||
):
|
||||
del effective_at
|
||||
if requested_identity_id == identity_id and tenant_id in (None, assignment.tenant_id):
|
||||
return (assignment,)
|
||||
return ()
|
||||
|
||||
def organization_function_assignments_for_account(self, requested_account_id: str, *, tenant_id: str | None = None):
|
||||
def organization_function_assignments_for_account(
|
||||
self,
|
||||
requested_account_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at=None,
|
||||
):
|
||||
del effective_at
|
||||
if requested_account_id == account_id and tenant_id in (None, assignment.tenant_id):
|
||||
return (assignment,)
|
||||
return ()
|
||||
|
||||
def organization_function_assignments_for_identities(
|
||||
self,
|
||||
identity_ids,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at=None,
|
||||
):
|
||||
return {
|
||||
item_id: self.organization_function_assignments_for_identity(
|
||||
item_id,
|
||||
tenant_id=tenant_id,
|
||||
effective_at=effective_at,
|
||||
)
|
||||
for item_id in identity_ids
|
||||
}
|
||||
|
||||
def organization_function_assignments_for_accounts(
|
||||
self,
|
||||
account_ids,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at=None,
|
||||
):
|
||||
return {
|
||||
item_id: self.organization_function_assignments_for_account(
|
||||
item_id,
|
||||
tenant_id=tenant_id,
|
||||
effective_at=effective_at,
|
||||
)
|
||||
for item_id in account_ids
|
||||
}
|
||||
|
||||
class FakeOrganizationDirectory(CoreOrganizationDirectory):
|
||||
def get_organization_unit(self, requested_organization_unit_id: str):
|
||||
if requested_organization_unit_id != organization_unit_id:
|
||||
@@ -1318,7 +1364,7 @@ class AccessContractTests(unittest.TestCase):
|
||||
clear_runtime()
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
def test_api_principal_dependency_uses_access_resolver_capability(self) -> None:
|
||||
def test_api_principal_dependency_uses_auth_provider_capability(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-access-contract-"))
|
||||
try:
|
||||
account_id = "account-capability"
|
||||
@@ -1347,6 +1393,31 @@ class AccessContractTests(unittest.TestCase):
|
||||
def explain_scope(self, principal: PrincipalRef, required_scope: str) -> AccessDecision:
|
||||
return AccessDecision(allowed=self.has_scope(principal, required_scope), requirements=(required_scope,))
|
||||
|
||||
class CapabilityApiPrincipalProvider:
|
||||
def resolve_api_principal(
|
||||
self,
|
||||
request,
|
||||
session,
|
||||
*,
|
||||
authorization=None,
|
||||
x_api_key=None,
|
||||
):
|
||||
del authorization, x_api_key
|
||||
principal_ref = CapabilityPrincipalResolver().resolve_request(
|
||||
request,
|
||||
session=session,
|
||||
)
|
||||
account = session.get(Account, principal_ref.account_id)
|
||||
user = session.get(User, principal_ref.membership_id)
|
||||
assert account is not None
|
||||
assert user is not None
|
||||
return ApiPrincipal(
|
||||
principal=principal_ref,
|
||||
account=account,
|
||||
user=user,
|
||||
permission_evaluator=CapabilityPermissionEvaluator(),
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/capability-principal")
|
||||
@@ -1366,6 +1437,7 @@ class AccessContractTests(unittest.TestCase):
|
||||
capability_factories={
|
||||
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER: lambda context: CapabilityPrincipalResolver(),
|
||||
CAPABILITY_ACCESS_PERMISSION_EVALUATOR: lambda context: CapabilityPermissionEvaluator(),
|
||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER: lambda context: CapabilityApiPrincipalProvider(),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1373,7 +1445,7 @@ class AccessContractTests(unittest.TestCase):
|
||||
title="access contract test",
|
||||
version="test",
|
||||
settings=SimpleNamespace(database_url=f"sqlite:///{root / 'test.db'}"),
|
||||
enabled_modules=(),
|
||||
enabled_modules=(ACCESS_MODULE_ID,),
|
||||
manifest_factories=(access_manifest,),
|
||||
base_routers=(router,),
|
||||
post_module_routers=(),
|
||||
@@ -1382,6 +1454,8 @@ class AccessContractTests(unittest.TestCase):
|
||||
app_configurators=(),
|
||||
)
|
||||
with temporary_database(f"sqlite:///{root / 'test.db'}") as database:
|
||||
from govoplan_core.server.app import create_app
|
||||
|
||||
app = create_app(config)
|
||||
|
||||
create_scope_tables(database.engine)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
96
tests/test_automation_contract.py
Normal file
96
tests/test_automation_contract.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.automation import (
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||
AutomationInvocation,
|
||||
AutomationPrincipalProvider,
|
||||
AutomationPrincipalRequest,
|
||||
AutomationPrincipalResolution,
|
||||
automation_principal_provider,
|
||||
)
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
|
||||
|
||||
class _Provider:
|
||||
def resolve_automation_principal(self, session, *, request):
|
||||
del session
|
||||
return AutomationPrincipalResolution(
|
||||
allowed=True,
|
||||
principal={"account_id": request.account_id},
|
||||
granted_scopes=request.grant_scopes,
|
||||
)
|
||||
|
||||
|
||||
class AutomationContractTests(unittest.TestCase):
|
||||
def test_principal_request_has_explicit_subject_contracts(self) -> None:
|
||||
service = AutomationPrincipalRequest.service_account(
|
||||
tenant_id="tenant-1",
|
||||
service_account_id="service-1",
|
||||
authorization_ref="trigger:1",
|
||||
grant_scopes=("dataflow:pipeline:run",),
|
||||
)
|
||||
|
||||
self.assertEqual("service_account", service.subject_kind)
|
||||
self.assertEqual("service-1", service.service_account_id)
|
||||
self.assertIsNone(service.account_id)
|
||||
with self.assertRaisesRegex(
|
||||
ValueError,
|
||||
"Delegated-user automation",
|
||||
):
|
||||
AutomationPrincipalRequest(
|
||||
tenant_id="tenant-1",
|
||||
authorization_ref="trigger:1",
|
||||
grant_scopes=("dataflow:pipeline:run",),
|
||||
)
|
||||
|
||||
def test_invocation_records_stable_trigger_provenance(self) -> None:
|
||||
invocation = AutomationInvocation(
|
||||
kind="event",
|
||||
trigger_ref="dataflow-trigger:1",
|
||||
event_id="event-1",
|
||||
event_type="files.uploaded",
|
||||
)
|
||||
|
||||
self.assertEqual("event", invocation.kind)
|
||||
self.assertEqual("event-1", invocation.event_id)
|
||||
|
||||
def test_principal_provider_is_runtime_resolved(self) -> None:
|
||||
provider = _Provider()
|
||||
self.assertIsInstance(provider, AutomationPrincipalProvider)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="automation_contract_test",
|
||||
name="Automation contract test",
|
||||
version="test",
|
||||
capability_factories={
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER: (
|
||||
lambda context: provider
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
registry.configure_capability_context(
|
||||
ModuleContext(registry=registry, settings=object())
|
||||
)
|
||||
|
||||
self.assertIs(provider, automation_principal_provider(registry))
|
||||
result = provider.resolve_automation_principal(
|
||||
object(),
|
||||
request=AutomationPrincipalRequest(
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-1",
|
||||
membership_id="membership-1",
|
||||
authorization_ref="trigger:1",
|
||||
grant_scopes=("dataflow:pipeline:run",),
|
||||
),
|
||||
)
|
||||
self.assertTrue(result.allowed)
|
||||
self.assertEqual(("dataflow:pipeline:run",), result.granted_scopes)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
48
tests/test_calendar_outbox_worker.py
Normal file
48
tests/test_calendar_outbox_worker.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from govoplan_core.celery_app import celery, dispatch_calendar_outbox
|
||||
|
||||
|
||||
class CalendarOutboxWorkerTests(unittest.TestCase):
|
||||
def test_worker_commits_provider_outcome(self) -> None:
|
||||
session = MagicMock()
|
||||
database = MagicMock()
|
||||
database.SessionLocal.return_value.__enter__.return_value = session
|
||||
provider = MagicMock()
|
||||
provider.dispatch_due.return_value = {
|
||||
"processed": 1,
|
||||
"succeeded": 1,
|
||||
"retrying": 0,
|
||||
"failed": 0,
|
||||
"operations": [{"id": "operation-1", "status": "succeeded"}],
|
||||
}
|
||||
|
||||
with (
|
||||
patch("govoplan_core.celery_app._calendar_outbox", return_value=provider),
|
||||
patch("govoplan_core.db.session.get_database", return_value=database),
|
||||
):
|
||||
result = dispatch_calendar_outbox.run("tenant-1", 25)
|
||||
|
||||
provider.dispatch_due.assert_called_once_with(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
limit=25,
|
||||
)
|
||||
session.commit.assert_called_once_with()
|
||||
self.assertEqual(result["succeeded"], 1)
|
||||
|
||||
def test_calendar_worker_route_and_periodic_recovery_are_registered(self) -> None:
|
||||
self.assertEqual(
|
||||
celery.conf.task_routes["govoplan.calendar.dispatch_outbox"],
|
||||
{"queue": "calendar"},
|
||||
)
|
||||
schedule = celery.conf.beat_schedule["calendar-outbox-every-minute"]
|
||||
self.assertEqual(schedule["task"], "govoplan.calendar.dispatch_outbox")
|
||||
self.assertEqual(schedule["schedule"], 60.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -19,10 +19,25 @@ from govoplan_core.core.change_sequence import (
|
||||
sequence_watermark_is_expired,
|
||||
)
|
||||
from govoplan_core.core.events import EventActorRef, EventBus, EventObjectRef, EventTenantRef, PlatformEvent, event_bus_context
|
||||
from govoplan_core.core.runtime import (
|
||||
clear_runtime,
|
||||
configure_runtime,
|
||||
get_runtime_context,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
class ChangeSequenceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._runtime_context = get_runtime_context()
|
||||
clear_runtime()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
if self._runtime_context is not None:
|
||||
configure_runtime(self._runtime_context)
|
||||
else:
|
||||
clear_runtime()
|
||||
|
||||
def test_records_changes_and_returns_entries_after_watermark(self) -> None:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
SessionLocal = sessionmaker(bind=engine)
|
||||
@@ -93,6 +108,8 @@ class ChangeSequenceTests(unittest.TestCase):
|
||||
actor_id="user-1",
|
||||
payload={"scope_type": "tenant"},
|
||||
)
|
||||
self.assertEqual([], seen)
|
||||
session.commit()
|
||||
|
||||
self.assertEqual([case[-1] for case in cases], [event.type for event in seen])
|
||||
event = seen[1]
|
||||
@@ -105,6 +122,36 @@ class ChangeSequenceTests(unittest.TestCase):
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
def test_record_change_discards_event_when_transaction_rolls_back(self) -> None:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
SessionLocal = sessionmaker(bind=engine)
|
||||
try:
|
||||
Base.metadata.create_all(
|
||||
bind=engine,
|
||||
tables=[
|
||||
ChangeSequenceEntry.__table__,
|
||||
ChangeSequenceRetentionFloor.__table__,
|
||||
],
|
||||
)
|
||||
seen: list[PlatformEvent] = []
|
||||
bus = EventBus()
|
||||
bus.subscribe("*", seen.append)
|
||||
with SessionLocal() as session, event_bus_context(bus):
|
||||
record_change(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
module_id="files",
|
||||
collection="files.assets",
|
||||
resource_type="file",
|
||||
resource_id="file-1",
|
||||
operation="created",
|
||||
)
|
||||
session.rollback()
|
||||
|
||||
self.assertEqual([], seen)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
def test_pruning_records_retention_floor_for_stale_watermarks(self) -> None:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
SessionLocal = sessionmaker(bind=engine)
|
||||
|
||||
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Response
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from govoplan_core.auth import get_api_principal
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.server.fastapi import create_govoplan_app
|
||||
from govoplan_core.server.platform import create_platform_router
|
||||
@@ -85,9 +86,14 @@ class ConditionalRequestTests(unittest.TestCase):
|
||||
registry=PlatformRegistry(),
|
||||
api_router=api_router,
|
||||
)
|
||||
app.dependency_overrides[get_api_principal] = lambda: object()
|
||||
|
||||
with TestClient(app) as client:
|
||||
for path in ("/api/v1/platform/status", "/api/v1/platform/modules"):
|
||||
for path in (
|
||||
"/api/v1/platform/status",
|
||||
"/api/v1/platform/modules",
|
||||
"/api/v1/platform/public-modules",
|
||||
):
|
||||
first = client.get(path)
|
||||
self.assertEqual(200, first.status_code, first.text)
|
||||
etag = first.headers.get("etag")
|
||||
|
||||
@@ -4,18 +4,23 @@ import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.audit.logging import audit_event, audit_operation_context
|
||||
from govoplan_core.core.events import (
|
||||
DurableEventConsumer,
|
||||
EventActorRef,
|
||||
EventBus,
|
||||
EventObjectRef,
|
||||
EventTenantRef,
|
||||
PlatformEvent,
|
||||
current_event_trace,
|
||||
emit_platform_event,
|
||||
event_bus_context,
|
||||
event_context,
|
||||
normalize_trace_id,
|
||||
@@ -30,13 +35,96 @@ from tests.db_isolation import temporary_database
|
||||
|
||||
|
||||
def _configure_audit_runtime() -> None:
|
||||
registry = build_platform_registry(("audit",))
|
||||
registry = build_platform_registry(("access", "audit"))
|
||||
context = ModuleContext(registry=registry, settings=object())
|
||||
registry.configure_capability_context(context)
|
||||
configure_runtime(context)
|
||||
|
||||
|
||||
class CoreEventTests(unittest.TestCase):
|
||||
def test_durable_consumer_requires_policy_for_classified_events(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "policy decision"):
|
||||
DurableEventConsumer(
|
||||
consumer_id="workflow.triggers.v1",
|
||||
classifications=frozenset({"restricted"}),
|
||||
handler=lambda _event, _delivery_key: None,
|
||||
)
|
||||
|
||||
consumer = DurableEventConsumer(
|
||||
consumer_id="workflow.triggers.v1",
|
||||
event_types=frozenset({"case.changed"}),
|
||||
classifications=frozenset(
|
||||
{"internal", "confidential"}
|
||||
),
|
||||
policy_decision_ref="policy:decision:1",
|
||||
handler=lambda _event, _delivery_key: None,
|
||||
)
|
||||
accepted = PlatformEvent(
|
||||
type="case.changed",
|
||||
module_id="cases",
|
||||
classification="confidential",
|
||||
event_id="event-1",
|
||||
)
|
||||
rejected = PlatformEvent(
|
||||
type="case.deleted",
|
||||
module_id="cases",
|
||||
classification="confidential",
|
||||
)
|
||||
|
||||
self.assertTrue(consumer.accepts(accepted))
|
||||
self.assertFalse(consumer.accepts(rejected))
|
||||
self.assertEqual(
|
||||
"event-1:workflow.triggers.v1",
|
||||
consumer.delivery_key(accepted),
|
||||
)
|
||||
|
||||
def test_fallback_event_waits_for_outer_commit_after_savepoint(self) -> None:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
seen: list[PlatformEvent] = []
|
||||
bus = EventBus()
|
||||
bus.subscribe("*", seen.append)
|
||||
try:
|
||||
with Session(engine) as session, event_bus_context(bus):
|
||||
with session.begin():
|
||||
with session.begin_nested():
|
||||
emit_platform_event(
|
||||
session,
|
||||
PlatformEvent(type="nested.created", module_id="core"),
|
||||
)
|
||||
self.assertEqual([], seen)
|
||||
self.assertEqual(
|
||||
["nested.created"],
|
||||
[event.type for event in seen],
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
def test_fallback_event_discards_only_rolled_back_savepoint(self) -> None:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
seen: list[PlatformEvent] = []
|
||||
bus = EventBus()
|
||||
bus.subscribe("*", seen.append)
|
||||
try:
|
||||
with Session(engine) as session, event_bus_context(bus):
|
||||
with session.begin():
|
||||
emit_platform_event(
|
||||
session,
|
||||
PlatformEvent(type="outer.created", module_id="core"),
|
||||
)
|
||||
savepoint = session.begin_nested()
|
||||
emit_platform_event(
|
||||
session,
|
||||
PlatformEvent(type="nested.created", module_id="core"),
|
||||
)
|
||||
savepoint.rollback()
|
||||
self.assertEqual([], seen)
|
||||
self.assertEqual(
|
||||
["outer.created"],
|
||||
[event.type for event in seen],
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
def test_event_bus_adds_trace_ids_and_propagates_causation_to_nested_events(self) -> None:
|
||||
bus = EventBus()
|
||||
seen: list[PlatformEvent] = []
|
||||
@@ -142,6 +230,107 @@ class CoreEventTests(unittest.TestCase):
|
||||
cors_origins=("*",),
|
||||
)
|
||||
|
||||
def test_app_sets_safe_default_browser_headers_and_https_hsts(self) -> None:
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"APP_ENV": "test", "GOVOPLAN_HTTP_HSTS_SECONDS": "86400"},
|
||||
):
|
||||
app = create_govoplan_app(
|
||||
title="security header test",
|
||||
version="test",
|
||||
registry=PlatformRegistry(),
|
||||
)
|
||||
|
||||
with TestClient(app, base_url="https://govoplan.example.test") as client:
|
||||
response = client.get("/health")
|
||||
|
||||
self.assertEqual("nosniff", response.headers["X-Content-Type-Options"])
|
||||
self.assertEqual("DENY", response.headers["X-Frame-Options"])
|
||||
self.assertEqual("strict-origin-when-cross-origin", response.headers["Referrer-Policy"])
|
||||
self.assertIn("frame-ancestors 'none'", response.headers["Content-Security-Policy"])
|
||||
self.assertEqual("max-age=86400", response.headers["Strict-Transport-Security"])
|
||||
|
||||
def test_slow_request_log_excludes_query_headers_and_cookies(self) -> None:
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/slow")
|
||||
def slow() -> dict[str, bool]:
|
||||
return {"ok": True}
|
||||
|
||||
with patch("govoplan_core.server.fastapi._slow_request_threshold_ms", return_value=0.0001):
|
||||
app = create_govoplan_app(
|
||||
title="request log redaction test",
|
||||
version="test",
|
||||
registry=PlatformRegistry(),
|
||||
api_router=router,
|
||||
)
|
||||
|
||||
with self.assertLogs("govoplan.request", level="WARNING") as captured:
|
||||
with TestClient(app) as client:
|
||||
response = client.get(
|
||||
"/slow?token=query-secret",
|
||||
headers={
|
||||
"Authorization": "Bearer header-secret",
|
||||
"Cookie": "govoplan_session=cookie-secret",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
rendered = "\n".join(captured.output)
|
||||
self.assertIn("path=/slow", rendered)
|
||||
self.assertNotIn("query-secret", rendered)
|
||||
self.assertNotIn("header-secret", rendered)
|
||||
self.assertNotIn("cookie-secret", rendered)
|
||||
|
||||
def test_production_app_startup_rejects_an_unvalidated_environment(self) -> None:
|
||||
with patch.dict("os.environ", {"APP_ENV": "production"}, clear=True), self.assertRaisesRegex(
|
||||
RuntimeError,
|
||||
"GovOPlaN configuration validation: FAILED",
|
||||
):
|
||||
create_govoplan_app(
|
||||
title="unsafe production test",
|
||||
version="test",
|
||||
registry=PlatformRegistry(),
|
||||
)
|
||||
|
||||
def test_app_rejects_request_bodies_above_deployment_limit(self) -> None:
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/body")
|
||||
async def body(request: Request):
|
||||
return {"size": len(await request.body())}
|
||||
|
||||
with patch.dict("os.environ", {"GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES": "10"}):
|
||||
app = create_govoplan_app(
|
||||
title="request body limit test",
|
||||
version="test",
|
||||
registry=PlatformRegistry(),
|
||||
api_router=router,
|
||||
)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/body", content=b"12345678901")
|
||||
streamed = client.post("/body", content=(chunk for chunk in (b"123456", b"78901")))
|
||||
|
||||
self.assertEqual(413, response.status_code, response.text)
|
||||
self.assertIn("10 bytes", response.json()["detail"])
|
||||
self.assertEqual(413, streamed.status_code, streamed.text)
|
||||
|
||||
def test_app_enforces_configured_trusted_hosts(self) -> None:
|
||||
with patch.dict("os.environ", {"GOVOPLAN_TRUSTED_HOSTS": "govoplan.example.test,*.internal.test"}):
|
||||
app = create_govoplan_app(
|
||||
title="trusted host test",
|
||||
version="test",
|
||||
registry=PlatformRegistry(),
|
||||
)
|
||||
|
||||
with TestClient(app, base_url="https://govoplan.example.test") as client:
|
||||
allowed = client.get("/health")
|
||||
rejected = client.get("https://attacker.example.test/health")
|
||||
|
||||
self.assertEqual(200, allowed.status_code, allowed.text)
|
||||
self.assertEqual(400, rejected.status_code, rejected.text)
|
||||
|
||||
def test_audit_event_persists_trace_details_from_event_context(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-audit-trace-"))
|
||||
try:
|
||||
@@ -197,6 +386,11 @@ class CoreEventTests(unittest.TestCase):
|
||||
object_id="user-2",
|
||||
details={"password": "secret", "field": "display_name"},
|
||||
)
|
||||
session.commit()
|
||||
from govoplan_audit.backend.outbox import SqlAuditOutbox
|
||||
|
||||
SqlAuditOutbox().dispatch_pending(session)
|
||||
session.commit()
|
||||
|
||||
action_events = [event for event in seen if event.type == "user.updated"]
|
||||
self.assertEqual(1, len(action_events))
|
||||
@@ -245,6 +439,11 @@ class CoreEventTests(unittest.TestCase):
|
||||
object_type="demo",
|
||||
object_id=action,
|
||||
)
|
||||
session.commit()
|
||||
from govoplan_audit.backend.outbox import SqlAuditOutbox
|
||||
|
||||
SqlAuditOutbox().dispatch_pending(session)
|
||||
session.commit()
|
||||
|
||||
by_type = {event.type: event.module_id for event in seen if event.type in cases}
|
||||
self.assertEqual(cases, by_type)
|
||||
|
||||
245
tests/test_credential_envelopes.py
Normal file
245
tests/test_credential_envelopes.py
Normal file
@@ -0,0 +1,245 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.security.credential_envelopes import (
|
||||
CredentialAccessContext,
|
||||
CredentialEnvelope,
|
||||
CredentialEnvelopeError,
|
||||
create_credential_envelope,
|
||||
credential_envelope_summary,
|
||||
credential_visible_to_context,
|
||||
list_managed_credential_envelopes,
|
||||
resolve_credential_envelope,
|
||||
update_credential_envelope,
|
||||
)
|
||||
from govoplan_core.security.secrets import encrypt_secret
|
||||
from govoplan_core.server.credentials import _target_scope
|
||||
|
||||
|
||||
def _principal(scopes: set[str]) -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
scopes=frozenset(scopes),
|
||||
),
|
||||
account=object(),
|
||||
user=object(),
|
||||
)
|
||||
|
||||
|
||||
class CredentialEnvelopeTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
ChangeSequenceEntry.__table__.create(self.engine)
|
||||
CredentialEnvelope.__table__.create(self.engine)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.engine.dispose()
|
||||
|
||||
def test_inherited_credential_is_filtered_by_module_and_server(self) -> None:
|
||||
row = CredentialEnvelope(
|
||||
id="credential-1",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
name="Shared account",
|
||||
credential_kind="username_password",
|
||||
public_data={"username": "service@example.org"},
|
||||
secret_data_encrypted=encrypt_secret(json.dumps({"password": "not-returned"})),
|
||||
secret_keys=["password"],
|
||||
allowed_modules=["mail"],
|
||||
allowed_server_refs=["mail:server-1"],
|
||||
inherit_to_lower_scopes=True,
|
||||
is_active=True,
|
||||
revision="revision-1",
|
||||
)
|
||||
allowed = CredentialAccessContext(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
target_scope_type="user",
|
||||
target_scope_id="user-1",
|
||||
module_id="mail",
|
||||
server_ref="mail:server-1",
|
||||
)
|
||||
wrong_module = CredentialAccessContext(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
target_scope_type="user",
|
||||
target_scope_id="user-1",
|
||||
module_id="calendar",
|
||||
server_ref="mail:server-1",
|
||||
)
|
||||
wrong_server = CredentialAccessContext(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
target_scope_type="user",
|
||||
target_scope_id="user-1",
|
||||
module_id="mail",
|
||||
server_ref="mail:server-2",
|
||||
)
|
||||
|
||||
self.assertTrue(credential_visible_to_context(row, allowed))
|
||||
self.assertFalse(credential_visible_to_context(row, wrong_module))
|
||||
self.assertFalse(credential_visible_to_context(row, wrong_server))
|
||||
self.assertNotIn("secret_data_encrypted", credential_envelope_summary(row))
|
||||
|
||||
def test_resolution_returns_secret_only_after_access_check(self) -> None:
|
||||
with Session(self.engine) as session:
|
||||
session.add(
|
||||
CredentialEnvelope(
|
||||
id="credential-1",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
name="Shared account",
|
||||
credential_kind="username_password",
|
||||
public_data={"username": "service@example.org"},
|
||||
secret_data_encrypted=encrypt_secret(json.dumps({"password": "secret"})),
|
||||
secret_keys=["password"],
|
||||
allowed_modules=["mail"],
|
||||
allowed_server_refs=[],
|
||||
inherit_to_lower_scopes=True,
|
||||
is_active=True,
|
||||
revision="revision-1",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
resolved = resolve_credential_envelope(
|
||||
session,
|
||||
credential_id="credential-1",
|
||||
context=CredentialAccessContext(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
target_scope_type="user",
|
||||
target_scope_id="user-1",
|
||||
module_id="mail",
|
||||
),
|
||||
)
|
||||
self.assertEqual(resolved.public_data["username"], "service@example.org")
|
||||
self.assertEqual(resolved.secret_data["password"], "secret")
|
||||
|
||||
with self.assertRaises(CredentialEnvelopeError):
|
||||
resolve_credential_envelope(
|
||||
session,
|
||||
credential_id="credential-1",
|
||||
context=CredentialAccessContext(
|
||||
tenant_id="tenant-2",
|
||||
user_id="user-2",
|
||||
target_scope_type="user",
|
||||
target_scope_id="user-2",
|
||||
module_id="mail",
|
||||
),
|
||||
)
|
||||
|
||||
def test_management_listing_stays_in_tenant_but_ignores_use_site_limits(self) -> None:
|
||||
with Session(self.engine) as session:
|
||||
session.add_all(
|
||||
[
|
||||
CredentialEnvelope(
|
||||
id="tenant-credential",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
name="Tenant restricted",
|
||||
credential_kind="username_password",
|
||||
public_data={},
|
||||
secret_keys=[],
|
||||
allowed_modules=["calendar"],
|
||||
allowed_server_refs=["calendar:source-1"],
|
||||
inherit_to_lower_scopes=True,
|
||||
is_active=True,
|
||||
revision="revision-1",
|
||||
),
|
||||
CredentialEnvelope(
|
||||
id="other-credential",
|
||||
tenant_id="tenant-2",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-2",
|
||||
name="Other tenant",
|
||||
credential_kind="username_password",
|
||||
public_data={},
|
||||
secret_keys=[],
|
||||
allowed_modules=[],
|
||||
allowed_server_refs=[],
|
||||
inherit_to_lower_scopes=False,
|
||||
is_active=True,
|
||||
revision="revision-2",
|
||||
),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
rows = list_managed_credential_envelopes(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
)
|
||||
|
||||
self.assertEqual([row.id for row in rows], ["tenant-credential"])
|
||||
|
||||
def test_tenant_credential_permission_cannot_manage_system_scope(self) -> None:
|
||||
tenant_principal = _principal({"access:credential:write"})
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
_target_scope(
|
||||
tenant_principal,
|
||||
scope_type="system",
|
||||
scope_id=None,
|
||||
write=True,
|
||||
)
|
||||
self.assertEqual(raised.exception.status_code, 403)
|
||||
|
||||
system_principal = _principal({"access:system_credential:write"})
|
||||
self.assertEqual(
|
||||
_target_scope(
|
||||
system_principal,
|
||||
scope_type="system",
|
||||
scope_id=None,
|
||||
write=True,
|
||||
),
|
||||
(None, "system", None),
|
||||
)
|
||||
|
||||
def test_public_data_rejects_secrets_and_kind_changes_require_secret_decision(self) -> None:
|
||||
with Session(self.engine) as session:
|
||||
with self.assertRaisesRegex(CredentialEnvelopeError, "public_data"):
|
||||
create_credential_envelope(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
name="Unsafe",
|
||||
credential_kind="username_password",
|
||||
public_data={"nested": [{"clientSecret": "not-public"}]},
|
||||
)
|
||||
|
||||
row = create_credential_envelope(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
name="Safe",
|
||||
credential_kind="username_password",
|
||||
public_data={"username": "service@example.org"},
|
||||
secret_data={"password": "secret"},
|
||||
)
|
||||
with self.assertRaisesRegex(CredentialEnvelopeError, "requires replacing"):
|
||||
update_credential_envelope(
|
||||
session,
|
||||
row,
|
||||
credential_kind="token",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
@@ -39,6 +40,22 @@ def database_migration_heads(connection) -> set[str]:
|
||||
|
||||
|
||||
class DatabaseMigrationTests(unittest.TestCase):
|
||||
def test_migration_logging_keeps_application_loggers_enabled(self) -> None:
|
||||
logger = logging.getLogger("govoplan.request")
|
||||
previous_disabled = logger.disabled
|
||||
logger.disabled = False
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-migration-logging-test-") as directory:
|
||||
database = Path(directory) / "logging.db"
|
||||
command.stamp(
|
||||
alembic_config(database_url=f"sqlite:///{database}", enabled_modules=()),
|
||||
"heads",
|
||||
)
|
||||
|
||||
self.assertFalse(logger.disabled)
|
||||
finally:
|
||||
logger.disabled = previous_disabled
|
||||
|
||||
def test_migration_tracks_use_separate_version_locations(self) -> None:
|
||||
release_locations = alembic_config(database_url="sqlite:////tmp/govoplan-release.db").get_main_option("version_locations")
|
||||
dev_locations = alembic_config(
|
||||
@@ -114,8 +131,15 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-core-baseline-test-") as directory:
|
||||
database = Path(directory) / "core.db"
|
||||
url = f"sqlite:///{database}"
|
||||
enabled_modules = ("access",)
|
||||
|
||||
command.upgrade(alembic_config(database_url=url, enabled_modules=()), "heads")
|
||||
command.upgrade(
|
||||
alembic_config(
|
||||
database_url=url,
|
||||
enabled_modules=enabled_modules,
|
||||
),
|
||||
"heads",
|
||||
)
|
||||
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
@@ -140,7 +164,13 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
).scalar_one()
|
||||
current = database_migration_heads(connection)
|
||||
|
||||
self.assertEqual(current, configured_migration_heads(url, enabled_modules=()))
|
||||
self.assertEqual(
|
||||
current,
|
||||
configured_migration_heads(
|
||||
url,
|
||||
enabled_modules=enabled_modules,
|
||||
),
|
||||
)
|
||||
self.assertIn("core_scopes", tables)
|
||||
self.assertNotIn("tenancy_tenants", tables)
|
||||
self.assertIn("access_accounts", tables)
|
||||
@@ -184,6 +214,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
self.assertIsNone(result.reconciled_revision)
|
||||
self.assertEqual(current, configured_migration_heads(url))
|
||||
self.assertEqual(result.current_revision, ",".join(sorted(current)))
|
||||
self.assertIn("calendar_outbox_operations", tables)
|
||||
self.assertIn("calendar_sync_credentials", tables)
|
||||
self.assertIn("campaign_recipient_import_mapping_profiles", tables)
|
||||
self.assertIn("file_connector_credentials", tables)
|
||||
@@ -214,8 +245,9 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(current, configured_migration_heads(url, migration_track="dev"))
|
||||
self.assertEqual(result.current_revision, ",".join(sorted(current)))
|
||||
self.assertIn("0f1e2d3c4b5a", current)
|
||||
self.assertIn("core_credential_envelopes", tables)
|
||||
self.assertIn("audit_outbox_events", tables)
|
||||
self.assertIn("calendar_outbox_operations", tables)
|
||||
self.assertIn("file_connector_profiles", tables)
|
||||
self.assertIn("core_scopes", tables)
|
||||
finally:
|
||||
|
||||
99
tests/test_dataflow_contract.py
Normal file
99
tests/test_dataflow_contract.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.dataflows import (
|
||||
CAPABILITY_DATAFLOW_RUN_LIFECYCLE,
|
||||
CAPABILITY_DATAFLOW_RUN_WORKER,
|
||||
DataflowRunDescriptor,
|
||||
DataflowRunLifecycleProvider,
|
||||
DataflowRunWorker,
|
||||
dataflow_run_lifecycle,
|
||||
dataflow_run_worker,
|
||||
)
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
|
||||
|
||||
class _Provider:
|
||||
def start_run(self, session, principal, *, request):
|
||||
del session, principal
|
||||
return DataflowRunDescriptor(
|
||||
ref="dataflow-run:1",
|
||||
pipeline_ref=request.pipeline_ref,
|
||||
revision=request.revision,
|
||||
status="succeeded",
|
||||
definition_hash="abc",
|
||||
executor_version="test",
|
||||
)
|
||||
|
||||
def get_run(self, session, principal, *, run_ref):
|
||||
del session, principal
|
||||
if run_ref != "dataflow-run:1":
|
||||
return None
|
||||
return DataflowRunDescriptor(
|
||||
ref=run_ref,
|
||||
pipeline_ref="pipeline:1",
|
||||
revision=1,
|
||||
status="succeeded",
|
||||
definition_hash="abc",
|
||||
executor_version="test",
|
||||
)
|
||||
|
||||
def cancel_run(self, session, principal, *, run_ref):
|
||||
del session, principal
|
||||
return DataflowRunDescriptor(
|
||||
ref=run_ref,
|
||||
pipeline_ref="pipeline:1",
|
||||
revision=1,
|
||||
status="cancelled",
|
||||
definition_hash="abc",
|
||||
executor_version="test",
|
||||
)
|
||||
|
||||
|
||||
class _Worker:
|
||||
def dispatch_pending(
|
||||
self,
|
||||
session,
|
||||
*,
|
||||
now=None,
|
||||
limit=10,
|
||||
worker_id=None,
|
||||
):
|
||||
return {"claimed": 0}
|
||||
|
||||
def purge_expired(self, session, *, now=None, limit=500):
|
||||
return {"purged": 0}
|
||||
|
||||
|
||||
class DataflowContractTests(unittest.TestCase):
|
||||
def test_run_lifecycle_is_runtime_checkable_and_resolved(self) -> None:
|
||||
provider = _Provider()
|
||||
worker = _Worker()
|
||||
self.assertIsInstance(provider, DataflowRunLifecycleProvider)
|
||||
self.assertIsInstance(worker, DataflowRunWorker)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="dataflow_contract_test",
|
||||
name="Dataflow contract test",
|
||||
version="test",
|
||||
capability_factories={
|
||||
CAPABILITY_DATAFLOW_RUN_LIFECYCLE: lambda context: provider,
|
||||
CAPABILITY_DATAFLOW_RUN_WORKER: lambda context: worker,
|
||||
},
|
||||
)
|
||||
)
|
||||
registry.configure_capability_context(
|
||||
ModuleContext(registry=registry, settings=object())
|
||||
)
|
||||
|
||||
self.assertIs(provider, dataflow_run_lifecycle(registry))
|
||||
self.assertIs(worker, dataflow_run_worker(registry))
|
||||
self.assertIsNone(dataflow_run_lifecycle(PlatformRegistry()))
|
||||
self.assertIsNone(dataflow_run_worker(PlatformRegistry()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
91
tests/test_dataflow_run_worker.py
Normal file
91
tests/test_dataflow_run_worker.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
|
||||
from govoplan_core.celery_app import (
|
||||
celery,
|
||||
dispatch_dataflow_runs,
|
||||
purge_dataflow_runs,
|
||||
)
|
||||
|
||||
|
||||
class DataflowRunWorkerTests(unittest.TestCase):
|
||||
def test_dispatch_commits_worker_outcome(self) -> None:
|
||||
session = MagicMock()
|
||||
database = MagicMock()
|
||||
database.SessionLocal.return_value.__enter__.return_value = session
|
||||
provider = MagicMock()
|
||||
provider.dispatch_pending.return_value = {
|
||||
"claimed": 1,
|
||||
"succeeded": 1,
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_core.celery_app._dataflow_run_worker",
|
||||
return_value=provider,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.db.session.get_database",
|
||||
return_value=database,
|
||||
),
|
||||
):
|
||||
result = dispatch_dataflow_runs.run(7)
|
||||
|
||||
provider.dispatch_pending.assert_called_once_with(
|
||||
session,
|
||||
limit=7,
|
||||
worker_id=ANY,
|
||||
)
|
||||
session.commit.assert_called_once_with()
|
||||
self.assertEqual(1, result["succeeded"])
|
||||
|
||||
def test_retention_commits_worker_outcome(self) -> None:
|
||||
session = MagicMock()
|
||||
database = MagicMock()
|
||||
database.SessionLocal.return_value.__enter__.return_value = session
|
||||
provider = MagicMock()
|
||||
provider.purge_expired.return_value = {"purged": 2}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_core.celery_app._dataflow_run_worker",
|
||||
return_value=provider,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.db.session.get_database",
|
||||
return_value=database,
|
||||
),
|
||||
):
|
||||
result = purge_dataflow_runs.run(25)
|
||||
|
||||
provider.purge_expired.assert_called_once_with(session, limit=25)
|
||||
session.commit.assert_called_once_with()
|
||||
self.assertEqual(2, result["purged"])
|
||||
|
||||
def test_routes_and_periodic_jobs_are_registered(self) -> None:
|
||||
self.assertEqual(
|
||||
celery.conf.task_routes["govoplan.dataflow.dispatch_runs"],
|
||||
{"queue": "dataflow"},
|
||||
)
|
||||
self.assertEqual(
|
||||
celery.conf.task_routes["govoplan.dataflow.purge_runs"],
|
||||
{"queue": "dataflow"},
|
||||
)
|
||||
self.assertEqual(
|
||||
"govoplan.dataflow.dispatch_runs",
|
||||
celery.conf.beat_schedule[
|
||||
"dataflow-runs-every-five-seconds"
|
||||
]["task"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"govoplan.dataflow.purge_runs",
|
||||
celery.conf.beat_schedule[
|
||||
"dataflow-run-retention-daily"
|
||||
]["task"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
56
tests/test_dataflow_trigger_worker.py
Normal file
56
tests/test_dataflow_trigger_worker.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from govoplan_core.celery_app import celery, dispatch_dataflow_triggers
|
||||
|
||||
|
||||
class DataflowTriggerWorkerTests(unittest.TestCase):
|
||||
def test_worker_commits_dispatch_outcome(self) -> None:
|
||||
session = MagicMock()
|
||||
database = MagicMock()
|
||||
database.SessionLocal.return_value.__enter__.return_value = session
|
||||
provider = MagicMock()
|
||||
provider.dispatch_due.return_value = {
|
||||
"queued": 1,
|
||||
"processed": 1,
|
||||
"succeeded": 1,
|
||||
"failed": 0,
|
||||
"blocked": 0,
|
||||
"skipped": 0,
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_core.celery_app._dataflow_trigger_dispatcher",
|
||||
return_value=provider,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.db.session.get_database",
|
||||
return_value=database,
|
||||
),
|
||||
):
|
||||
result = dispatch_dataflow_triggers.run(25)
|
||||
|
||||
provider.dispatch_due.assert_called_once_with(session, limit=25)
|
||||
session.commit.assert_called_once_with()
|
||||
self.assertEqual(result["succeeded"], 1)
|
||||
|
||||
def test_worker_route_and_periodic_dispatch_are_registered(self) -> None:
|
||||
self.assertEqual(
|
||||
celery.conf.task_routes["govoplan.dataflow.dispatch_triggers"],
|
||||
{"queue": "dataflow"},
|
||||
)
|
||||
schedule = celery.conf.beat_schedule[
|
||||
"dataflow-triggers-every-minute"
|
||||
]
|
||||
self.assertEqual(
|
||||
schedule["task"],
|
||||
"govoplan.dataflow.dispatch_triggers",
|
||||
)
|
||||
self.assertEqual(schedule["schedule"], 60.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
186
tests/test_datasource_contract.py
Normal file
186
tests/test_datasource_contract.py
Normal file
@@ -0,0 +1,186 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.datasources import (
|
||||
CAPABILITY_DATASOURCE_CATALOGUE,
|
||||
CAPABILITY_DATASOURCE_LIFECYCLE,
|
||||
CAPABILITY_DATASOURCE_ORIGINS,
|
||||
CAPABILITY_DATASOURCE_PUBLICATION,
|
||||
DatasourceCatalogueProvider,
|
||||
DatasourceDescriptor,
|
||||
DatasourceField,
|
||||
DatasourceLifecycleProvider,
|
||||
DatasourceMaterialization,
|
||||
DatasourceOrigin,
|
||||
DatasourceOriginProvider,
|
||||
DatasourceOriginReadResult,
|
||||
DatasourcePublicationProvider,
|
||||
DatasourcePublicationResult,
|
||||
DatasourceReadResult,
|
||||
DatasourceStage,
|
||||
datasource_catalogue,
|
||||
datasource_lifecycle,
|
||||
datasource_origins,
|
||||
datasource_publication,
|
||||
)
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
|
||||
|
||||
class _Provider:
|
||||
descriptor = DatasourceDescriptor(
|
||||
ref="datasource:source-1",
|
||||
source_name="monthly_cases",
|
||||
name="Monthly cases",
|
||||
kind="upload",
|
||||
mode="static",
|
||||
shape="tabular",
|
||||
schema=(DatasourceField(name="case_id", data_type="string", nullable=False),),
|
||||
fingerprint="abc123",
|
||||
)
|
||||
|
||||
def list_datasources(self, session, principal, *, query="", limit=100):
|
||||
del session, principal, query
|
||||
return (self.descriptor,)[:limit]
|
||||
|
||||
def get_datasource(self, session, principal, *, datasource_ref):
|
||||
del session, principal
|
||||
return self.descriptor if datasource_ref == self.descriptor.ref else None
|
||||
|
||||
def read_datasource(self, session, principal, *, request):
|
||||
del session, principal, request
|
||||
return DatasourceReadResult(
|
||||
datasource=self.descriptor,
|
||||
rows=({"case_id": "0012"},),
|
||||
total_rows=1,
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
def list_materializations(self, session, principal, *, datasource_ref):
|
||||
del session, principal, datasource_ref
|
||||
return ()
|
||||
|
||||
def list_stages(self, session, principal, *, limit=100):
|
||||
del session, principal, limit
|
||||
return ()
|
||||
|
||||
def create_stage(self, session, principal, *, stage):
|
||||
del session, principal, stage
|
||||
return DatasourceStage(
|
||||
ref="stage:1",
|
||||
name="Stage",
|
||||
source_name="stage",
|
||||
kind="upload",
|
||||
mode="static",
|
||||
shape="tabular",
|
||||
state="ready",
|
||||
)
|
||||
|
||||
def promote_stage(self, session, principal, *, stage_ref, freeze=False, frozen_label=None):
|
||||
del session, principal, stage_ref, freeze, frozen_label
|
||||
return self.descriptor, DatasourceMaterialization(
|
||||
ref="materialization:1",
|
||||
datasource_ref=self.descriptor.ref,
|
||||
revision=1,
|
||||
state="published",
|
||||
fingerprint=self.descriptor.fingerprint,
|
||||
)
|
||||
|
||||
def register_origin(self, session, principal, **kwargs):
|
||||
del session, principal, kwargs
|
||||
return self.descriptor
|
||||
|
||||
def refresh_datasource(self, session, principal, *, datasource_ref):
|
||||
del session, principal, datasource_ref
|
||||
return self.promote_stage(object(), object(), stage_ref="stage:1")
|
||||
|
||||
def freeze_datasource(self, session, principal, *, datasource_ref, label=None):
|
||||
del session, principal, datasource_ref, label
|
||||
return self.promote_stage(object(), object(), stage_ref="stage:1")[1]
|
||||
|
||||
def retire_datasource(self, session, principal, *, datasource_ref):
|
||||
del session, principal, datasource_ref
|
||||
return self.descriptor
|
||||
|
||||
def publish_rows(self, session, principal, *, request):
|
||||
del session, principal, request
|
||||
return DatasourcePublicationResult(
|
||||
ref="publication:1",
|
||||
status="published",
|
||||
datasource=self.descriptor,
|
||||
materialization=DatasourceMaterialization(
|
||||
ref="materialization:1",
|
||||
datasource_ref=self.descriptor.ref,
|
||||
revision=1,
|
||||
state="published",
|
||||
fingerprint=self.descriptor.fingerprint,
|
||||
),
|
||||
)
|
||||
|
||||
def list_origins(self, session, principal, *, query="", limit=100):
|
||||
del session, principal, query
|
||||
return (
|
||||
DatasourceOrigin(
|
||||
ref="origin:1",
|
||||
source_name="origin",
|
||||
name="Origin",
|
||||
kind="database",
|
||||
shape="tabular",
|
||||
supported_modes=("live", "cached"),
|
||||
provider="test",
|
||||
),
|
||||
)[:limit]
|
||||
|
||||
def get_origin(self, session, principal, *, origin_ref):
|
||||
return self.list_origins(session, principal)[0] if origin_ref == "origin:1" else None
|
||||
|
||||
def read_origin(self, session, principal, *, request):
|
||||
origin = self.get_origin(session, principal, origin_ref=request.origin_ref)
|
||||
return DatasourceOriginReadResult(
|
||||
origin=origin,
|
||||
rows=({"id": 1},),
|
||||
total_rows=1,
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
|
||||
class DatasourceContractTests(unittest.TestCase):
|
||||
def test_capabilities_are_runtime_checkable_and_resolved_without_modules(self) -> None:
|
||||
provider = _Provider()
|
||||
self.assertIsInstance(provider, DatasourceCatalogueProvider)
|
||||
self.assertIsInstance(provider, DatasourceLifecycleProvider)
|
||||
self.assertIsInstance(provider, DatasourcePublicationProvider)
|
||||
self.assertIsInstance(provider, DatasourceOriginProvider)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="datasource_contract_test",
|
||||
name="Datasource contract test",
|
||||
version="test",
|
||||
capability_factories={
|
||||
CAPABILITY_DATASOURCE_CATALOGUE: lambda context: provider,
|
||||
CAPABILITY_DATASOURCE_LIFECYCLE: lambda context: provider,
|
||||
CAPABILITY_DATASOURCE_PUBLICATION: lambda context: provider,
|
||||
CAPABILITY_DATASOURCE_ORIGINS: lambda context: provider,
|
||||
},
|
||||
)
|
||||
)
|
||||
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
|
||||
|
||||
self.assertIs(provider, datasource_catalogue(registry))
|
||||
self.assertIs(provider, datasource_lifecycle(registry))
|
||||
self.assertIs(provider, datasource_publication(registry))
|
||||
self.assertIs(provider, datasource_origins(registry))
|
||||
self.assertIsNone(datasource_catalogue(PlatformRegistry()))
|
||||
|
||||
def test_descriptor_distinguishes_mode_kind_shape_and_materialization(self) -> None:
|
||||
descriptor = _Provider.descriptor
|
||||
|
||||
self.assertEqual("static", descriptor.mode)
|
||||
self.assertEqual("upload", descriptor.kind)
|
||||
self.assertEqual("tabular", descriptor.shape)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
79
tests/test_definition_governance_contract.py
Normal file
79
tests/test_definition_governance_contract.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.policy import (
|
||||
CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
|
||||
DefinitionGovernancePolicy,
|
||||
DefinitionGovernanceRequest,
|
||||
DefinitionScopeRef,
|
||||
PolicyDecision,
|
||||
definition_governance_policy,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
|
||||
|
||||
class _Policy:
|
||||
def resolve_definition_action(self, *, request):
|
||||
return PolicyDecision(
|
||||
allowed=request.definition_kind != "template"
|
||||
or request.action != "run",
|
||||
)
|
||||
|
||||
|
||||
class DefinitionGovernanceContractTests(unittest.TestCase):
|
||||
def test_scope_paths_reuse_policy_provenance_format(self) -> None:
|
||||
self.assertEqual("system", DefinitionScopeRef("system").path)
|
||||
self.assertEqual(
|
||||
"tenant:tenant-1",
|
||||
DefinitionScopeRef("tenant", "tenant-1").path,
|
||||
)
|
||||
|
||||
def test_policy_is_runtime_resolved(self) -> None:
|
||||
provider = _Policy()
|
||||
self.assertIsInstance(provider, DefinitionGovernancePolicy)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="definition_governance_test",
|
||||
name="Definition governance test",
|
||||
version="test",
|
||||
capability_factories={
|
||||
CAPABILITY_POLICY_DEFINITION_GOVERNANCE: (
|
||||
lambda context: provider
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
registry.configure_capability_context(
|
||||
ModuleContext(registry=registry, settings=object())
|
||||
)
|
||||
|
||||
resolved = definition_governance_policy(registry)
|
||||
self.assertIs(provider, resolved)
|
||||
decision = resolved.resolve_definition_action(
|
||||
request=DefinitionGovernanceRequest(
|
||||
module_id="dataflow",
|
||||
definition_ref="pipeline:1",
|
||||
tenant_id="tenant-1",
|
||||
definition_scope=DefinitionScopeRef(
|
||||
"tenant",
|
||||
"tenant-1",
|
||||
),
|
||||
target_scope=DefinitionScopeRef("tenant", "tenant-1"),
|
||||
definition_kind="template",
|
||||
action="run",
|
||||
actor=PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="membership-1",
|
||||
tenant_id="tenant-1",
|
||||
),
|
||||
)
|
||||
)
|
||||
self.assertFalse(decision.allowed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
145
tests/test_definition_graphs.py
Normal file
145
tests/test_definition_graphs.py
Normal file
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.definition_graphs import (
|
||||
DefinitionEdge,
|
||||
DefinitionGraphConstraints,
|
||||
DefinitionGraphLibrary,
|
||||
DefinitionNode,
|
||||
DefinitionNodeCountConstraint,
|
||||
DefinitionNodeType,
|
||||
DefinitionPort,
|
||||
validate_definition_graph,
|
||||
)
|
||||
|
||||
|
||||
def library(*, allow_cycles: bool) -> DefinitionGraphLibrary:
|
||||
return DefinitionGraphLibrary(
|
||||
id="test",
|
||||
version="0.1.0",
|
||||
category_labels={"control": "Control"},
|
||||
node_types=(
|
||||
DefinitionNodeType(
|
||||
type="start",
|
||||
category="control",
|
||||
label="Start",
|
||||
description="Start",
|
||||
icon="play",
|
||||
),
|
||||
DefinitionNodeType(
|
||||
type="step",
|
||||
category="control",
|
||||
label="Step",
|
||||
description="Step",
|
||||
icon="square",
|
||||
input_ports=(DefinitionPort(id="input", label="Input"),),
|
||||
),
|
||||
DefinitionNodeType(
|
||||
type="end",
|
||||
category="control",
|
||||
label="End",
|
||||
description="End",
|
||||
icon="circle-stop",
|
||||
input_ports=(DefinitionPort(id="input", label="Input"),),
|
||||
output_ports=(),
|
||||
),
|
||||
),
|
||||
constraints=DefinitionGraphConstraints(
|
||||
allow_cycles=allow_cycles,
|
||||
node_counts=(
|
||||
DefinitionNodeCountConstraint(
|
||||
code="graph.start_count",
|
||||
label="start",
|
||||
minimum=1,
|
||||
maximum=1,
|
||||
node_types=("start",),
|
||||
),
|
||||
DefinitionNodeCountConstraint(
|
||||
code="graph.end_count",
|
||||
label="end",
|
||||
minimum=1,
|
||||
node_types=("end",),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DefinitionGraphTests(unittest.TestCase):
|
||||
def test_library_driven_ports_and_counts_validate_a_definition(self) -> None:
|
||||
diagnostics = validate_definition_graph(
|
||||
library(allow_cycles=False),
|
||||
nodes=(
|
||||
DefinitionNode(id="start", type="start"),
|
||||
DefinitionNode(id="step", type="step"),
|
||||
DefinitionNode(id="end", type="end"),
|
||||
),
|
||||
edges=(
|
||||
DefinitionEdge(id="edge-1", source="start", target="step"),
|
||||
DefinitionEdge(id="edge-2", source="step", target="end"),
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual((), diagnostics)
|
||||
|
||||
def test_constraints_change_cycle_semantics_without_changing_graph_shape(self) -> None:
|
||||
nodes = (
|
||||
DefinitionNode(id="start", type="start"),
|
||||
DefinitionNode(id="step", type="step"),
|
||||
DefinitionNode(id="retry", type="step"),
|
||||
DefinitionNode(id="end", type="end"),
|
||||
)
|
||||
edges = (
|
||||
DefinitionEdge(id="edge-1", source="start", target="step"),
|
||||
DefinitionEdge(id="edge-2", source="step", target="retry"),
|
||||
DefinitionEdge(id="edge-3", source="retry", target="step"),
|
||||
DefinitionEdge(id="edge-4", source="retry", target="end"),
|
||||
)
|
||||
|
||||
dag_codes = {
|
||||
item.code
|
||||
for item in validate_definition_graph(
|
||||
library(allow_cycles=False),
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
)
|
||||
}
|
||||
workflow_codes = {
|
||||
item.code
|
||||
for item in validate_definition_graph(
|
||||
library(allow_cycles=True),
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
)
|
||||
}
|
||||
|
||||
self.assertIn("graph.cycle", dag_codes)
|
||||
self.assertNotIn("graph.cycle", workflow_codes)
|
||||
|
||||
def test_unknown_ports_and_missing_required_inputs_are_explicit(self) -> None:
|
||||
diagnostics = validate_definition_graph(
|
||||
library(allow_cycles=False),
|
||||
nodes=(
|
||||
DefinitionNode(id="start", type="start"),
|
||||
DefinitionNode(id="end", type="end"),
|
||||
),
|
||||
edges=(
|
||||
DefinitionEdge(
|
||||
id="edge",
|
||||
source="start",
|
||||
target="end",
|
||||
target_port="missing",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
{"edge.unknown_target_port", "node.input_required"}.issubset(
|
||||
{item.code for item in diagnostics}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user