131 Commits
v0.1.8 ... main

Author SHA1 Message Date
6970bf7457 Support observable grid queries and file filters 2026-07-30 05:22:09 +02:00
47e106684d perf(webui): lazily load module descriptors 2026-07-30 04:35:57 +02:00
9e219bc4d3 feat(core): dispatch bounded postbox routes 2026-07-30 03:59:38 +02:00
ea436a513f feat(core): define organization hierarchy contracts 2026-07-30 03:26:19 +02:00
e7c84e3227 feat(core): reconcile durable workflow instances 2026-07-30 03:09:58 +02:00
cf7afe9dda feat(core): dispatch durable dataflow runs 2026-07-30 02:32:49 +02:00
ca8a8c5111 feat(core): define sanctions screening gate contract 2026-07-30 01:54:58 +02:00
f3b388fe7e perf(core): define bounded reference search 2026-07-30 01:29:45 +02:00
af3e0a055d perf(core): batch tenant summary providers 2026-07-30 01:15:04 +02:00
51d4032b86 docs(core): define compatibility retention policy 2026-07-30 01:03:39 +02:00
0beb9ffea9 build: reject duplicate generated translations 2026-07-30 00:40:25 +02:00
9e6a6b5fdc feat(webui): center optional global search 2026-07-29 22:00:45 +02:00
48fb953b93 feat: add durable auth principal cache revisions 2026-07-29 19:23:52 +02:00
4bde0495f7 feat: expose workflow view resolution input 2026-07-29 19:09:03 +02:00
a80caf7933 feat: support focused dev reload scopes 2026-07-29 18:52:55 +02:00
790790ab37 feat: expose sanctions screening integration 2026-07-29 18:46:53 +02:00
920e3c9834 feat: version search source contracts 2026-07-29 18:08:52 +02:00
13893c80cd feat: version automation principal subjects 2026-07-29 17:48:36 +02:00
a192a2215f Add durable platform event delivery contract 2026-07-29 17:34:52 +02:00
e8fed6d25a fix: resolve postbox inbox icon 2026-07-29 16:01:45 +02:00
d9b5708df0 feat: add search and external integration contracts 2026-07-29 15:50:08 +02:00
68328f3d8e feat: strengthen module contracts and shared WebUI runtime 2026-07-29 14:16:28 +02:00
53e947935a fix: standardize direct page scroll viewports 2026-07-28 22:50:11 +02:00
324c26da78 fix: allow fallback dashboard scrolling 2026-07-28 22:13:22 +02:00
389f98e349 Keep normalized View roots acyclic 2026-07-28 21:32:20 +02:00
ce9ef8d88f Add governed View surface runtime 2026-07-28 21:04:54 +02:00
13bc3d3b4e Add shared credential envelope infrastructure 2026-07-28 19:32:41 +02:00
3f5870281a Color all shared alert tones 2026-07-28 18:35:53 +02:00
a46df85479 Resolve XyFlow styles for linked modules 2026-07-28 15:47:15 +02:00
c31581b1b9 Prebundle XyFlow for linked module development 2026-07-28 15:39:00 +02:00
26ae034153 Add governed automation contracts 2026-07-28 15:02:42 +02:00
baa2143a26 feat: add dataflow publication contracts and workflow webui 2026-07-28 13:47:50 +02:00
8b1910b5b7 feat: add datasource and definition graph contracts 2026-07-28 12:42:49 +02:00
d36bb94335 Add provider-neutral tabular source contracts 2026-07-28 11:12:24 +02:00
74034947c6 Update vulnerable PostCSS dependency 2026-07-28 01:36:39 +02:00
c7183fe7f1 Integrate Dataflow module into core 2026-07-28 01:33:37 +02:00
139a352c80 chore: update GovOPlaN repository references 2026-07-27 15:46:51 +02:00
336c94137f chore(release): align Mail bundle with Campaign contract 2026-07-23 00:47:34 +02:00
93225b6487 docs: move system status badges to meta repository 2026-07-22 23:45:42 +02:00
e11ea81008 chore(release): prepare Core 0.1.14 2026-07-22 20:31:49 +02:00
bc8afeb139 test(campaign): align synchronous send security contract 2026-07-22 20:30:00 +02:00
f876345656 test(db): prove PostgreSQL retirement atomicity 2026-07-22 15:28:00 +02:00
d487726f4d chore(release): bump Core to 0.1.13 2026-07-22 10:40:27 +02:00
e6fc07da37 chore(release): bundle Campaign 0.1.10 2026-07-22 10:38:37 +02:00
e6d589eb07 fix(release): package Core migration runtime 2026-07-22 10:34:34 +02:00
59610e21d2 chore(release): record reviewed 0.1.12 migration heads 2026-07-22 09:06:56 +02:00
cece71d945 feat(webui): synchronize external DataGrid queries 2026-07-22 09:03:11 +02:00
22e8183846 fix(webui): translate MetricCard content 2026-07-22 08:48:42 +02:00
aa111a5fe1 chore(release): bump Core to 0.1.12 2026-07-22 08:41:54 +02:00
e6062fe9e4 fix(webui): enforce full-result DataGrid queries 2026-07-22 08:05:11 +02:00
987ca894ed chore(release): record reviewed 0.1.11 migration heads 2026-07-22 04:42:30 +02:00
4caa326878 chore(core): bump version to 0.1.11 2026-07-22 03:41:24 +02:00
8c4c4456c6 feat(core): define auditable poll response retirement 2026-07-22 03:31:05 +02:00
6abe292ac8 feat(core): define governed poll participation contract 2026-07-22 03:21:03 +02:00
fea2807754 feat(core): define atomic poll option ordering 2026-07-22 03:20:28 +02:00
22646c614c feat(core): add bounded people picker foundation 2026-07-22 03:01:56 +02:00
17376332a2 feat(core): configure bounded scheduling cancellation notices 2026-07-22 02:58:09 +02:00
0946bc84a9 feat(core): support explicit public module routes 2026-07-22 02:58:03 +02:00
a18499cbb5 feat(core): require scoped user workflows 2026-07-22 01:55:24 +02:00
36d7b73bb5 fix(webui): allow card content to overflow 2026-07-22 01:49:24 +02:00
b89a2d15f1 chore(core): bump version to 0.1.10 2026-07-21 20:47:54 +02:00
7f923afdad docs(core): refine function-bound postbox encryption 2026-07-21 20:47:54 +02:00
a7683c5d4a feat(core): add resilient fixed-window throttling 2026-07-21 20:47:54 +02:00
41ad057f7e feat(webui): standardize discard and table actions 2026-07-21 20:47:54 +02:00
bf0729eb59 feat(core): add authenticated baseline role templates 2026-07-21 20:47:54 +02:00
c4b90181e0 fix(webui): localize contextual Mail help 2026-07-21 19:15:56 +02:00
55ed194a99 fix(webui): respect configured documentation access 2026-07-21 19:01:00 +02:00
b3b0cf0fca feat(webui): open contextual configured handbooks 2026-07-21 18:42:31 +02:00
fa9119bea7 security(webui): block remote mail preview content 2026-07-21 17:52:02 +02:00
70ca772138 test: gate Mail on Campaign access interface 2026-07-21 17:51:35 +02:00
2eae5c4df6 test: align module contracts with Mail-owned delivery 2026-07-21 17:15:19 +02:00
57fe6c6006 security(connectors): reject tunneled metadata addresses 2026-07-21 16:54:10 +02:00
713afdb39b fix(installer): enlist table retirement transaction 2026-07-21 16:44:20 +02:00
77f8d15d17 docs: clarify fail-closed connector SDKs 2026-07-21 15:53:22 +02:00
fda99d40eb docs: clarify institutional identity ownership 2026-07-21 15:46:55 +02:00
5ab1af803b docs: align technical roadmap to reference program 2026-07-21 15:45:39 +02:00
0845e99cf6 Block SDK-managed secondary connector peers 2026-07-21 15:43:04 +02:00
28a0a596a6 Fail closed for unpinned connector transports 2026-07-21 15:36:32 +02:00
ae74189588 fix(webui): complete central admin control styles 2026-07-21 14:01:56 +02:00
09b5009187 feat(webui): extend shared explorer tree actions 2026-07-21 13:58:38 +02:00
2ca61059dc refactor(webui): describe organization function actions 2026-07-21 13:51:19 +02:00
865901f090 Centralize shared layout style contracts 2026-07-21 13:46:59 +02:00
2ac1e64daa refactor(webui): share outside-dismiss behavior 2026-07-21 13:35:09 +02:00
7526c5ebb2 fix(webui): centralize form control layout 2026-07-21 13:31:32 +02:00
8e1f64c790 feat(webui): add central selection list 2026-07-21 13:30:39 +02:00
66e4783d2e feat(webui): add central icon button 2026-07-21 13:23:59 +02:00
7af86b42eb Centralize explorer work-surface styling 2026-07-21 13:19:20 +02:00
ad202f1267 Centralize shared small-note styling 2026-07-21 13:19:20 +02:00
6526f37aae feat(webui): centralize resource access explanations 2026-07-21 13:18:30 +02:00
9dabd9356d Document Core test bootstrap lint exemptions 2026-07-21 13:11:24 +02:00
6502775bf7 Block connector limited-broadcast targets 2026-07-21 13:02:29 +02:00
b2492b820f Harden private connector address validation 2026-07-21 12:55:01 +02:00
78d9ae48b2 feat(webui): centralize disabled button reasons 2026-07-21 12:38:45 +02:00
4cb3e94de3 feat(webui): expose central pagination bar 2026-07-21 12:36:49 +02:00
9131838b98 refactor(webui): generalize central selection lists 2026-07-21 12:34:35 +02:00
8e9eb6e1f5 feat(webui): centralize contextual table actions 2026-07-21 12:22:35 +02:00
249bf63eb8 fix(release): remove unavailable tagged webui packages 2026-07-21 12:22:07 +02:00
248e3dc70e test(release): isolate catalog contract projection 2026-07-21 12:21:38 +02:00
230ecf42b0 Enforce deployment security boundaries 2026-07-21 12:10:05 +02:00
825791e9b0 Harden outbound connector transports 2026-07-21 12:09:44 +02:00
7184b6cdd6 Dispose replaced migration database handles 2026-07-21 03:22:59 +02:00
ea8c600dce Cover Notifications in WebUI permutations 2026-07-21 03:18:08 +02:00
1839693575 Manage nested dialogs through a central stack 2026-07-21 03:18:08 +02:00
183bf7aef0 Ignore generated file drop test builds 2026-07-21 03:18:08 +02:00
37a5dfb182 Use managed Files in Campaign smoke fixtures 2026-07-21 03:18:08 +02:00
844f934379 Redact devserver database credentials 2026-07-21 03:18:08 +02:00
a98475f7bc Harden installer runtime secrets 2026-07-21 03:18:08 +02:00
1153c9dd36 Clean Core security audit findings 2026-07-21 03:18:07 +02:00
7eef52776c docs: link product vision to technical roadmap 2026-07-20 20:48:47 +02:00
c50ce58ad8 refactor(webui): clarify shared controls and status feedback 2026-07-20 20:03:42 +02:00
344fc0077f test(integration): harden cross-module API coverage 2026-07-20 20:03:27 +02:00
28afc01371 fix(notifications): commit worker delivery transactions 2026-07-20 20:03:21 +02:00
1a29e75db4 feat(identity): define the search provider contract 2026-07-20 20:03:15 +02:00
57ec960f40 docs(release): record the v0.1.8 composition 2026-07-20 20:03:10 +02:00
6388afdad8 chore(release): move tooling tests to meta repository 2026-07-20 20:03:10 +02:00
1a0e90b22d fix(migrations): preserve runtime logging during upgrades 2026-07-20 20:03:02 +02:00
7ad6f6328a feat(poll): add filtered response lookup contract 2026-07-20 18:28:56 +02:00
c79a7124b7 chore(core): bump contract version to 0.1.9 2026-07-20 18:27:13 +02:00
abbef5a10b feat(policy): add scheduling participant privacy contract 2026-07-20 18:23:59 +02:00
2f559e3f0b feat(poll): extend response editing contract 2026-07-20 18:16:42 +02:00
9b5418db78 fix(webui): keep DataGrid actions out of form submission 2026-07-20 17:58:32 +02:00
1678602fd6 docs(ui): bind interfaces to central components 2026-07-20 17:56:18 +02:00
6e373dcdd6 feat(poll): define the scheduling capability contract 2026-07-20 17:33:47 +02:00
d1c033edc7 feat(calendar): document outbox retention policy 2026-07-20 17:06:19 +02:00
b5cfba666c fix(files): make dropped-file handling explicit 2026-07-20 16:57:29 +02:00
78b4afdec4 feat(calendar): define picker UI capability 2026-07-20 16:57:17 +02:00
15596f0742 feat(calendar): schedule durable outbox recovery 2026-07-20 16:57:01 +02:00
a2320fcb5d docs(ui): record placement and focused-view rules 2026-07-20 16:49:57 +02:00
e6f7c45f0a intermittent commit 2026-07-14 13:22:10 +02:00
8aa1943581 Register Scheduling WebUI package 2026-07-12 19:00:54 +02:00
b9badc9153 Harden module installation and imports 2026-07-11 18:49:35 +02:00
254 changed files with 31723 additions and 3953 deletions

2
.gitignore vendored
View File

@@ -138,11 +138,13 @@ dist
# Local WebUI test/build scratch directories # Local WebUI test/build scratch directories
.component-test-build/ .component-test-build/
.file-drop-test-build/
.module-test-build/ .module-test-build/
.policy-test-build/ .policy-test-build/
.template-preview-test-build/ .template-preview-test-build/
.import-test-build/ .import-test-build/
webui/.component-test-build/ webui/.component-test-build/
webui/.file-drop-test-build/
webui/.module-test-build/ webui/.module-test-build/
webui/.policy-test-build/ webui/.policy-test-build/
webui/.template-preview-test-build/ webui/.template-preview-test-build/

View File

@@ -4,13 +4,6 @@
**Repository type:** system (kernel). **Repository type:** system (kernel).
<!-- govoplan-repository-type:end --> <!-- govoplan-repository-type:end -->
[![Module Matrix](https://git.add-ideas.de/add-ideas/govoplan/actions/workflows/module-matrix.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=module-matrix.yml&actor=0&status=0)
[![Release Integration](https://git.add-ideas.de/add-ideas/govoplan/actions/workflows/release-integration.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=release-integration.yml&actor=0&status=0)
[![Dependency Audit](https://git.add-ideas.de/add-ideas/govoplan/actions/workflows/dependency-audit.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=dependency-audit.yml&actor=0&status=0)
[![Security Audit](https://git.add-ideas.de/add-ideas/govoplan/actions/workflows/security-audit.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=security-audit.yml&actor=0&status=0)
# govoplan-core
GovOPlaN core is the platform runner and shared foundation. It owns the server entry point, database/session primitives, module discovery, migration orchestration, capability contracts, install/uninstall orchestration, and the shared WebUI shell. Platform and feature behavior is supplied by installed modules. GovOPlaN core is the platform runner and shared foundation. It owns the server entry point, database/session primitives, module discovery, migration orchestration, capability contracts, install/uninstall orchestration, and the shared WebUI shell. Platform and feature behavior is supplied by installed modules.
## Repository ownership ## Repository ownership
@@ -54,7 +47,7 @@ python3 -m venv .venv
./.venv/bin/python -m pip install -r requirements-dev.txt ./.venv/bin/python -m pip install -r requirements-dev.txt
``` ```
Run the platform server from core through the module-aware development runner. The default config reads `ENABLED_MODULES` and discovers installed module entry points. Local development defaults to `tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops`; set `ENABLED_MODULES` explicitly when testing a smaller module permutation. Run the platform server from core through the module-aware development runner. The default config reads `ENABLED_MODULES` and discovers installed module entry points. Local development defaults to the modules listed by `govoplan_core.settings.Settings.enabled_modules`, including Views when its package is installed; set `ENABLED_MODULES` explicitly when testing a smaller module permutation.
```bash ```bash
cd /mnt/DATA/git/govoplan-core cd /mnt/DATA/git/govoplan-core
@@ -74,6 +67,21 @@ ENABLED_MODULES=access,campaigns /mnt/DATA/git/govoplan/.venv/bin/python -m govo
The runner loads the same `GovoplanServerConfig` as `govoplan_core.server.app:app`, builds the platform registry, and passes core plus enabled module source roots to uvicorn as reload directories. After reinstalling the editable package, the same command is also available as `govoplan-devserver`. The runner loads the same `GovoplanServerConfig` as `govoplan_core.server.app:app`, builds the platform registry, and passes core plus enabled module source roots to uvicorn as reload directories. After reinstalling the editable package, the same command is also available as `govoplan-devserver`.
For focused backend work, keep the complete module graph active while watching
only the module being edited. Core/config sources and explicit `--reload-dir`
paths remain watched:
```bash
/mnt/DATA/git/govoplan/.venv/bin/python -m govoplan_core.devserver \
--reload-module calendar \
--reload-module campaign
```
Use `--reload-core-only` when no optional module source tree should trigger a
restart. Omitting both options preserves the broad default and watches every
enabled module. Startup, migration, and compatibility checks still run against
the complete enabled graph whenever the backend restarts.
The default development database is PostgreSQL at `postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev`. Store the password in `~/.pgpass`. To force the disposable SQLite fallback, run with `GOVOPLAN_DEV_DATABASE_BACKEND=sqlite`; that database lives below `runtime/`. The default development database is PostgreSQL at `postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev`. Store the password in `~/.pgpass`. To force the disposable SQLite fallback, run with `GOVOPLAN_DEV_DATABASE_BACKEND=sqlite`; that database lives below `runtime/`.
Local devserver runs do not require Redis. `CELERY_ENABLED` defaults to `false`, so campaign queue actions update database state without publishing Celery tasks. Use the synchronous send flow for local send tests, or set `CELERY_ENABLED=true` only when a Redis broker and worker are running. Local devserver runs do not require Redis. `CELERY_ENABLED` defaults to `false`, so campaign queue actions update database state without publishing Celery tasks. Use the synchronous send flow for local send tests, or set `CELERY_ENABLED=true` only when a Redis broker and worker are running.
@@ -143,6 +151,10 @@ PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versio
The local host links sibling module WebUI packages through local file dependencies and Vite filesystem allowances. Release builds should use `webui/package.release.json`, which points WebUI module packages at tagged git refs. See [RELEASE_DEPENDENCIES.md](docs/RELEASE_DEPENDENCIES.md). The local host links sibling module WebUI packages through local file dependencies and Vite filesystem allowances. Release builds should use `webui/package.release.json`, which points WebUI module packages at tagged git refs. See [RELEASE_DEPENDENCIES.md](docs/RELEASE_DEPENDENCIES.md).
Production builds lazy-load enabled module descriptors and enforce initial and
asynchronous JavaScript budgets. See
[WEBUI_BUNDLE_BUDGETS.md](docs/WEBUI_BUNDLE_BUDGETS.md).
## Module contract ## Module contract
Backend modules register through the `govoplan.modules` entry point and return a `ModuleManifest`. A manifest can contribute: Backend modules register through the `govoplan.modules` entry point and return a `ModuleManifest`. A manifest can contribute:

View File

@@ -30,10 +30,14 @@ _TABLE_RENAMES = (
("governance_templates", "admin_governance_templates"), ("governance_templates", "admin_governance_templates"),
("governance_template_assignments", "admin_governance_template_assignments"), ("governance_template_assignments", "admin_governance_template_assignments"),
) )
_KNOWN_TABLE_NAMES = {name for pair in _TABLE_RENAMES for name in pair}
def _row_count(bind: sa.Connection, table_name: str) -> int: def _row_count(bind: sa.Connection, table_name: str) -> int:
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {table_name}")).scalar_one()) if table_name not in _KNOWN_TABLE_NAMES:
raise RuntimeError(f"Unexpected table name: {table_name}")
quoted = bind.dialect.identifier_preparer.quote(table_name)
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one()) # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
def _rename_tables(renames: tuple[tuple[str, str], ...]) -> None: def _rename_tables(renames: tuple[tuple[str, str], ...]) -> None:

View File

@@ -19,11 +19,14 @@ LEGACY_SCOPE_TABLE = "tenancy_tenants"
CORE_SCOPE_TABLE = "core_scopes" CORE_SCOPE_TABLE = "core_scopes"
LEGACY_SLUG_INDEX = "ix_tenancy_tenants_slug" LEGACY_SLUG_INDEX = "ix_tenancy_tenants_slug"
CORE_SLUG_INDEX = "ix_core_scopes_slug" CORE_SLUG_INDEX = "ix_core_scopes_slug"
_KNOWN_SCOPE_TABLES = {LEGACY_SCOPE_TABLE, CORE_SCOPE_TABLE}
def _row_count(bind: sa.Connection, table_name: str) -> int: def _row_count(bind: sa.Connection, table_name: str) -> int:
if table_name not in _KNOWN_SCOPE_TABLES:
raise RuntimeError(f"Unexpected scope table name: {table_name}")
quoted = bind.dialect.identifier_preparer.quote(table_name) quoted = bind.dialect.identifier_preparer.quote(table_name)
return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one()) return int(bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one()) # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
def _scope_tables(bind: sa.Connection) -> set[str]: def _scope_tables(bind: sa.Connection) -> set[str]:

View File

@@ -16,6 +16,7 @@ revision = "9d0e1f2a3b4c"
down_revision = "8c9d0e1f2a3b" down_revision = "8c9d0e1f2a3b"
branch_labels = None branch_labels = None
depends_on = None depends_on = None
_RECONCILE_CREATE_ALL_TABLES = ("admin_governance_template_assignments", "admin_governance_templates", "core_system_settings")
def _now() -> datetime: def _now() -> datetime:
@@ -28,9 +29,10 @@ def upgrade() -> None:
tables = set(inspector.get_table_names()) tables = set(inspector.get_table_names())
# Reconcile only the empty create_all shape for the newly introduced tables. # Reconcile only the empty create_all shape for the newly introduced tables.
for table_name in ("admin_governance_template_assignments", "admin_governance_templates", "core_system_settings"): for table_name in _RECONCILE_CREATE_ALL_TABLES:
if table_name in tables: if table_name in tables:
count = bind.execute(sa.text(f"SELECT COUNT(*) FROM {table_name}")).scalar_one() quoted = bind.dialect.identifier_preparer.quote(table_name)
count = bind.execute(sa.text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one() # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
if count: if count:
raise RuntimeError(f"Cannot reconcile non-empty create_all table {table_name}") raise RuntimeError(f"Cannot reconcile non-empty create_all table {table_name}")
op.drop_table(table_name) op.drop_table(table_name)

View File

@@ -49,7 +49,7 @@ def upgrade() -> None:
placeholders = ", ".join(f":action_{index}" for index, _ in enumerate(SYSTEM_ACTIONS)) placeholders = ", ".join(f":action_{index}" for index, _ in enumerate(SYSTEM_ACTIONS))
params = {f"action_{index}": action for index, action in enumerate(SYSTEM_ACTIONS)} params = {f"action_{index}": action for index, action in enumerate(SYSTEM_ACTIONS)}
bind.execute( bind.execute(
sa.text(f"UPDATE audit_log SET scope = 'system' WHERE action IN ({placeholders})"), sa.text(f"UPDATE audit_log SET scope = 'system' WHERE action IN ({placeholders})"), # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
params, params,
) )
bind.execute(sa.text("UPDATE audit_log SET scope = 'tenant' WHERE scope IS NULL OR scope NOT IN ('tenant', 'system')")) bind.execute(sa.text("UPDATE audit_log SET scope = 'tenant' WHERE scope IS NULL OR scope NOT IN ('tenant', 'system')"))

View File

@@ -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")

View File

@@ -5,9 +5,14 @@ from logging.config import fileConfig
from alembic import context from alembic import context
from sqlalchemy import engine_from_config, pool from sqlalchemy import engine_from_config, pool
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata try:
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate optional access metadata
except ModuleNotFoundError as exc:
if exc.name != "govoplan_access":
raise
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
from govoplan_core.core.migrations import migration_metadata_plan from govoplan_core.core.migrations import migration_metadata_plan
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.server.default_config import get_server_config from govoplan_core.server.default_config import get_server_config
@@ -20,7 +25,10 @@ database_url = config.attributes.get("database_url") or settings.database_url
config.set_main_option("sqlalchemy.url", database_url) config.set_main_option("sqlalchemy.url", database_url)
if config.config_file_name is not None: if config.config_file_name is not None:
fileConfig(config.config_file_name) # Migrations can run inside the long-lived application process when module
# state changes. Do not let Alembic's logging setup disable loggers that the
# server already created (for example slow-request diagnostics).
fileConfig(config.config_file_name, disable_existing_loggers=False)
def _target_metadata(): def _target_metadata():

View 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")

View File

@@ -0,0 +1,59 @@
# Automation Contracts
Core defines provider-neutral automation contracts. It does not own domain
schedules, Workflow graphs, or Dataflow execution.
## Invocation Envelope
`AutomationInvocation` classifies a start as `manual`, `api`, `schedule`,
`event`, `workflow`, `dependency`, `retry`, or `backfill`. It carries opaque
trigger and delivery references, event identity, correlation and causation
IDs, scheduled time, requesting actor, and bounded metadata. Domain runs store
this envelope with their immutable definition revision.
## Current Authorization
An automated trigger must not persist a user session, bearer token, API key,
or a snapshot of all current permissions. It stores:
- tenant, account, and membership IDs;
- an opaque authorization reference;
- the minimum scopes required by the pinned definition and output target.
At delivery time the optional
`auth.automationPrincipalProvider` capability resolves current account,
membership, role, group, function, and delegation state. It intersects current
authorization with the stored grant. Missing, inactive, or reduced
authorization blocks the delivery before effects occur.
## Definition Governance
The optional `policy.definitionGovernance` capability evaluates `view`,
`edit`, `run`, `reuse`, `derive`, and `automate` for system, tenant, group, and
user definitions. A decision contains an ordered source path and effective
limits. Derived definitions pin their source revision and hash and retain
ancestor ceilings. Templates are reusable definitions and cannot run on their
own.
Without Policy, domain modules use a conservative tenant-local fallback:
local definitions remain viewable/editable and active complete flows may run;
inheritance, reuse, derivation, and automation are unavailable.
## Delivery Durability
Domain trigger implementations persist idempotent deliveries before running.
`emit_platform_event` binds event delivery to the producer's SQLAlchemy
transaction. When an enabled module provides `platform.eventOutbox`, the event
is stored in that transaction and a dispatcher may retry it across restarts and
workers. The Audit module provides the current SQL outbox implementation; the
Celery `govoplan.events.dispatch_outbox` task drains it through the Dataflow
event-ingestion capability and the local event bus.
The outbox capability remains optional so reduced module combinations can
start. Without it, Core queues events on the SQLAlchemy transaction and
publishes them to the process-local bus only after the outer commit. A rollback,
including a nested savepoint rollback, discards the corresponding events. This
fallback is suitable for local or non-critical reactions, but it is not a
durable multi-worker automation source. Deployments that rely on event-triggered
work must enable the outbox provider and run the `events` worker queue and
periodic dispatcher.

View File

@@ -0,0 +1,69 @@
# GovOPlaN Compatibility Policy
This document defines the compatibility window that release tooling, module
owners, migration authors, import/export providers, and API maintainers must
preserve. It is the source of truth for deciding whether compatibility code can
be removed.
## Database Upgrades
- A released installation from every tagged `0.1.x` version is a supported
database upgrade origin.
- The recorded public release-baseline ledger starts at `v0.1.7`; earlier
`0.1.x` tags predate production installations. If an earlier tagged database
is encountered, the release must provide or document a compatibility bridge
instead of silently treating the database as a fresh installation.
- Released migration revision IDs and recorded release heads are immutable.
- Each release must prove an upgrade from every still-supported recorded
baseline, as well as a fresh installation, before its tag is published.
- Migration-only reconciliation needed by an old database remains available for
at least one subsequent major release cycle after the corresponding runtime
compatibility path is removed.
The release-baseline format and commands are documented in
`RELEASE_DEPENDENCIES.md`.
## Configuration And Export Schemas
- Writers emit only the current schema version.
- Readers accept the current schema version and the previous two schema
versions.
- Older input is rejected with a diagnostic that identifies its version and the
required staged upgrade or conversion path.
- A module-owned configuration provider must version its input and output
schema explicitly. It must not infer an old schema from missing fields once a
versioned schema has shipped.
- Round-trip and upgrade tests must cover all three readable versions before a
schema change is released.
This window applies to configuration packages, module-owned exports, and other
portable GovOPlaN configuration artifacts. Domain interchange standards with
their own compatibility rules remain governed by the owning module.
## Runtime And API Aliases
- Compatibility aliases must emit an explicit deprecation diagnostic and point
to the supported replacement.
- New callers must use the canonical contract. In-tree callers may not add new
uses of a deprecated alias.
- Runtime imports, request fields, response fields, routes, and scope aliases
carried for the `0.1.x` split line are retired at `0.2`, with migration notes.
- An alias may be removed earlier only when it never shipped in a tag or when a
security fix requires removal. The release notes must state the exception.
- Database reconciliation code is not a runtime/API alias and follows the
longer database window above.
## Removal Checklist
Compatibility code can be removed only when all of the following are true:
1. The path is inventoried as a database bridge, portable-schema reader, or
runtime/API alias.
2. Its minimum retention window has elapsed.
3. In-tree callers and published module manifests use the replacement.
4. Upgrade, import, or API regression tests cover the retained window.
5. Diagnostics and migration notes identify any staged action operators must
take.
If one condition is not met, version-gate the compatibility path and record its
planned removal release instead of deleting it.

View File

@@ -69,6 +69,11 @@ Required package metadata:
- migration or transformation rules for older package versions - migration or transformation rules for older package versions
- provenance, export source metadata, and signature metadata - provenance, export source metadata, and signature metadata
Portable configuration schemas follow `COMPATIBILITY_POLICY.md`: providers
write only their current schema version and read that version plus the previous
two versions. Older input must produce a version-specific staged-upgrade
diagnostic.
Configuration fragments are interpreted only by the module that owns them. For Configuration fragments are interpreted only by the module that owns them. For
example, workflow imports workflow definitions; forms imports form schemas; example, workflow imports workflow definitions; forms imports form schemas;
mail imports mail templates and delivery defaults; payments imports payment mail imports mail templates and delivery defaults; payments imports payment

View File

@@ -144,8 +144,12 @@ tools/checks/postgres-integration-check.py \
``` ```
The integration check runs migrations and startup smoke checks across the The integration check runs migrations and startup smoke checks across the
standard module permutations. `--reset-schema` is destructive and belongs only standard module permutations. It first requires the retirement atomicity proof,
on throwaway databases. using Files' real secret-owning provider and Audit's persistent recorder. That
proof uses only random, test-owned schemas and cleans them afterward; it does
not reset `public`. `--reset-schema` is destructive and belongs only on
throwaway databases. Do not pass `--skip-retirement-atomicity` when collecting
release evidence.
### Broker And Workers ### Broker And Workers
@@ -153,16 +157,26 @@ on throwaway databases.
| --- | --- | --- | | --- | --- | --- |
| `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. | | `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. |
| `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. | | `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. |
| `CELERY_QUEUES` | `send_email,append_sent,default` | Queue list expected by worker/process manager definitions. | | `CELERY_QUEUES` | `send_email,append_sent,notifications,calendar,dataflow,events,default` | Queue list expected by worker/process manager definitions. The `events` queue drains transactional platform events; `dataflow` drains trigger deliveries and schedules. |
| `PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS` | `8` | Failed durable consumer deliveries are quarantined after this many attempts. |
| `PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS` | `90` | Successful event envelopes older than this are removed by the daily retention task. Quarantined evidence is retained. |
Worker command: Worker command:
```bash ```bash
python -m celery -A govoplan_core.celery_app:celery worker \ python -m celery -A govoplan_core.celery_app:celery worker \
--queues send_email,append_sent,default \ --queues send_email,append_sent,notifications,calendar,dataflow,events,default \
--loglevel INFO --loglevel INFO
``` ```
Run Celery beat as a separately supervised process. Its built-in one-minute
schedule recovers Calendar outbox rows left behind by broker failures, process
crashes, and expired worker leases:
```bash
python -m celery -A govoplan_core.celery_app:celery beat --loglevel INFO
```
### Storage ### Storage
| Setting | Default | Notes | | Setting | Default | Notes |
@@ -184,15 +198,64 @@ prefer `FILE_STORAGE_*`.
| Setting | Default | Notes | | Setting | Default | Notes |
| --- | --- | --- | | --- | --- | --- |
| `CORS_ORIGINS` | local dev origins | Set to the exact WebUI origins in staging/production. | | `CORS_ORIGINS` | local dev origins | Set to the exact WebUI origins in staging/production. |
| `GOVOPLAN_TRUSTED_HOSTS` | empty | Exact API host names accepted by the application. Production-like validation requires an explicit list; narrowly scoped `*.example.org` entries are supported. |
| `FORWARDED_ALLOW_IPS` | Uvicorn default | Address or network of the trusted reverse proxy. Never use `*` in production-like deployments. |
| `AUTH_SESSION_COOKIE_NAME` | configured default | Change only through a controlled rollout because it logs users out. | | `AUTH_SESSION_COOKIE_NAME` | configured default | Change only through a controlled rollout because it logs users out. |
| `AUTH_CSRF_COOKIE_NAME` | configured default | Must match WebUI/API deployment. | | `AUTH_CSRF_COOKIE_NAME` | configured default | Must match WebUI/API deployment. |
| `AUTH_COOKIE_SECURE` | `false` | Set `true` behind HTTPS. | | `AUTH_COOKIE_SECURE` | `false` | Set `true` behind HTTPS. |
| `AUTH_COOKIE_SAMESITE` | `lax` | Use a stricter value only after testing login and CSRF flows. | | `AUTH_COOKIE_SAMESITE` | `lax` | Use a stricter value only after testing login and CSRF flows. |
| `AUTH_COOKIE_DOMAIN` | empty | Set only when the API and WebUI intentionally share a parent domain. | | `AUTH_COOKIE_DOMAIN` | empty | Set only when the API and WebUI intentionally share a parent domain. |
| `GOVOPLAN_HTTP_HSTS_SECONDS` | `31536000` in production, otherwise `0` | Emitted only for HTTPS requests. Set `0` while rehearsing a deployment that is not yet HTTPS-only. |
| `GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES` | `536870912` (512 MiB) | Deployment hard ceiling; file and module APIs apply their own lower limits where appropriate. |
Interactive password login is enabled with fixed-window limits of 10 failures
per normalized identity and 100 failures per direct client over 900 seconds.
`AUTH_LOGIN_THROTTLE_*` settings change those limits. Counters use `REDIS_URL`
when Redis is reachable so replicas share state. Production-like startup fails
when throttling is enabled without `REDIS_URL`. Set
`GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE=true` only as an explicit
single-process risk acceptance. A bounded process-local fallback keeps
development and temporary Redis outages functional, with per-process
enforcement until Redis recovers; monitor Redis because protection is weaker
during that fallback.
### Outbound Connector Egress
| Setting | Default | Notes |
| --- | --- | --- |
| `GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS` | `true` in dev/test, otherwise `false` | Deployment-wide decision. Set `true` only when pinned HTTP(S), DAV, SMTP, or IMAP transports must reach internal addresses. It does not enable an SDK transport that cannot pin every peer. |
| `GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES` | `16777216` (16 MiB) | Maximum buffered JSON, XML, iCalendar, vCard, catalog, and connector error response. |
| `GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES` | `536870912` (512 MiB) | Hard upper bound for a single remote file; module upload limits may be lower. |
| `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST` | empty | Comma-separated exact environment names usable by deployment-owned connector profiles. Tenant/API-managed profiles cannot select process variables, even when a name is listed. |
| `GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST` | empty | Comma-separated exact absolute CA bundle paths. Mount the same files at the same paths on every API and connector worker. |
Production-like configuration validation requires the private-network choice to
be explicit. HTTP connector downloads are streamed up to the configured bound,
and credential-bearing DAV redirects remain confined to their configured
origin.
The urllib, HTTPX/httpcore, SMTP, and IMAP transports resolve, validate, and
connect to the same approved address record while retaining the original host
for HTTP Host, TLS SNI, and certificate verification. Live SMB and S3 access
fails closed in both public-only and private-network deployments: the current
SDK transports cannot pin every initial and secondary peer or revalidate every
SDK-managed redirect/referral. An explicit IP endpoint does not bypass this
rule. Production deployments should still enforce the same decision at their
worker/container egress firewall or outbound proxy as a second boundary.
File connector TLS verification may be disabled only in dev/test. A custom CA
bundle must be an existing regular file whose resolved absolute path is listed
in `GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST`. Environment-backed file connector
credentials are supported only in deployment-owned connector JSON and require
their exact names in `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST`; UI/API profiles
must use encrypted stored credentials or a scoped secret-provider reference.
Public URLs are currently supplied by deployment/reverse-proxy configuration and Public URLs are currently supplied by deployment/reverse-proxy configuration and
module settings. Do not hardcode them in core; configuration packages should ask module settings. Do not hardcode them in core; configuration packages should ask
for portal, WebUI, postbox, and notification URLs when they become relevant. for portal, WebUI, postbox, and notification URLs when they become relevant.
Uvicorn applies `X-Forwarded-*` only from `FORWARDED_ALLOW_IPS`; keep that value
aligned with the reverse proxy and do not expose the application server directly
through the same trusted address range.
### Module Catalogs, Licenses, And Trust Roots ### Module Catalogs, Licenses, And Trust Roots
@@ -275,7 +338,7 @@ the checked in `.env.example`. It runs:
- explicit `ENABLED_MODULES` - explicit `ENABLED_MODULES`
- explicit migrations and `--with-dev-data` bootstrap - explicit migrations and `--with-dev-data` bootstrap
- API via the module-aware devserver - API via the module-aware devserver
- a Celery worker for `send_email,append_sent,default` - a Celery worker for `send_email,append_sent,notifications,calendar,default`
- WebUI through the Vite dev server - WebUI through the Vite dev server
- durable local files under `runtime/production-like/files` - durable local files under `runtime/production-like/files`

View File

@@ -9,6 +9,7 @@ operator, and roadmap pages.
| Topic | Canonical document | Notes | | Topic | Canonical document | Notes |
| --- | --- | --- | | --- | --- | --- |
| Module architecture and kernel contracts | `MODULE_ARCHITECTURE.md` | Stable module contracts, API efficiency contracts, durable boundary decisions, lifecycle, and WebUI contribution rules. | | Module architecture and kernel contracts | `MODULE_ARCHITECTURE.md` | Stable module contracts, API efficiency contracts, durable boundary decisions, lifecycle, and WebUI contribution rules. |
| Compatibility policy | `COMPATIBILITY_POLICY.md` | Supported database upgrade origins, portable-schema read/write windows, runtime/API alias retirement, and compatibility-code removal criteria. |
| RBAC and resource access | `ACCESS_RBAC_MODEL.md` | Current permission, role, API-key, and resource-access model. | | RBAC and resource access | `ACCESS_RBAC_MODEL.md` | Current permission, role, API-key, and resource-access model. |
| Governance hierarchy | `GOVERNANCE_MODEL.md` | System, tenant, user/group, campaign policy inheritance and admin UI structure. | | Governance hierarchy | `GOVERNANCE_MODEL.md` | System, tenant, user/group, campaign policy inheritance and admin UI structure. |
| Policy decision DTOs and provenance | `POLICY_CONTRACTS.md` | Shared explain/provenance shape; module-specific policy docs should link here. | | Policy decision DTOs and provenance | `POLICY_CONTRACTS.md` | Shared explain/provenance shape; module-specific policy docs should link here. |
@@ -25,6 +26,7 @@ operator, and roadmap pages.
| Release dependencies and catalogs | `RELEASE_DEPENDENCIES.md` | Release package refs, migration baselines, release lockfiles, catalog trust/licensing, catalog publishing, and release checklist. | | Release dependencies and catalogs | `RELEASE_DEPENDENCIES.md` | Release package refs, migration baselines, release lockfiles, catalog trust/licensing, catalog publishing, and release checklist. |
| Dependency vulnerability audits | `DEPENDENCY_AUDITS.md` | Local and CI audit commands plus dated audit result notes. | | Dependency vulnerability audits | `DEPENDENCY_AUDITS.md` | Local and CI audit commands plus dated audit result notes. |
| Remote WebUI bundle design | `REMOTE_WEBUI_BUNDLES.md` | Experimental controlled-deployment design; normal releases still use package builds. | | Remote WebUI bundle design | `REMOTE_WEBUI_BUNDLES.md` | Experimental controlled-deployment design; normal releases still use package builds. |
| WebUI loading and bundle budgets | `WEBUI_BUNDLE_BUDGETS.md` | Installed-module lazy boundaries, enforced initial/async budgets, and baseline measurements. |
## Product And Module Planning ## Product And Module Planning

View File

@@ -8,25 +8,32 @@ module reactions, and operator diagnostics.
## Production Transport Decision ## Production Transport Decision
The first production target is a **database outbox plus in-process immediate The production transport is a **transactional database outbox plus retrying
dispatch**: dispatcher**:
- Use `govoplan_core.core.events.PlatformEvent` for domain and platform events. - Use `govoplan_core.core.events.PlatformEvent` for domain and platform events.
- Use `EventBus` as the in-process dispatch contract for same-process module - Call `emit_platform_event(session, event)` to bind event delivery to the
reactions that are safe to run inline. domain transaction.
- The optional `platform.eventOutbox` capability persists events atomically.
The Audit module provides the current SQL implementation.
- Without an outbox provider, Core publishes to `EventBus` only after the outer
transaction commits. This preserves reduced installations but is not durable
across process failure or multiple workers.
- Use `EventBus` as the in-process dispatch contract for module reactions
invoked by the outbox dispatcher or for non-critical fallback reactions.
- Use the shared `audit_event` / `audit_from_principal` helper for audited - Use the shared `audit_event` / `audit_from_principal` helper for audited
module actions. The helper persists the audit row and immediately publishes a module actions. The helper persists the audit row and transactionally emits a
governed `PlatformEvent` whose `type` is the audit action. governed `PlatformEvent` whose `type` is the audit action.
- Use `record_change` for module delta feeds. It persists the change-sequence - Use `record_change` for module delta feeds. It persists the change-sequence
row and immediately publishes a generic module change event such as row and transactionally emits a generic module change event such as
`mail.profile.updated`. `mail.profile.updated`.
- Persist durable integration/workflow events through a database outbox before - Drain the outbox with the Celery `govoplan.events.dispatch_outbox` task on the
acknowledging the state change that produced them. `events` queue. The periodic schedule also retries pending rows.
- Drain the outbox through a small dispatcher process. The dispatcher may call - The dispatcher invokes Dataflow event ingestion when that capability is
in-process handlers in the same deployment first, but its storage contract is active, then publishes to the process-local bus.
database-backed.
- Treat Redis/Celery as worker/job infrastructure, not as the authoritative - Treat Redis/Celery as worker/job infrastructure, not as the authoritative
first event transport. A Celery dispatcher can consume the outbox later. first event transport. PostgreSQL remains authoritative until dispatch is
recorded.
- Keep the dispatch implementation pluggable behind the `PlatformEvent` - Keep the dispatch implementation pluggable behind the `PlatformEvent`
envelope so a future message broker can be added without changing event envelope so a future message broker can be added without changing event
producers. producers.
@@ -44,17 +51,18 @@ Event producers should write their domain state and outbox event in the same
database transaction wherever possible. Handlers must be idempotent because the database transaction wherever possible. Handlers must be idempotent because the
outbox dispatcher can retry after a crash or timeout. outbox dispatcher can retry after a crash or timeout.
Recommended first outbox columns: The current outbox stores:
- `event_id`, `event_type`, `module_id` - `event_id`, `event_type`, `module_id`
- `correlation_id`, `causation_id` - `correlation_id`, `causation_id`
- `payload`, `occurred_at` - `classification`, serialized event `payload`
- `available_at`, `attempt_count`, `claimed_at`, `claim_token` - `status`, `attempts`, `next_attempt_at`
- `processed_at`, `last_error` - `dispatched_at`, `last_error`, timestamps
Inline `EventBus` handlers are allowed only for non-critical local reactions. Handlers must be idempotent: a worker may complete an external effect and fail
Anything that must survive process failure, restart, package update, or worker before marking its outbox row dispatched. Anything that must survive process
redeployment belongs in the outbox. failure, restart, package update, or worker redeployment requires the outbox
provider and dispatcher.
## Trace IDs ## Trace IDs

View 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`.

View File

@@ -1,13 +1,23 @@
# GovOPlaN Master Roadmap # GovOPlaN Master Roadmap
This roadmap is the durable product north star and sequencing guide for This roadmap is the technical and module-sequencing companion for GovOPlaN as
GovOPlaN as a modular platform for administrative operations. It keeps the a modular platform for administrative operations. It translates the
product moving without turning every possible public-sector need into an cross-product outcome horizons into dependency waves without turning every
immediate implementation track. possible public-sector need into an immediate implementation track.
Use this document for product direction, sequencing, and module routing. Issues Use this document for technical sequencing, module routing, and implementation
are the active backlog; this document is durable planning context and should be gates. Issues are the active backlog; this document is durable architecture
mirrored to the Gitea wiki. planning context and should be mirrored to the Gitea wiki.
The meta repository's
[Connected Governance Platform Roadmap](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
describes the corresponding cross-product stakeholder visions, configurable
service and operating configurations, connected outcome stories, and
capability horizons. The selected five-stage delivery sequence and its gates
are in the meta repository's
[Reference Journey Program](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/REFERENCE_JOURNEY_PROGRAM.md).
Those product documents are canonical; this Core roadmap remains their
technical sequencing and module-routing companion.
## Product Thesis ## Product Thesis
@@ -113,7 +123,8 @@ pattern exists.
## Focus Rules ## Focus Rules
1. Build one reference journey per wave. 1. Build one selected reference journey stage at a time; a later capability
cluster is not an active program merely because it appears below.
2. Do not implement a module because the repository exists. 2. Do not implement a module because the repository exists.
3. Do not add module-to-module imports for optional behavior. 3. Do not add module-to-module imports for optional behavior.
4. Every new domain module must justify its own semantics beyond `cases`, 4. Every new domain module must justify its own semantics beyond `cases`,
@@ -138,11 +149,15 @@ pattern exists.
| Internal work queues and tasks | `govoplan-tasks` | | Internal work queues and tasks | `govoplan-tasks` |
| Appointment proposals and booking | `govoplan-appointments`, `govoplan-calendar` | | Appointment proposals and booking | `govoplan-appointments`, `govoplan-calendar` |
| Postbox, email, and notifications | `govoplan-postbox`, `govoplan-mail`, `govoplan-notifications` | | Postbox, email, and notifications | `govoplan-postbox`, `govoplan-mail`, `govoplan-notifications` |
| Canonical subjects and account links | `govoplan-identity` |
| Organizational structures, units, and functions | `govoplan-organizations` |
| Identity-to-function assignments and directory synchronization | `govoplan-idm` |
| Identity trust, device keys, and encrypted postbox key contracts | `govoplan-identity-trust`, `govoplan-access`, `govoplan-postbox` | | Identity trust, device keys, and encrypted postbox key contracts | `govoplan-identity-trust`, `govoplan-access`, `govoplan-postbox` |
| Service directory/catalog | `govoplan-portal` | | Service directory/catalog | `govoplan-portal` |
| Permit/document generation | `govoplan-templates`, `govoplan-dms` | | Permit/document generation | `govoplan-templates`, `govoplan-dms` |
| Payment capture and accounting handoff | `govoplan-payments`, `govoplan-ledger` | | Payment capture and accounting handoff | `govoplan-payments`, `govoplan-ledger` |
| Roles, permissions, tenants, policy, audit | `govoplan-access`, `govoplan-tenancy`, `govoplan-policy`, `govoplan-audit` | | Authentication projection, roles, permissions, acting context | `govoplan-access` |
| Tenants, policy, and audit | `govoplan-tenancy`, `govoplan-policy`, `govoplan-audit` |
| External software integration | `govoplan-connectors` | | External software integration | `govoplan-connectors` |
| Recurring extraction and transformation | possible future `govoplan-datasources`, possible future `govoplan-dataflow` | | Recurring extraction and transformation | possible future `govoplan-datasources`, possible future `govoplan-dataflow` |
| Reports, BI, and management visibility | `govoplan-reporting` | | Reports, BI, and management visibility | `govoplan-reporting` |
@@ -189,56 +204,82 @@ an editor applies a high-impact configuration change.
## Reference Journeys ## Reference Journeys
The roadmap should be driven by three journeys. The active sequence is selected. Workflow remains deliberately deferred and is
not a dependency of these journeys.
### Journey 1: Permit To Payment ### Journey 1: Campaign Demonstration Composition
This is the primary public-administration journey. Campaign is the first complete proof of modular composition. Campaign owns
intent, recipient snapshots, personalization, execution state, and delivery
evidence. Mail owns reusable profiles, credentials, protocol policy, and
provider execution; Campaign stores only a selected profile reference. Files
owns storage, connector profiles, file policy, and provenance.
1. A person applies for a permit through the public portal. The technical gate is a pinned Campaign/Mail/Files composition with central
2. The applicant uploads required files and submits structured form data. UI, adaptive user/admin/operator/integration documentation, target SMTP/IMAP
3. Submission creates a case, a workflow instance, and an internal task. and file-provider evidence, and explicit test/send/resend/retry/reconciliation
4. Completing the task creates a postbox message, a notification, and an email semantics. Readers must not receive backend paths, worker claims, secrets, or
notification with an appointment proposal. raw provider diagnostics.
5. The applicant accepts an appointment, which updates the calendar and the
workflow state.
6. During the appointment, the case is opened and the permit is generated from
a governed template.
7. The payment is processed and linked to the case and accounting handoff.
8. The permit, payment evidence, communication history, audit trail, retention
state, and records evidence remain available according to policy.
This journey proves the platform can coordinate modules without core knowing ### Journey 2: Function-Bound Postbox Delivery
module internals.
### Journey 2: Training To Certificate Postbox accepts delivery to an addressable postbox or a function in an
organizational unit. Organizations owns units and functions, Identity owns
subjects, IDM owns identity-to-function assignments and upstream sync, and
Access resolves current roles, delegation, acting context, and permission.
This is the best university-administration and internal-administration journey. Campaign consumes a typed delivery-target capability without importing Postbox
or identity internals. Reassignment changes future access without moving the
message; vacancy or ambiguous acting context fails visibly; delivery, access,
and correction remain auditable.
1. Course or training offer is planned. ### Journey 3: Data-Backed Templates, Reports, And Deep Launch
2. Room, trainer, resource, and capacity are booked.
3. Participants register or are assigned.
4. Attendance is tracked.
5. Certificate or participation confirmation is issued.
6. Evidence remains available through records, files, audit, and docs.
This journey keeps `booking`, `resources`, `learning`, and `certificates` An authenticated user follows an opaque, short-lived launch reference from HIS
focused instead of becoming broad ERP replacements. or another specialist system. GovOPlaN re-authorizes the actor, resolves a
curated data context server-side, displays source/freshness/version, and renders
one reproducible document and report.
### Journey 3: Report To Resolution Templates owns definition/version/schema/rendering, Reporting owns source
selection/parameters/execution/export, Files owns generated bytes, and
connectors own protocol access. URLs do not carry credentials, arbitrary SQL,
or trusted raw personal data. Retries are idempotent and generation evidence
connects source, snapshot/reference, transformation, definition, parameters,
output checksum, actor, and policy.
This is the internal operations and municipal issue-reporting journey. ### Journey 4: Governed University BI Path
1. A person reports an issue. Starting from the Journey 3 source contract, one bounded university dataset is
2. The issue is triaged into helpdesk, facilities, assets, or a case. catalogued, staged by snapshot or watermark, validated, transformed through a
3. Work is assigned, tracked, and escalated. versioned lineage graph, and exposed as a policy-aware analytical data product.
4. Evidence, communication, and status updates are preserved. The result must preserve official-key mappings, organizational and reporting
5. Reports show workload, SLA, recurring problems, and completion. date semantics, quality findings, quarantine/replay, transparent calculation,
and reproducible promotion between development, test, and production.
This journey prevents `helpdesk`, `issue-reporting`, `facilities`, and `assets` Reporting consumes the product. Create `govoplan-datasources` or
from becoming disconnected ticket silos. `govoplan-dataflow` only after the concrete path proves repeated ownership that
does not belong to connectors, Reporting, or the producing domain module.
## Roadmap Waves ### Journey 5: Collaborative Document Lifecycle
An uploaded or generated artifact becomes a DMS document. Files continues to
own bytes; DMS owns identity, versions, renditions, editing sessions, locks,
comments, review, approval, comparison, and recovery; a collaboration connector
owns provider-specific protocol behavior; Records owns later classification,
hold, archive, and disposal.
The gate requires no silent lost updates, short-lived and currently authorized
editing sessions, idempotent authenticated callbacks, visible uncertain saves,
immutable accepted renditions, and a Records-ready handoff with stable content
and provenance.
## Capability Dependency Waves
The waves below remain a dependency and ownership catalogue for the wider
product vision. They are not the active delivery order. The five selected
journeys above and the meta roadmap decide what is implemented now; other
clusters remain dormant until a selected journey consumes them or they are
explicitly reprioritized.
### Wave 0: Platform Spine ### Wave 0: Platform Spine
@@ -248,9 +289,13 @@ Refine:
- `govoplan-core`: module discovery, capabilities, events, migrations, release - `govoplan-core`: module discovery, capabilities, events, migrations, release
catalog, configuration package runtime, WebUI shell. catalog, configuration package runtime, WebUI shell.
- `govoplan-access`: identities, sessions, API keys, users, groups, roles, - `govoplan-identity`: canonical identities and account links.
memberships, function assignments, delegation, RBAC decisions. - `govoplan-organizations`: organizational structures, units, and functions.
- `govoplan-tenancy`: tenant and organizational-unit boundaries. - `govoplan-idm`: identity-to-function assignments, directory synchronization,
preview, conflicts, and reconciliation.
- `govoplan-access`: sessions, API keys, users, groups, roles, memberships,
function-to-role projection, delegation, acting context, and RBAC decisions.
- `govoplan-tenancy`: tenant lifecycle and tenant boundaries.
- `govoplan-identity-trust`: initial trust contracts for device keys, public key - `govoplan-identity-trust`: initial trust contracts for device keys, public key
directory, assurance, and later encrypted postbox key access. directory, assurance, and later encrypted postbox key access.
- `govoplan-policy`: policy sources, policy decisions, retention inputs. - `govoplan-policy`: policy sources, policy decisions, retention inputs.
@@ -367,7 +412,7 @@ Goal: cover internal support and public issue reporting.
Create or refine in this order: Create or refine in this order:
1. `govoplan-issue-reporting`: public/internal reports, categories, intake, 1. `govoplan-tickets`: public/internal reports, requests, incidents, queues,
location, evidence, and triage. location, evidence, and triage.
2. `govoplan-helpdesk`: service desk tickets, queues, SLAs, assignments, 2. `govoplan-helpdesk`: service desk tickets, queues, SLAs, assignments,
escalation, and resolution evidence. escalation, and resolution evidence.
@@ -530,17 +575,30 @@ Before a module becomes release-included, it needs:
- smoke test or permutation test - smoke test or permutation test
- no required imports from optional modules - no required imports from optional modules
## Priority Order Summary ## Technical Dependency Order Summary
1. Stabilize the platform spine. Use this active order while respecting the ownership and implementation gates
2. Deliver permit-to-payment MVP. in the capability waves:
3. Build booking and resource operations.
4. Add learning and certificates. 1. Keep the platform/release spine green and extend connector, identity,
5. Add issue reporting and helpdesk. external-effect, provenance, documentation, focused-view, recovery, and
6. Add records, DMS, search, and transparency. version contracts only as the current journey requires.
7. Add procurement, contracts, grants, and finance handoff. 2. Complete and package Campaign with Mail-owned profiles, Files, target
8. Add committee and consultation workflows. delivery/recovery, central UI, and adaptive documentation.
9. Expand integration, dataflow, reporting, and operations. 3. Implement function-bound Postbox delivery through
OrganizationsIdentityIDMAccess and consume it from Campaign through a
typed capability.
4. Implement one data-backed Templates/Reporting path and safe HIS-style deep
launch.
5. Extend that concrete source into one governed university analytical data
product before generalizing data-source or dataflow ownership.
6. Implement Files-backed DMS versions and one provider-neutral collaborative
editing lifecycle, then connect Records handoff.
7. Maintain already integrated Calendar/Scheduling/Poll and other foundations;
activate another capability cluster only when the current journey needs it
or the product roadmap explicitly reprioritizes it.
8. Resume Workflow only by explicit product decision and constrain it with
stable actions from one demonstrated package.
## Deliberate Deferrals ## Deliberate Deferrals
@@ -548,7 +606,8 @@ Defer these until a reference journey proves the need:
- full ERP replacement - full ERP replacement
- native project management beyond connector support - native project management beyond connector support
- broad BI/dataflow platform - an unbounded general-purpose dataflow platform; the bounded governed BI
reference journey is selected
- every possible public-sector protocol adapter - every possible public-sector protocol adapter
- rich LMS behavior beyond training administration - rich LMS behavior beyond training administration
- full qualified digital signing/trust services beyond the identity-trust and - full qualified digital signing/trust services beyond the identity-trust and
@@ -566,48 +625,48 @@ repositories or to explicit missing-module decisions.
| Idea | Owner | Tracking | | Idea | Owner | Tracking |
| --- | --- | --- | | --- | --- | --- |
| Government operations backbone reference model | `govoplan-core` | `add-ideas/govoplan-core#213` | | Government operations backbone reference model | `govoplan-core` | `GovOPlaN/govoplan-core#213` |
| Permit-to-payment configuration package | `govoplan-core` plus participating modules | `add-ideas/govoplan-core#214` | | Permit-to-payment configuration package | `govoplan-core` plus participating modules | `GovOPlaN/govoplan-core#214` |
| Fully UI-managed configuration with safety controls | `govoplan-admin`, `govoplan-core`, `govoplan-policy`, `govoplan-access`, `govoplan-audit` | `add-ideas/govoplan-core#218` | | Fully UI-managed configuration with safety controls | `govoplan-admin`, `govoplan-core`, `govoplan-policy`, `govoplan-access`, `govoplan-audit` | `GovOPlaN/govoplan-core#218` |
| Access as a module | `govoplan-access` | `add-ideas/govoplan-access#7` | | Access as a module | `govoplan-access` | `GovOPlaN/govoplan-access#7` |
| Interface ethics and decision-consequence doctrine | `govoplan-core` plus all UI-owning modules | `add-ideas/govoplan-core#227` | | Interface ethics and decision-consequence doctrine | `govoplan-core` plus all UI-owning modules | `GovOPlaN/govoplan-core#227` |
| Action/effect automation layer | first `govoplan-workflow`; possible future `govoplan-automation` | `add-ideas/govoplan-workflow#1` | | Action/effect automation layer | first `govoplan-workflow`; possible future `govoplan-automation` | `GovOPlaN/govoplan-workflow#1` |
| E2EE role/function postbox architecture | `govoplan-postbox`, `govoplan-identity-trust`, `govoplan-access`, `govoplan-policy`, `govoplan-audit` | `add-ideas/govoplan-postbox#15`, `add-ideas/govoplan-identity-trust#1` | | E2EE role/function postbox architecture | `govoplan-postbox`, `govoplan-identity-trust`, `govoplan-access`, `govoplan-policy`, `govoplan-audit` | `GovOPlaN/govoplan-postbox#15`, `GovOPlaN/govoplan-identity-trust#1` |
| Identity, account, function, role, right semantic model | `govoplan-access` | `add-ideas/govoplan-access#9` | | Identity, account, function, role, right semantic model | `govoplan-access` | `GovOPlaN/govoplan-access#9` |
| Role-based service directory/catalog | `govoplan-portal` | `add-ideas/govoplan-portal#1` | | Role-based service directory/catalog | `govoplan-portal` | `GovOPlaN/govoplan-portal#1` |
| Unified inbox across tasks, postbox, notifications, and portal | `govoplan-core` coordination plus owning modules | `add-ideas/govoplan-tasks#1`, `add-ideas/govoplan-notifications#1` | | Unified inbox across tasks, postbox, notifications, and portal | `govoplan-core` coordination plus owning modules | `GovOPlaN/govoplan-tasks#1`, `GovOPlaN/govoplan-notifications#1` |
| OpenProject API / project management connector | `govoplan-connectors` | `add-ideas/govoplan-connectors#1` | | OpenProject API / project management connector | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#1` |
| Native project-management module decision | connector-first through `govoplan-connectors`; no native project module yet | `add-ideas/govoplan-core#196`, `add-ideas/govoplan-connectors#1` | | Native project-management module decision | connector-first through `govoplan-connectors`; no native project module yet | `GovOPlaN/govoplan-core#196`, `GovOPlaN/govoplan-connectors#1` |
| Datasources for databases, CSV, files, APIs | no repository yet; start with connectors/files/reporting and create `govoplan-datasources` only after the first package proves shared source-catalog ownership | `add-ideas/govoplan-core#197` | | Datasources for databases, CSV, files, APIs | no repository yet; start with connectors/files/reporting and create `govoplan-datasources` only after the first package proves shared source-catalog ownership | `GovOPlaN/govoplan-core#197` |
| Dataflow for pipelines, BI, publication | no repository yet; start with workflow/reporting/connectors and create `govoplan-dataflow` only after repeated pipeline/lineage contracts emerge | `add-ideas/govoplan-core#198` | | Dataflow for pipelines, BI, publication | no repository yet; start with workflow/reporting/connectors and create `govoplan-dataflow` only after repeated pipeline/lineage contracts emerge | `GovOPlaN/govoplan-core#198` |
| Monthly datasource and transformation workflows | first as configuration package across connectors, files, workflow, reporting, and templates | `add-ideas/govoplan-core#216` | | Monthly datasource and transformation workflows | first as configuration package across connectors, files, workflow, reporting, and templates | `GovOPlaN/govoplan-core#216` |
| Templates for letters, emails, forms, reports | `govoplan-templates`, separate from reporting | `add-ideas/govoplan-core#190`, `add-ideas/govoplan-templates#1` | | Templates for letters, emails, forms, reports | `govoplan-templates`, separate from reporting | `GovOPlaN/govoplan-core#190`, `GovOPlaN/govoplan-templates#1` |
| Reporting and BI | `govoplan-reporting`, separate from templates | `add-ideas/govoplan-core#190`, `add-ideas/govoplan-reporting#1` | | Reporting and BI | `govoplan-reporting`, separate from templates | `GovOPlaN/govoplan-core#190`, `GovOPlaN/govoplan-reporting#1` |
| File connectors: Nextcloud, Seafile, SMB, NFS | `govoplan-files` | `add-ideas/govoplan-files#15` | | File connectors: Nextcloud, Seafile, SMB, NFS | `govoplan-files` | `GovOPlaN/govoplan-files#15` |
| Public-sector software integration catalogue | `govoplan-connectors` with core strategy index | `add-ideas/govoplan-core#191`, `add-ideas/govoplan-connectors#2` | | Public-sector software integration catalogue | `govoplan-connectors` with core strategy index | `GovOPlaN/govoplan-core#191`, `GovOPlaN/govoplan-connectors#2` |
| Public-sector integration landscape catalogue | `govoplan-connectors` with core tracking | `add-ideas/govoplan-core#215` | | Public-sector integration landscape catalogue | `govoplan-connectors` with core tracking | `GovOPlaN/govoplan-core#215` |
| Cases module concept | `govoplan-cases` | `add-ideas/govoplan-core#174` | | Cases module concept | `govoplan-cases` | `GovOPlaN/govoplan-core#174` |
| Workflow module concept | `govoplan-workflow` | `add-ideas/govoplan-core#175` | | Workflow module concept | `govoplan-workflow` | `GovOPlaN/govoplan-core#175` |
| Connectors module concept | `govoplan-connectors` | `add-ideas/govoplan-core#176` | | Connectors module concept | `govoplan-connectors` | `GovOPlaN/govoplan-core#176` |
| Adrema-style address and distribution-list management | `govoplan-addresses` | `add-ideas/govoplan-addresses#1` | | Adrema-style address and distribution-list management | `govoplan-addresses` | `GovOPlaN/govoplan-addresses#1` |
| Consume sources and become a governed source | `govoplan-connectors` plus possible future `govoplan-dataflow` | `add-ideas/govoplan-connectors#3`, `add-ideas/govoplan-core#198` | | Consume sources and become a governed source | `govoplan-connectors` plus possible future `govoplan-dataflow` | `GovOPlaN/govoplan-connectors#3`, `GovOPlaN/govoplan-core#198` |
| Governed connector configuration, dry-run, and simulation runtime | `govoplan-connectors` | `add-ideas/govoplan-connectors#6` | | Governed connector configuration, dry-run, and simulation runtime | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#6` |
| Terminfindung and meeting scheduling polls | `govoplan-scheduling`; calendar primitives remain in calendar | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-scheduling#1` | | Terminfindung and meeting scheduling polls | `govoplan-scheduling`; calendar primitives remain in calendar | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-scheduling#1` |
| Terminplaner and calendar primitives | `govoplan-calendar` | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-calendar#1` | | Terminplaner and calendar primitives | `govoplan-calendar` | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-calendar#1` |
| Terminbuchung appointment booking | `govoplan-appointments` | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-appointments#1` | | Terminbuchung appointment booking | `govoplan-appointments` | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-appointments#1` |
| Collaborative documents | `govoplan-dms` | `add-ideas/govoplan-dms#1` | | Collaborative documents | `govoplan-dms` | `GovOPlaN/govoplan-dms#1` |
| Forms | `govoplan-forms` for definitions and `govoplan-forms-runtime` for submissions/runtime behavior | `add-ideas/govoplan-core#194`, `add-ideas/govoplan-forms#1` | | Forms | `govoplan-forms` for definitions and `govoplan-forms-runtime` for submissions/runtime behavior | `GovOPlaN/govoplan-core#194`, `GovOPlaN/govoplan-forms#1` |
| RSS consume and emit | `govoplan-connectors` | `add-ideas/govoplan-connectors#4` | | RSS consume and emit | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#4` |
| LDAP, Active Directory, OpenDesk identity | `govoplan-idm` | `add-ideas/govoplan-idm#1` | | LDAP, Active Directory, OpenDesk identity | `govoplan-idm` | `GovOPlaN/govoplan-idm#1` |
| OpenDesk stack integration map | integration profile across IDM/access, mail/calendar, files/DMS, and connectors; not a monolithic module | `add-ideas/govoplan-core#195`, `add-ideas/govoplan-connectors#5` | | OpenDesk stack integration map | integration profile across IDM/access, mail/calendar, files/DMS, and connectors; not a monolithic module | `GovOPlaN/govoplan-core#195`, `GovOPlaN/govoplan-connectors#5` |
| Open-Xchange mail/groupware | `govoplan-mail` | `add-ideas/govoplan-mail#5` | | Open-Xchange mail/groupware | `govoplan-mail` | `GovOPlaN/govoplan-mail#5` |
| Open-Xchange calendar | `govoplan-calendar` | `add-ideas/govoplan-calendar#2` | | Open-Xchange calendar | `govoplan-calendar` | `GovOPlaN/govoplan-calendar#2` |
| Scalability profiles and autoscaling readiness | `govoplan-ops`, `govoplan-core` | `add-ideas/govoplan-core#217` | | Scalability profiles and autoscaling readiness | `govoplan-ops`, `govoplan-core` | `GovOPlaN/govoplan-core#217` |
| Hardware sizing matrix and requirements calculator | `govoplan-ops`, `govoplan-core` | `add-ideas/govoplan-core#219` | | Hardware sizing matrix and requirements calculator | `govoplan-ops`, `govoplan-core` | `GovOPlaN/govoplan-core#219` |
| Collaboration suite integration strategy | `govoplan-connectors`, `govoplan-dms`, `govoplan-workflow`, `govoplan-tasks`, `govoplan-appointments`, `govoplan-calendar` | `add-ideas/govoplan-core#220` | | Collaboration suite integration strategy | `govoplan-connectors`, `govoplan-dms`, `govoplan-workflow`, `govoplan-tasks`, `govoplan-appointments`, `govoplan-calendar` | `GovOPlaN/govoplan-core#220` |
| Install/runtime configuration contract | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#19` | | Install/runtime configuration contract | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#19` |
| Installer/deployment operator flow | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#26` | | Installer/deployment operator flow | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#26` |
| Production-like deployment documentation | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#28` | | Production-like deployment documentation | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#28` |
Boundary rationale lives in `MODULE_ARCHITECTURE.md`. Current decisions: Boundary rationale lives in `MODULE_ARCHITECTURE.md`. Current decisions:
@@ -645,19 +704,24 @@ Release composition and tag-only repository handling are documented in
## Next Practical Work ## Next Practical Work
The next planning step should create or update Gitea issues for Wave 0 and Wave The active cross-product story is
1 only. Later waves should stay as roadmap context until the permit-to-payment [`GovOPlaN/govoplan#14`](https://git.add-ideas.de/GovOPlaN/govoplan/issues/14).
MVP is demonstrable. Module repositories own implementation issues; do not clone their state here.
Recommended immediate issue buckets: Immediate issue buckets:
- platform spine hardening - fail-closed connector destination pinning and private-network deployment
- configuration package preflight and rollback control for every real transport
- forms-runtime MVP - Mail-profile-only Campaign authoring/build/delivery and safe legacy failure
- portal submission MVP - immediate audited secret deletion when a provider/profile is removed
- cases/workflow/tasks integration MVP - Campaign central-component and role-safe UI acceptance
- template-generated decision document - adaptive Campaign, Mail, and Files task/process/admin/operator/integration/
- postbox/notification handoff security/acceptance documentation
- appointment/booking handoff - target SMTP/IMAP, file-provider, queue/reconciliation, install/upgrade, and
- payment evidence handoff restore proof for the pinned Campaign reference composition
- configured documentation for the reference process
Once that gate is demonstrable, activate the existing Postbox model, access,
API, inbox, and Campaign integration issues. Templates/Reporting, governed BI,
and DMS collaboration remain durable selected direction, but should be
decomposed only as the preceding stage stabilizes or a bounded independent
contract can be implemented without pre-deciding target-system choices.

View File

@@ -74,6 +74,10 @@ The compatibility/deprecation plan for the current split line is:
- reject new cross-module imports that bypass manifests, capabilities, events, - reject new cross-module imports that bypass manifests, capabilities, events,
or public module APIs or public module APIs
The retention windows and removal checklist for database bridges,
configuration/export schemas, and runtime/API aliases are defined in
`COMPATIBILITY_POLICY.md`.
## Stable Kernel Contracts ## Stable Kernel Contracts
The following contracts are the baseline API that modules can rely on: The following contracts are the baseline API that modules can rely on:
@@ -87,7 +91,8 @@ The following contracts are the baseline API that modules can rely on:
- capability factory contract - capability factory contract
- access DTO/protocol contracts in `govoplan_core.core.access` - access DTO/protocol contracts in `govoplan_core.core.access`
- resource ACL provider contract - resource ACL provider contract
- tenant summary provider contract - bounded reference-option search provider contract
- single-tenant and optional batched tenant summary provider contracts
- tenant delete-veto provider contract - tenant delete-veto provider contract
- WebUI module contribution contract - WebUI module contribution contract
- navigation metadata contract - navigation metadata contract
@@ -96,6 +101,15 @@ The following contracts are the baseline API that modules can rely on:
Changes to these contracts must be versioned or accompanied by compatibility shims. Changes to these contracts must be versioned or accompanied by compatibility shims.
Tenant list pages prefer `tenant_summary_batch_providers`. A batch provider
receives the unique tenant IDs on the current page and returns count mappings
keyed by tenant ID. Missing tenant keys mean that the provider has no counts for
that tenant; provider errors remain visible. Modules that expose only the
single-tenant contract remain compatible through a per-tenant fallback.
Destructive tenant lifecycle planning deliberately continues to use the
single-tenant path so it invokes every registered provider for the target
tenant, independent of ordinary list-page projections.
This list is the Milestone A kernel-contract freeze baseline. New module work This list is the Milestone A kernel-contract freeze baseline. New module work
may extend the kernel by adding explicit contracts, but existing contracts must may extend the kernel by adding explicit contracts, but existing contracts must
remain source-compatible through the 0.1.x split line unless a migration shim remain source-compatible through the 0.1.x split line unless a migration shim
@@ -126,6 +140,15 @@ Feature modules should prefer these capabilities over direct reads of
access/tenant ORM models when they need labels, group membership, default access/tenant ORM models when they need labels, group membership, default
access provisioning, counts, audit actor labels, or tenant metadata. access provisioning, counts, audit actor labels, or tenant metadata.
Other stable runtime capabilities currently include:
- `identity.directory` and `identity.search`
- `organizations.directory`
- `idm.directory`
- `calendar.outbox` and `calendar.scheduling`
- `poll.scheduling`
- `notifications.dispatch`
### Named Interface Contracts ### Named Interface Contracts
Capabilities are runtime objects. Named interface contracts are compatibility Capabilities are runtime objects. Named interface contracts are compatibility
@@ -147,15 +170,25 @@ intended for SemVer major-version lines. Missing optional interfaces are
allowed, but an installed provider with an incompatible version blocks allowed, but an installed provider with an incompatible version blocks
activation because the integration would otherwise bind to an unsafe API. activation because the integration would otherwise bind to an unsafe API.
Current named interfaces: Current named interfaces, generated from the source manifests by the workspace
contract checks, are:
- `files.campaign_attachments` - `addresses.contact_writer`, `addresses.lookup`, `addresses.recipient_source`
- `calendar.outbox`, `calendar.scheduling`
- `campaigns.access`, `campaigns.delivery_tasks`,
`campaigns.mail_policy_context`, `campaigns.policy_context`,
`campaigns.retention`
- `dist_lists.expand`, `dist_lists.source`, `dist_lists.writer`
- `evaluation.feedback`, `evaluation.result_aggregation`, `evaluation.scoring`
- `files.access`, `files.campaign_attachments`
- `mail.campaign_delivery` - `mail.campaign_delivery`
- `campaigns.access` - `notifications.dispatch`
- `campaigns.delivery_tasks` - `poll.availability_matrix`, `poll.option_selection`,
- `campaigns.mail_policy_context` `poll.response_collection`, `poll.signed_participation`,
- `campaigns.policy_context` `poll.workflow_context`
- `campaigns.retention` - `rest.function_publication`
- `scheduling.candidate_slots`, `scheduling.decision_handoff`
- `soap.operation_publication`
Core validates named interface contracts in three places: Core validates named interface contracts in three places:
@@ -314,6 +347,31 @@ full snapshot with `full: true`. A first-use `seq:0` watermark remains valid
until such a floor exists, even if unrelated collections have advanced the until such a floor exists, even if unrelated collections have advanced the
global sequence. global sequence.
### Bounded Reference Selectors
Cross-module selectors use the module-neutral contract in
`govoplan_core.core.references`; consumers must not load an optional module's
complete directory and filter it in memory.
- Providers receive a normalized `ReferenceSearchRequest` with `kind`,
`tenant_id`, `query`, `selected_values`, `limit`, optional opaque `cursor`,
and policy context.
- Providers apply visibility and text filtering before materializing rows and
return `ReferenceSearchPage(options, next_cursor, has_more)`.
- A page contains at most the requested bounded search results. Already-selected
references are retained in addition to that bound so historical values remain
readable and removable even when they are inactive, deleted, or outside the
current search page.
- API consumers expose `next_cursor` and `has_more`. The current searchable
selector requests the first bounded page for each query; later load-more UI
can use the same cursor without changing the provider contract.
- `access.reference_options` supplies SQL-backed account, membership, and group
searches. When it is absent, Core degrades to the legacy Access directory or
to principal-only/unavailable references without importing Access.
- The shared WebUI `apiReferenceOptionProvider` resolves selected values in
chunks of at most 200, preventing a large existing selection from turning
into an unbounded request.
### Cursor/Keyset Pages ### Cursor/Keyset Pages
Offset pagination remains supported for compatibility and for first page loads, Offset pagination remains supported for compatibility and for first page loads,
@@ -434,6 +492,18 @@ The manifest should declare:
- navigation metadata using serializable icon names - navigation metadata using serializable icon names
- uninstall guard providers for data, migration, worker, or scheduler vetoes - uninstall guard providers for data, migration, worker, or scheduler vetoes
A tenant-level managed `RoleTemplate` may set `default_authenticated=True`
only when every authenticated tenant member must receive that narrow baseline
while the contributing module is installed. Access derives the explicit grant
from the active manifest set during authorization without mutating the request
transaction. It may materialize a non-assignable role row for administration,
but no per-user assignment is required and role edits cannot remove the
baseline.
This is not a shortcut for feature authorization: keep the template narrow and
continue to enforce each domain action's own permission and resource policy.
System-level, unmanaged, wildcard-bearing, or slug-colliding automatic
templates are rejected by registry validation.
Backend nav metadata must use icon-name strings, not frontend components: Backend nav metadata must use icon-name strings, not frontend components:
```python ```python
@@ -554,11 +624,18 @@ Uninstall remains non-destructive unless the operator explicitly requests
## WebUI Contract ## WebUI Contract
A WebUI module exports a `PlatformWebModule` from its package. The object contributes local/fallback metadata and route render functions. A WebUI module exports a `PlatformWebModule` from its package. The object
contributes local/fallback metadata and route render functions. The package
must ship `src/module.ts` with the default contribution export: Core's Vite
host imports that descriptor directly after the backend reports the module as
enabled. This keeps package-root re-exports from pulling page implementations
into the initial shell.
Example: Example:
```ts ```ts
const FilesPage = lazy(() => import("./features/files/FilesPage"));
export const filesModule: PlatformWebModule = { export const filesModule: PlatformWebModule = {
id: "files", id: "files",
label: "Files", label: "Files",
@@ -573,6 +650,11 @@ export const filesModule: PlatformWebModule = {
}; };
``` ```
Route pages and substantial panels must use stable lazy imports. Core supplies
the shared loading and retryable error state around route rendering. The
initial static import closure and largest asynchronous chunk are enforced by
the budgets documented in [WEBUI_BUNDLE_BUDGETS.md](WEBUI_BUNDLE_BUDGETS.md).
WebUI modules receive only the core route context: WebUI modules receive only the core route context:
- `settings` - `settings`

View File

@@ -13,6 +13,7 @@ consistent while each module still owns its domain rules.
| RBAC/access policy | `govoplan-access` | access capabilities in `govoplan_core.core.access` | Permission decisions should use access capability contracts. Explain responses should adopt `PolicyDecision` when an API-level explanation is added. | | RBAC/access policy | `govoplan-access` | access capabilities in `govoplan_core.core.access` | Permission decisions should use access capability contracts. Explain responses should adopt `PolicyDecision` when an API-level explanation is added. |
| Governance defaults | `govoplan-admin` plus `govoplan-access` materializer | admin settings, governance template routes, access materialization capability | System governance can block tenant-local groups, roles, and API keys. | | Governance defaults | `govoplan-admin` plus `govoplan-access` materializer | admin settings, governance template routes, access materialization capability | System governance can block tenant-local groups, roles, and API keys. |
| Delegation and ownership policy | access/campaign/mail/files modules | capability checks and owner-scoped APIs | Source provenance should use this contract when policies become externally explainable. | | Delegation and ownership policy | access/campaign/mail/files modules | capability checks and owner-scoped APIs | Source provenance should use this contract when policies become externally explainable. |
| Definition governance | `govoplan-policy` | capability `policy.definitionGovernance` | Resolves view, edit, run/start, reuse, derive, and automate for system, tenant, group, and user Dataflow/Workflow definitions. |
## Policy Decision ## Policy Decision
@@ -97,6 +98,34 @@ New backend code should import policy-owned retention behavior from
`govoplan-policy` or request the capability, not add new implementation logic `govoplan-policy` or request the capability, not add new implementation logic
to core. to core.
The retention API DTOs live in `govoplan_core.privacy.schemas`.
`PrivacyRetentionPolicyItem`, `PrivacyRetentionPolicyPatchItem`,
`RETENTION_POLICY_FIELD_KEYS`, and `default_allow_lower_level_limits()` are
platform contracts because admin, access compatibility, and policy routes expose
the same stable retention payload shape. The policy engine's internal
`PrivacyRetentionPolicy` and `PrivacyRetentionPolicyPatch` models stay in
`govoplan-policy`, because they carry implementation validators and merge
behavior that are not generic API contracts.
Tenant administration DTOs remain owned by `govoplan-tenancy`; access keeps
matching compatibility DTOs only for its legacy admin surface. Admin overview
responses remain module-local because the same counters are exposed from
different menu contexts and are not yet a separately versioned platform API.
## Definition Governance
Dataflow and Workflow submit a `DefinitionGovernanceRequest` using only stable
scope, principal, status, definition-kind, and limit fields. Policy returns a
standard `PolicyDecision`. System definitions may be inherited as read-only;
group and user definitions are visible only in matching contexts. Templates
may be viewed and derived but never run or automated. A derived definition
passes its pinned ancestor limits back through the request context, and Policy
applies those limits as ceilings rather than defaults that can be broadened.
When the capability is absent, modules must not silently emulate cross-scope
inheritance. Their conservative fallback is limited to local tenant
definitions and disables reuse, derivation, and automation.
## Frontend Contract ## Frontend Contract
Policy UIs must: Policy UIs must:

View File

@@ -57,6 +57,11 @@ The trust layer should provide:
- key rotation and epoch tracking - key rotation and epoch tracking
- recovery policy hooks - recovery policy hooks
Recovery must be organizationally governed. A server-held universal plaintext
key would defeat the E2EE claim; any escrow, threshold recovery, or emergency
grant needs an explicit assurance profile, authority/quorum, audit trail, and
user-visible consequence.
## Role And Function Postboxes ## Role And Function Postboxes
Role-bound access needs special handling. A postbox can be bound to an Role-bound access needs special handling. A postbox can be bound to an
@@ -75,6 +80,30 @@ rewrapping service:
Key epochs are required when role membership changes. Older messages may remain Key epochs are required when role membership changes. Older messages may remain
readable according to policy, but new access must use the current epoch. readable according to policy, but new access must use the current epoch.
The function-bound container exists independently of membership. It may remain
vacant and continue to receive ciphertext without falling back to an unrelated
personal mailbox. Zero, one, or several incumbents are valid states. Each
incumbent receives an independently auditable, device-bound wrapped-key path;
the postbox is never copied into their account ownership.
A new assignment or hand-over rotates the function/postbox key epoch. Envelope
encryption permits the normal rotation path to rewrap per-message data keys
rather than rewrite large ciphertext objects; a security policy may require
full content re-encryption for selected compromise or cryptographic-profile
events. The history available to a new incumbent must be selected policy (all
retained history, a bounded historical window, or assignment-time content) and
recorded with the grant.
Delegation is a time-bounded represented-function grant, not a copy or
substitution of the postbox. Expiry or withdrawal stops future key release and
actions. It cannot revoke plaintext already decrypted, printed, exported, or
captured outside the platform. Multiple simultaneous incumbents and delegates
remain distinguishable in key-fetch and action evidence.
Postbox content and signed manifests are immutable. Correction or replacement
creates a linked new object/version; it never silently substitutes ciphertext
or evidence that another actor may already have inspected.
## External Recipients ## External Recipients
External recipients may need one-time or time-limited access without a full External recipients may need one-time or time-limited access without a full

View File

@@ -5,6 +5,9 @@ before deciding to replace specialist workflows. This document is the core
strategy index. The executable connector catalogue lives in strategy index. The executable connector catalogue lives in
`govoplan-connectors/docs/PUBLIC_SECTOR_INTEGRATION_CATALOGUE.md`. `govoplan-connectors/docs/PUBLIC_SECTOR_INTEGRATION_CATALOGUE.md`.
The canonical cumulative maturity model and external-object DTO are documented
in [EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md](EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md).
## Strategy Labels ## Strategy Labels
Use one or more of these labels for every external system family: Use one or more of these labels for every external system family:

View File

@@ -40,17 +40,26 @@ cd /mnt/DATA/git/govoplan
Update those refs when cutting a release: Update those refs when cutting a release:
```text ```text
govoplan-access git@git.add-ideas.de:add-ideas/govoplan-access.git v0.1.6 govoplan-tenancy git@git.add-ideas.de:GovOPlaN/govoplan-tenancy.git v0.1.8
govoplan-admin git@git.add-ideas.de:add-ideas/govoplan-admin.git v0.1.6 govoplan-organizations git@git.add-ideas.de:GovOPlaN/govoplan-organizations.git v0.1.8
govoplan-tenancy git@git.add-ideas.de:add-ideas/govoplan-tenancy.git v0.1.6 govoplan-identity git@git.add-ideas.de:GovOPlaN/govoplan-identity.git v0.1.8
govoplan-organizations git@git.add-ideas.de:add-ideas/govoplan-organizations.git v0.1.6 govoplan-idm git@git.add-ideas.de:GovOPlaN/govoplan-idm.git v0.1.8
govoplan-identity git@git.add-ideas.de:add-ideas/govoplan-identity.git v0.1.6 govoplan-access git@git.add-ideas.de:GovOPlaN/govoplan-access.git v0.1.8
govoplan-policy git@git.add-ideas.de:add-ideas/govoplan-policy.git v0.1.6 govoplan-admin git@git.add-ideas.de:GovOPlaN/govoplan-admin.git v0.1.8
govoplan-audit git@git.add-ideas.de:add-ideas/govoplan-audit.git v0.1.6 govoplan-policy git@git.add-ideas.de:GovOPlaN/govoplan-policy.git v0.1.8
govoplan-files git@git.add-ideas.de:add-ideas/govoplan-files.git v0.1.6 govoplan-audit git@git.add-ideas.de:GovOPlaN/govoplan-audit.git v0.1.8
govoplan-mail git@git.add-ideas.de:add-ideas/govoplan-mail.git v0.1.6 govoplan-dashboard git@git.add-ideas.de:GovOPlaN/govoplan-dashboard.git v0.1.8
govoplan-campaign git@git.add-ideas.de:add-ideas/govoplan-campaign.git v0.1.6 govoplan-addresses git@git.add-ideas.de:GovOPlaN/govoplan-addresses.git v0.1.8
govoplan-calendar git@git.add-ideas.de:add-ideas/govoplan-calendar.git v0.1.6 govoplan-files git@git.add-ideas.de:GovOPlaN/govoplan-files.git v0.1.8
govoplan-mail git@git.add-ideas.de:GovOPlaN/govoplan-mail.git v0.1.8
govoplan-campaign git@git.add-ideas.de:GovOPlaN/govoplan-campaign.git v0.1.8
govoplan-calendar git@git.add-ideas.de:GovOPlaN/govoplan-calendar.git v0.1.8
govoplan-poll git@git.add-ideas.de:GovOPlaN/govoplan-poll.git v0.1.8
govoplan-scheduling git@git.add-ideas.de:GovOPlaN/govoplan-scheduling.git v0.1.8
govoplan-notifications git@git.add-ideas.de:GovOPlaN/govoplan-notifications.git v0.1.8
govoplan-evaluation git@git.add-ideas.de:GovOPlaN/govoplan-evaluation.git v0.1.8
govoplan-docs git@git.add-ideas.de:GovOPlaN/govoplan-docs.git v0.1.8
govoplan-ops git@git.add-ideas.de:GovOPlaN/govoplan-ops.git v0.1.8
``` ```
## WebUI Packages ## WebUI Packages
@@ -70,18 +79,18 @@ cd /mnt/DATA/git/govoplan-core/webui
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run build PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run build
``` ```
The module repositories include root-level npm package manifests so git Module repositories with a frontend include root-level npm package manifests
installs can resolve `@govoplan/access-webui`, `@govoplan/admin-webui`, so the `@govoplan/*-webui` dependencies in `webui/package.release.json` can be
`@govoplan/files-webui`, `@govoplan/mail-webui`, resolved from repository roots even though their source lives below
`@govoplan/campaign-webui`, and `@govoplan/calendar-webui` from repository `webui/src`.
roots even though their source lives below `webui/src`.
### Release Lockfile Strategy ### Release Lockfile Strategy
The supported release composition currently is the full GovOPlaN product: core The supported backend release composition is the set pinned in the meta
plus access, admin, tenancy, organizations, identity, policy, audit, repository's `requirements-release.txt`. The supported frontend composition
dashboard, files, mail, campaign, calendar, docs, and ops. Keep one committed is the independently buildable module set pinned in
full-product release lockfile at `webui/package.release.json`; backend-only modules do not need a frontend
package entry. Keep one committed full-product release lockfile at
`webui/package-lock.release.json`, generated from `webui/package-lock.release.json`, generated from
`webui/package.release.json` in a clean release workspace. Development `webui/package.release.json` in a clean release workspace. Development
`package-lock.json` may continue to point at local `file:` dependencies. `package-lock.json` may continue to point at local `file:` dependencies.
@@ -104,7 +113,7 @@ working tree has already been bumped, pass the current version explicitly:
```bash ```bash
cd /mnt/DATA/git/govoplan cd /mnt/DATA/git/govoplan
tools/release/push-release-tag.sh --version 0.1.6 tools/release/push-release-tag.sh --version 0.1.8
``` ```
`/mnt/DATA/git/govoplan/tools/release/generate-release-catalog.py` reads installed/discovered `/mnt/DATA/git/govoplan/tools/release/generate-release-catalog.py` reads installed/discovered
@@ -126,7 +135,6 @@ are not listed in `requirements-release.txt` or `webui/package.release.json`.
Current tag-only module repositories: Current tag-only module repositories:
- `govoplan-addresses`
- `govoplan-appointments` - `govoplan-appointments`
- `govoplan-cases` - `govoplan-cases`
- `govoplan-connectors` - `govoplan-connectors`
@@ -135,13 +143,11 @@ Current tag-only module repositories:
- `govoplan-fit-connect` - `govoplan-fit-connect`
- `govoplan-forms` - `govoplan-forms`
- `govoplan-identity-trust` - `govoplan-identity-trust`
- `govoplan-idm`
- `govoplan-ledger` - `govoplan-ledger`
- `govoplan-notifications`
- `govoplan-payments` - `govoplan-payments`
- `govoplan-portal` - `govoplan-portal`
- `govoplan-postbox`
- `govoplan-reporting` - `govoplan-reporting`
- `govoplan-scheduling`
- `govoplan-search` - `govoplan-search`
- `govoplan-tasks` - `govoplan-tasks`
- `govoplan-templates` - `govoplan-templates`
@@ -782,10 +788,27 @@ tools/checks/postgres-integration-check.py \
The script checks migrations and `/health` startup for core-only, files-only, The script checks migrations and `/health` startup for core-only, files-only,
mail-only, campaign-only, campaign+files, campaign+mail, and full-product mail-only, campaign-only, campaign+files, campaign+mail, and full-product
module sets. `--reset-schema` is destructive and must only be used against a module sets. `--reset-schema` is destructive and must only be used against a
throwaway database. throwaway database. Before those permutations, the required Core proof runs in
random, test-owned schemas without modifying `public`. It exercises Files' real
credential-owning retirement provider and proves that credential scrubbing,
non-secret audit
insertion, and table retirement commit together; database-injected audit and
DDL failures roll the entire unit back. A 500 ms PostgreSQL `lock_timeout` and
captured backend process IDs also prove that each `DROP TABLE` uses the
installer Session connection instead of waiting through a second connection.
The meta check enables the release-gate flag so missing PostgreSQL configuration
or full-stack test packages are a hard failure; ordinary Core-only test discovery
skips this integration proof. Do not pass `--skip-retirement-atomicity` when
collecting release evidence.
## Migration Baselines ## Migration Baselines
Release migration support follows `COMPATIBILITY_POLICY.md`: every tagged
`0.1.x` installation is a supported upgrade origin, released revision IDs are
immutable, and migration-only reconciliation remains available for at least one
subsequent major release cycle after the matching runtime compatibility path is
removed.
Development migrations may be small and numerous while a feature is moving. Development migrations may be small and numerous while a feature is moving.
GovOPlaN keeps those detailed migrations on an explicit development track and GovOPlaN keeps those detailed migrations on an explicit development track and
publishes reviewed release shortcuts on the release track. Before a stable publishes reviewed release shortcuts on the release track. Before a stable
@@ -875,7 +898,7 @@ before that baseline, so pre-v0.1.7 development revisions are not release
upgrade targets. Future release-to-release changes must start from a recorded upgrade targets. Future release-to-release changes must start from a recorded
release baseline and add a new release-track step-up instead of replacing prior release baseline and add a new release-track step-up instead of replacing prior
release shortcuts. The tracking issue is release shortcuts. The tracking issue is
`add-ideas/govoplan-core#223`. `GovOPlaN/govoplan-core#223`.
## Related Operator Documents ## Related Operator Documents

View File

@@ -40,10 +40,16 @@ set +a
The command reports all known blockers at once. Production-like/self-hosted The command reports all known blockers at once. Production-like/self-hosted
profiles require explicit `APP_ENV`, `DATABASE_URL`, `MASTER_KEY_B64`, profiles require explicit `APP_ENV`, `DATABASE_URL`, `MASTER_KEY_B64`,
`ENABLED_MODULES`, and `CORS_ORIGINS`. Production rejects SQLite, development `ENABLED_MODULES`, `CORS_ORIGINS`, `GOVOPLAN_TRUSTED_HOSTS`, and a deployment-wide decision for
`GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS`. Production rejects SQLite, development
bootstrap, insecure auth cookies, and unsigned catalog trust roots when a bootstrap, insecure auth cookies, and unsigned catalog trust roots when a
catalog source is configured. catalog source is configured.
Connector process-secret names and custom CA files are deployment-owned through
the exact, default-empty `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST` and
`GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST`; tenant/API configuration cannot widen
either boundary.
## Production-Like Dev Stack ## Production-Like Dev Stack
Use the local production-like wrapper for repeatable rehearsal: Use the local production-like wrapper for repeatable rehearsal:

24
docs/THROTTLING.md Normal file
View File

@@ -0,0 +1,24 @@
# Shared fixed-window throttling
Core exposes `govoplan_core.core.throttling` for security-sensitive endpoints
that need bounded fixed-window counters. Subjects are SHA-256 hashed before they
become store keys. A configured Redis instance provides atomic counters shared
across API workers. Development and temporary Redis outages use a bounded
process-local fallback. Production-like startup rejects an enabled login
throttle without `REDIS_URL` unless
`GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE=true` explicitly acknowledges the
single-process limitation. When Redis fails at runtime, local attempts are still
mirrored so losing the distributed store does not reset the active worker's
protection window.
Callers define one or more `ThrottleDimension` values with a controlled
namespace, a subject and a positive limit. They must call `check` before an
expensive verifier, `record` after a failed attempt, and may `reset` the relevant
dimension after successful verification. A blocked decision includes a
`retry_after_seconds` value suitable for an HTTP `Retry-After` header.
The first consumer is Scheduling's anonymous participation password challenge.
Its namespace is `poll-participation-password`; its subject combines tenant,
scheduling request and Poll's non-secret invitation-token fingerprint. Access's
login throttle predates this primitive and should be migrated onto it in a
separate compatibility-preserving slice.

View File

@@ -6,7 +6,7 @@ binding design reference: future implementation should follow these decisions
unless the decision is explicitly revised here and affected screens are updated unless the decision is explicitly revised here and affected screens are updated
to match. to match.
Active tracking issue: `add-ideas/govoplan-core#225`. Active tracking issue: `GovOPlaN/govoplan-core#225`.
## Operating Rule ## Operating Rule
@@ -38,6 +38,18 @@ contestability, responsibility, and traceability at the point of action.
| UX-012 | Automated actions must remain inspectable. The UI must show the system actor, trigger, policy result, observed effects, and failure/manual-intervention state when automation changes administrative state. | Accepted | Workflow, automation, connectors, tasks, audit | | UX-012 | Automated actions must remain inspectable. The UI must show the system actor, trigger, policy result, observed effects, and failure/manual-intervention state when automation changes administrative state. | Accepted | Workflow, automation, connectors, tasks, audit |
| UX-013 | Contestable decisions must expose provenance. Denials, locks, generated outputs, calculated defaults, policy decisions, access decisions, and retention decisions need a reachable source path. | Accepted | Policy, access, templates, workflow, retention, records | | UX-013 | Contestable decisions must expose provenance. Denials, locks, generated outputs, calculated defaults, policy decisions, access decisions, and retention decisions need a reachable source path. | Accepted | Policy, access, templates, workflow, retention, records |
| UX-014 | Retraction, expiry, undo, rollback, and delete controls must state the real limit of the operation. Corrective or future-only actions must not be described as if they undo already observed effects. | Accepted | Postbox, files, records, installer, workflow, payments | | UX-014 | Retraction, expiry, undo, rollback, and delete controls must state the real limit of the operation. Corrective or future-only actions must not be described as if they undo already observed effects. | Accepted | Postbox, files, records, installer, workflow, payments |
| UX-015 | Core owns the platform appearance contract. Modules must use shared CSS tokens and shared controls for theme-aware UI; they must not define independent light/dark palette systems. | Accepted | Core shell and all module WebUIs |
| UX-016 | Full-page create/edit surfaces keep `Discard` and the named `Save …` action in the upper-right page action cluster, with Save at the far right. Their position remains stable through validation and loading states. | Accepted | All full-page create/edit surfaces |
| UX-017 | Table row actions use icon-only controls in a stable rightmost column and intent order: inspect/open, edit, copy, transfer/share/download, retry/restore, destructive action last. Every icon requires a translated accessible name and tooltip. | Accepted | All structured tables |
| UX-018 | A collapsible card containing only one table gives the table the card's full available body, without decorative inner wrappers, duplicate padding, max-widths, or nested scrolling. | Accepted | List, detail, workflow, and configuration surfaces |
| UX-019 | Focused-view precedence is manual session pin, current-task suggestion, user default, role/tenant default, then the full interface. The active source and a full-interface escape remain visible; a suggested view never changes authorization or implies consent. | Accepted | Shell, modules, future workflow composition |
| UX-020 | Centrally exported Core components are mandatory wherever their contract covers the interaction. A custom reusable control, presentation primitive, or module-local substitute requires explicit product-owner authorization, a narrowly specific purpose, and documented rationale and scope; it must not duplicate a central component. | Accepted | Core WebUI and all module WebUIs |
| UX-021 | A collection-wide create action belongs in that collection's page heading and is not duplicated in a persistent side panel. When a side panel is the creation surface, it is present for the creation view only. | Accepted | List-detail, directory, and create surfaces |
| UX-022 | Use central `Card` components for logical sections, `DataGrid` for tabular row collections and their ordered actions, and `ToggleSwitch` for boolean settings. Repeatable people/contact editors use one structured row per person with name, email address, and actions; free-form address parsing is reserved for an explicitly designed bulk-import flow. | Accepted | All WebUI forms and collection editors |
| UX-023 | `FieldLabel` is the standard label/help surface for every field that is not self-explanatory. Any field rendered without it must be recorded in the omission register below, including its accessible-name source and rationale. Users may hide inline help markers through their persisted interface preference; the field label itself remains visible. | Accepted | All Core and module forms |
| UX-024 | Explicit `Discard` actions and dirty in-application navigation use the shared `UnsavedChangesProvider` dialog. A page registers save/discard behavior with `useUnsavedDraftGuard`; its Discard button calls `requestDiscard`, and route changes use `useGuardedNavigate` or `requestNavigation`. | Accepted | All create/edit surfaces |
| UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code |
| UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables |
## Confirmed Implementation Decisions ## Confirmed Implementation Decisions
@@ -138,6 +150,121 @@ adaptive form, not force a linear wizard.
- Wizard shells remain available for assisted setup, first-run guidance, - Wizard shells remain available for assisted setup, first-run guidance,
imports, discovery-heavy flows, and operational preflight workflows. imports, discovery-heavy flows, and operational preflight workflows.
### DUE-008: Platform Theme Contract
Decision: the WebUI shell exposes a small, stable appearance contract based on
shared CSS tokens and persisted user preference selection.
- Core applies `system`, `light`, and `dark` preferences at the document root.
- Core owns shared tokens such as `--bg`, `--bar`, `--panel`, `--surface`,
`--line`, `--line-dark`, `--text`, `--text-strong`, `--muted`, semantic
status colors, radii, shadows, and disabled-control colors.
- Modules must style new UI with these tokens and shared controls. Module-local
CSS may tune layout and spacing, but it must not introduce a separate
appearance system.
- Appearance controls live in user settings first. Tenant defaults and policy
enforcement can be added later without changing the token contract.
- Visual preview in settings is illustrative; it must reflect token families,
not become a second theme implementation.
### DUE-009: Central Component And Exception Contract
Decision: module interfaces are compositions of the components exported by
`@govoplan/core-webui`. When Core already owns the matching interaction, using
the central component is required rather than preferred.
A route or domain-specific page composed from central components is ordinary
module composition. A new reusable UI control, presentation primitive, or
module-local substitute is a custom component. Before one is implemented, the
product owner must explicitly authorize it and the owning decision or issue must
record:
- its single, narrowly defined purpose and intended consumers
- why central components or their composition cannot meet that purpose
- the permitted scope and the boundary it must not grow beyond
- accessibility, reachable states, theme behavior, and test expectations
- whether the component remains domain-owned or is a candidate for Core
Custom components must not duplicate, fork, or cosmetically replace a central
component. An existing local implementation does not grant an exception. If a
central contract later covers the need, migrate to it unless the product owner
explicitly retains the exception.
### DUE-010: Scheduling Request Reference Composition
Decision: Scheduling requests provide a concrete reference application of the
universal placement and component rules.
- The persistent left panel stacks `My scheduling requests` and `Scheduling
requests for me`; it is list context, not a second creation affordance.
- The left panel's `Scheduling requests` header owns one `Add` action. It opens
the shared view/create/edit surface in the right main panel.
- Basic information, Calendar integration, candidate slots, and participants
use the central `Card` component as four logical sections.
- Candidate slots and participants use the central `DataGrid`, including its
standard row-action placement and order.
- Calendar integration uses the central `ToggleSwitch`, with its dependent
controls shown when enabled.
- Each participant is edited as one structured row with name, email address,
and actions. The normal editor does not parse a free-form list of addresses;
that interaction requires a separate, explicitly designed bulk-import flow.
Equivalent list/create/edit surfaces use the same underlying rules. These are
not Scheduling-local component variants.
### DUE-011: Field Help, Discard, And Table Action Contracts
Decision: the central components own these interactions; modules compose them
instead of reproducing their behavior.
- `FormField` and `ToggleSwitch` already render `FieldLabel`. Direct field
compositions use `FieldLabel` explicitly when the meaning or limitation is
not self-explanatory.
- `help` content is contextual guidance, not the accessible name. The persisted
`show_inline_help_hints` user preference hides only the `InlineHelp` marker by
applying `ui-hide-help-hints` at the document root.
- A dirty editor registers once with `useUnsavedDraftGuard`. An explicit
Discard button calls `useUnsavedChanges().requestDiscard(afterResolve)`; SPA
navigation uses `useGuardedNavigate` or `requestNavigation`. Both paths show
the same shared unsaved-changes dialog. A browser tab/window unload remains a
browser-controlled confirmation because browsers do not permit a custom
modal at that boundary.
- `TableActionGroup` receives the table's stable action set. Use `disabled` and
`disabledReason` for row state; omit an action only when that action does not
belong to the table. `minimumSlots` reserves trailing positions for an empty
row. `DataGridEmptyAction` does this for the standard add/move/remove layout.
- A paginated `DataGrid` has exactly one query owner. Client mode receives the
complete logical row set and applies filtering and sorting before slicing a
page. Server mode receives only the loaded page, requires `onQueryChange`,
and the backend applies every emitted filter/sort before pagination while
returning `totalRows` for the filtered result. Server list filters declare
their complete option domain instead of deriving it from the loaded page.
External filter affordances such as summary-count shortcuts update the
grid's `query` contract; the grid header controls and backend query therefore
always display and execute the same filter state.
- Feedback and confirmation use `Dialog`, `ConfirmDialog`, or
`DismissibleAlert`. They never fall back to `window.alert`.
#### FieldLabel Omission Register
Every Core field surface that intentionally does not render `FieldLabel` is
listed here. Module repositories keep an equivalent register in their durable
UI documentation until a central cross-repository audit is available.
| Core scope | Why `FieldLabel` is omitted | Accessible/context label source |
| --- | --- | --- |
| `PasswordField`, `ColorPickerField`, `DateField`, `TimeField`, and `DateTimeField` input internals | These are label-neutral composite primitives and are placed inside `FormField`/`FieldLabel` by the consuming form. Rendering another label inside the primitive would duplicate it. | Enclosing label; a direct consumer must pass an accessible name and record that direct composition here. |
| `ToggleSwitch` native checkbox | The shared component already renders its visible text through `FieldLabel`; the native input must not render a second label. | The enclosing native label and derived `aria-label`. |
| `FileDropZone` hidden file input | The input is an implementation detail of the labelled keyboard-operable drop target. | Drop target text and `inputLabel`/`aria-label`. |
| `AdminSelectionList` and `DataGrid` list-filter checkboxes | Each option is self-explanatory and already enclosed by its visible option label. | Enclosing native option label. |
| `EmailAddressInput` compact Name and Email fields | These two conventional fields are self-explanatory in the compact address popover; richer address guidance belongs to the enclosing field. | Visible native labels; the free-form editor also has a descriptive `aria-label`. |
| `DataGrid` page-size, filter, and inline cell editors | The surrounding column header/filter heading supplies field context; repeating a labelled help marker in every cell would add noise. | Column header, filter heading/native label, or generated cell `aria-label`. |
| Retention-policy value controls | `PolicyRow` owns the field label, help, effective value, and provenance for its control. | The containing `PolicyRow` label/help contract. |
#### Alert Exception Register
No `window.alert` or global `alert` exception is authorized.
## Implementation Sequence ## Implementation Sequence
| Phase | Scope | Output | | Phase | Scope | Output |
@@ -200,6 +327,22 @@ Every new or changed admin/configuration surface should answer:
- Does the action surface show consequence, reversibility, and audit evidence - Does the action surface show consequence, reversibility, and audit evidence
when rights, duties, records, money, communication, external systems, or when rights, duties, records, money, communication, external systems, or
workflow state are affected? workflow state are affected?
- Does the surface use every applicable central Core component? If it contains
a custom component, is the product-owner authorization, narrow purpose,
rationale, scope, and non-duplication evidence recorded?
- Is a collection-wide create action in the collection heading rather than
duplicated in a persistent side panel?
- Are logical sections, tabular collections, boolean settings, and repeatable
people/contact rows composed with `Card`, `DataGrid`, `ToggleSwitch`, and one
structured row per person respectively?
- Does every non-self-explanatory field use `FieldLabel`, and is every omission
recorded with its rationale and accessible-name source?
- Do explicit Discard and dirty navigation use the shared unsaved-changes
registration/dialog rather than a page-local confirmation?
- Does every row retain the table's action set in the same order, disabling
unavailable actions and reserving the same empty-row slots?
- Is feedback rendered with a central dialog/alert component, with no
unauthorized `window.alert` or global `alert` call?
- If automation is involved, can the user see the trigger, system actor, - If automation is involved, can the user see the trigger, system actor,
observed effects, and failure/manual-intervention state? observed effects, and failure/manual-intervention state?
- Are technical details available without being the first thing the user sees? - Are technical details available without being the first thing the user sees?

View File

@@ -0,0 +1,71 @@
# WebUI Loading And Bundle Budgets
The Core WebUI host owns the loading boundary for installed module packages.
Vite discovers configured packages at build time, but emits an asynchronous
loader for each package's `src/module.ts` contribution descriptor. At runtime,
Core imports only descriptors whose backend manifests are enabled and identify
the matching `frontend.package_name`.
The direct descriptor entry is intentional. A package root may re-export pages
for consumers; importing that barrel as module wiring can cause those pages to
be evaluated before navigation. Route pages and substantial panels should use
`React.lazy`, and Core wraps routes in the shared loading/error boundary.
## Enforced Budgets
`webui/bundle-budget.json` contains the production limits:
| Measurement | Raw limit | Gzip limit |
| --- | ---: | ---: |
| Initial JavaScript static import closure | 512 KiB | 160 KiB |
| Largest individual asynchronous JavaScript chunk | 384 KiB | 110 KiB |
`npm run build` writes a Vite manifest, measures the entry and its recursive
static imports, writes `dist/bundle-metrics.json`, and fails when either budget
is exceeded. `npm run test:module-permutations` applies the same gate to every
permutation and records the collected results in
`dist/module-permutation-bundle-metrics.json`. In CI, each result is also added
to the step summary.
Budgets are limits, not targets. A change that approaches a limit should add a
new lazy boundary or remove unnecessary entry code instead of raising the
limit without measurement and review.
## 2026-07-30 Baseline
Measurements use the same full-product source tree and Node 22 runtime. The
post-change build additionally includes the Search module in the default and
full-product sets.
| Initial-load measurement | Before | After | Reduction |
| --- | ---: | ---: | ---: |
| JavaScript assets in initial static closure | 1 | 1 | 0% |
| Raw JavaScript | 1,387,043 B | 453,769 B | 67.3% |
| Gzip level 9 | 364,767 B | 141,725 B | 61.1% |
| Brotli quality 11 | 254,797 B | 106,701 B | 58.1% |
| Parse proxy median | 18.776 ms | 7.750 ms | 58.7% |
| Parse proxy p95 | 21.980 ms | 8.739 ms | 60.2% |
The parse proxy constructs a fresh `node:vm` `SourceTextModule` from the entry
source 30 times with a randomized source marker. It is useful for a controlled
before/after comparison, but is not enforced in CI because absolute timings
vary across runners. Transfer budgets use deterministic raw and gzip byte
counts.
The first budgeted full-product build reported:
- initial JavaScript: 453,769 B raw / 141,725 B gzip;
- largest async chunk: `CampaignWorkspace`, 353,724 B raw / 98,142 B gzip.
## Verification
```bash
cd /mnt/DATA/git/govoplan-core/webui
npm run build
npm run check:bundle-budget
npm run test:module-permutations
```
The build gate also catches accidental eager imports: a page pulled into the
entry closure consumes the initial budget, while an oversized page or module
descriptor consumes the asynchronous chunk budget.

View File

@@ -45,7 +45,7 @@ Remediation applied on 2026-07-09:
- upgraded the campaign ZIP dependency to `pyzipper>=0.4,<1`, resolving to - upgraded the campaign ZIP dependency to `pyzipper>=0.4,<1`, resolving to
`pyzipper==0.4.0` `pyzipper==0.4.0`
- upgraded the local audit environment to `pip==26.1.2` - upgraded the local audit environment to `pip==26.1.2`
- removed the obsolete local `govoplan-module-multimailer` editable install - removed the obsolete local mailer-module editable install
from the audit environment so the audit reflects the split module product from the audit environment so the audit reflects the split module product
Post-remediation result: Post-remediation result:

View File

@@ -8,7 +8,7 @@
"description": "Portal form, case workflow, task creation, mail notification, payment setup, and access roles for a simple application process.", "description": "Portal form, case workflow, task creation, mail notification, payment setup, and access roles for a simple application process.",
"publisher": "ADD ideas", "publisher": "ADD ideas",
"category": "workflow", "category": "workflow",
"artifact_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-configuration-packages.git@application-handling-basic-v0.1.0", "artifact_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-configuration-packages.git@application-handling-basic-v0.1.0",
"artifact_sha256": "<sha256>", "artifact_sha256": "<sha256>",
"required_modules": [ "required_modules": [
{ "module_id": "portal", "version": ">=0.1.0" }, { "module_id": "portal", "version": ">=0.1.0" },

View File

@@ -81,8 +81,588 @@
], ],
"recorded_at": "2026-07-11T01:39:45Z", "recorded_at": "2026-07-11T01:39:45Z",
"release": "0.1.7", "release": "0.1.7",
"track": "release", "squash_policy": "reviewed-manual",
"squash_policy": "reviewed-manual" "track": "release"
},
{
"heads": [
{
"owner": "govoplan-campaign",
"revision": "2c3d4e5f7081"
},
{
"owner": "govoplan-mail",
"revision": "3d4e5f708192"
},
{
"owner": "govoplan-idm",
"revision": "8f9a0b1c2d3e"
},
{
"owner": "govoplan-calendar",
"revision": "9e0f1a2b3c4d"
},
{
"owner": "govoplan-files",
"revision": "a7b8c9d0e1f3"
}
],
"owner_heads": [
{
"owner": "govoplan-access",
"revisions": [
"4a5b6c7d8e9f"
]
},
{
"owner": "govoplan-calendar",
"revisions": [
"9e0f1a2b3c4d"
]
},
{
"owner": "govoplan-campaign",
"revisions": [
"2c3d4e5f7081"
]
},
{
"owner": "govoplan-core",
"revisions": [
"4f2a9c8e7b6d"
]
},
{
"owner": "govoplan-files",
"revisions": [
"a7b8c9d0e1f3"
]
},
{
"owner": "govoplan-identity",
"revisions": [
"5c6d7e8f9a10"
]
},
{
"owner": "govoplan-idm",
"revisions": [
"8f9a0b1c2d3e"
]
},
{
"owner": "govoplan-mail",
"revisions": [
"3d4e5f708192"
]
},
{
"owner": "govoplan-organizations",
"revisions": [
"6d7e8f9a0b1c"
]
}
],
"recorded_at": "2026-07-20T02:45:41Z",
"release": "0.1.8",
"squash_policy": "reviewed-manual",
"track": "release"
},
{
"heads": [
{
"owner": "govoplan-campaign",
"revision": "4d5e6f7a9203"
},
{
"owner": "govoplan-mail",
"revision": "608192abcdef"
},
{
"owner": "govoplan-poll",
"revision": "6e7f8a9b0c1d"
},
{
"owner": "govoplan-notifications",
"revision": "6f7a8b9c0d1e"
},
{
"owner": "govoplan-idm",
"revision": "8f9a0b1c2d3e"
},
{
"owner": "govoplan-files",
"revision": "a7b8c9d0e1f3"
},
{
"owner": "govoplan-calendar",
"revision": "af1b2c3d4e5f"
},
{
"owner": "govoplan-scheduling",
"revision": "c9d4e7f1a2b3"
},
{
"owner": "govoplan-addresses",
"revision": "e1f2a4b5c6d"
}
],
"owner_heads": [
{
"owner": "govoplan-access",
"revisions": [
"4a5b6c7d8e9f"
]
},
{
"owner": "govoplan-addresses",
"revisions": [
"e1f2a4b5c6d"
]
},
{
"owner": "govoplan-calendar",
"revisions": [
"af1b2c3d4e5f"
]
},
{
"owner": "govoplan-campaign",
"revisions": [
"4d5e6f7a9203"
]
},
{
"owner": "govoplan-core",
"revisions": [
"4f2a9c8e7b6d"
]
},
{
"owner": "govoplan-files",
"revisions": [
"a7b8c9d0e1f3"
]
},
{
"owner": "govoplan-identity",
"revisions": [
"5c6d7e8f9a10"
]
},
{
"owner": "govoplan-idm",
"revisions": [
"8f9a0b1c2d3e"
]
},
{
"owner": "govoplan-mail",
"revisions": [
"608192abcdef"
]
},
{
"owner": "govoplan-notifications",
"revisions": [
"6f7a8b9c0d1e"
]
},
{
"owner": "govoplan-organizations",
"revisions": [
"6d7e8f9a0b1c"
]
},
{
"owner": "govoplan-poll",
"revisions": [
"6e7f8a9b0c1d"
]
},
{
"owner": "govoplan-scheduling",
"revisions": [
"c9d4e7f1a2b3"
]
}
],
"recorded_at": "2026-07-22T02:42:27Z",
"release": "0.1.11",
"squash_policy": "reviewed-manual",
"track": "release"
},
{
"heads": [
{
"owner": "govoplan-mail",
"revision": "608192abcdef"
},
{
"owner": "govoplan-poll",
"revision": "6e7f8a9b0c1d"
},
{
"owner": "govoplan-notifications",
"revision": "6f7a8b9c0d1e"
},
{
"owner": "govoplan-idm",
"revision": "8f9a0b1c2d3e"
},
{
"owner": "govoplan-files",
"revision": "a7b8c9d0e1f3"
},
{
"owner": "govoplan-calendar",
"revision": "af1b2c3d4e5f"
},
{
"owner": "govoplan-scheduling",
"revision": "c9d4e7f1a2b3"
},
{
"owner": "govoplan-campaign",
"revision": "d8b3e2c1f4a5"
},
{
"owner": "govoplan-addresses",
"revision": "e1f2a4b5c6d"
}
],
"owner_heads": [
{
"owner": "govoplan-access",
"revisions": [
"4a5b6c7d8e9f"
]
},
{
"owner": "govoplan-addresses",
"revisions": [
"e1f2a4b5c6d"
]
},
{
"owner": "govoplan-calendar",
"revisions": [
"af1b2c3d4e5f"
]
},
{
"owner": "govoplan-campaign",
"revisions": [
"d8b3e2c1f4a5"
]
},
{
"owner": "govoplan-core",
"revisions": [
"4f2a9c8e7b6d"
]
},
{
"owner": "govoplan-files",
"revisions": [
"a7b8c9d0e1f3"
]
},
{
"owner": "govoplan-identity",
"revisions": [
"5c6d7e8f9a10"
]
},
{
"owner": "govoplan-idm",
"revisions": [
"8f9a0b1c2d3e"
]
},
{
"owner": "govoplan-mail",
"revisions": [
"608192abcdef"
]
},
{
"owner": "govoplan-notifications",
"revisions": [
"6f7a8b9c0d1e"
]
},
{
"owner": "govoplan-organizations",
"revisions": [
"6d7e8f9a0b1c"
]
},
{
"owner": "govoplan-poll",
"revisions": [
"6e7f8a9b0c1d"
]
},
{
"owner": "govoplan-scheduling",
"revisions": [
"c9d4e7f1a2b3"
]
}
],
"recorded_at": "2026-07-22T07:06:21Z",
"release": "0.1.12",
"squash_policy": "reviewed-manual",
"track": "release"
},
{
"heads": [
{
"owner": "govoplan-mail",
"revision": "608192abcdef"
},
{
"owner": "govoplan-poll",
"revision": "6e7f8a9b0c1d"
},
{
"owner": "govoplan-notifications",
"revision": "6f7a8b9c0d1e"
},
{
"owner": "govoplan-idm",
"revision": "8f9a0b1c2d3e"
},
{
"owner": "govoplan-files",
"revision": "a7b8c9d0e1f3"
},
{
"owner": "govoplan-calendar",
"revision": "af1b2c3d4e5f"
},
{
"owner": "govoplan-scheduling",
"revision": "c9d4e7f1a2b3"
},
{
"owner": "govoplan-campaign",
"revision": "d8b3e2c1f4a5"
},
{
"owner": "govoplan-addresses",
"revision": "e1f2a4b5c6d"
}
],
"owner_heads": [
{
"owner": "govoplan-access",
"revisions": [
"4a5b6c7d8e9f"
]
},
{
"owner": "govoplan-addresses",
"revisions": [
"e1f2a4b5c6d"
]
},
{
"owner": "govoplan-calendar",
"revisions": [
"af1b2c3d4e5f"
]
},
{
"owner": "govoplan-campaign",
"revisions": [
"d8b3e2c1f4a5"
]
},
{
"owner": "govoplan-core",
"revisions": [
"4f2a9c8e7b6d"
]
},
{
"owner": "govoplan-files",
"revisions": [
"a7b8c9d0e1f3"
]
},
{
"owner": "govoplan-identity",
"revisions": [
"5c6d7e8f9a10"
]
},
{
"owner": "govoplan-idm",
"revisions": [
"8f9a0b1c2d3e"
]
},
{
"owner": "govoplan-mail",
"revisions": [
"608192abcdef"
]
},
{
"owner": "govoplan-notifications",
"revisions": [
"6f7a8b9c0d1e"
]
},
{
"owner": "govoplan-organizations",
"revisions": [
"6d7e8f9a0b1c"
]
},
{
"owner": "govoplan-poll",
"revisions": [
"6e7f8a9b0c1d"
]
},
{
"owner": "govoplan-scheduling",
"revisions": [
"c9d4e7f1a2b3"
]
}
],
"recorded_at": "2026-07-22T08:39:02Z",
"release": "0.1.13",
"squash_policy": "reviewed-manual",
"track": "release"
},
{
"heads": [
{
"owner": "govoplan-mail",
"revision": "608192abcdef"
},
{
"owner": "govoplan-poll",
"revision": "6e7f8a9b0c1d"
},
{
"owner": "govoplan-notifications",
"revision": "6f7a8b9c0d1e"
},
{
"owner": "govoplan-idm",
"revision": "8f9a0b1c2d3e"
},
{
"owner": "govoplan-files",
"revision": "a7b8c9d0e1f3"
},
{
"owner": "govoplan-calendar",
"revision": "af1b2c3d4e5f"
},
{
"owner": "govoplan-scheduling",
"revision": "c9d4e7f1a2b3"
},
{
"owner": "govoplan-campaign",
"revision": "d8b3e2c1f4a5"
},
{
"owner": "govoplan-addresses",
"revision": "e1f2a4b5c6d"
}
],
"owner_heads": [
{
"owner": "govoplan-access",
"revisions": [
"4a5b6c7d8e9f"
]
},
{
"owner": "govoplan-addresses",
"revisions": [
"e1f2a4b5c6d"
]
},
{
"owner": "govoplan-calendar",
"revisions": [
"af1b2c3d4e5f"
]
},
{
"owner": "govoplan-campaign",
"revisions": [
"d8b3e2c1f4a5"
]
},
{
"owner": "govoplan-core",
"revisions": [
"4f2a9c8e7b6d"
]
},
{
"owner": "govoplan-files",
"revisions": [
"a7b8c9d0e1f3"
]
},
{
"owner": "govoplan-identity",
"revisions": [
"5c6d7e8f9a10"
]
},
{
"owner": "govoplan-idm",
"revisions": [
"8f9a0b1c2d3e"
]
},
{
"owner": "govoplan-mail",
"revisions": [
"608192abcdef"
]
},
{
"owner": "govoplan-notifications",
"revisions": [
"6f7a8b9c0d1e"
]
},
{
"owner": "govoplan-organizations",
"revisions": [
"6d7e8f9a0b1c"
]
},
{
"owner": "govoplan-poll",
"revisions": [
"6e7f8a9b0c1d"
]
},
{
"owner": "govoplan-scheduling",
"revisions": [
"c9d4e7f1a2b3"
]
}
],
"recorded_at": "2026-07-22T18:15:01Z",
"release": "0.1.14",
"squash_policy": "reviewed-manual",
"track": "release"
} }
], ],
"version": 1 "version": 1

View File

@@ -12,9 +12,9 @@
"version": "0.1.4", "version": "0.1.4",
"action": "install", "action": "install",
"python_package": "govoplan-files", "python_package": "govoplan-files",
"python_ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.4", "python_ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.4",
"webui_package": "@govoplan/files-webui", "webui_package": "@govoplan/files-webui",
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4", "webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.4",
"migration_safety": "forward_only", "migration_safety": "forward_only",
"migration_notes": "Database rollback requires restoring the pre-update snapshot.", "migration_notes": "Database rollback requires restoring the pre-update snapshot.",
"migration_after": ["access"], "migration_after": ["access"],
@@ -54,14 +54,14 @@
], ],
"artifact_integrity": { "artifact_integrity": {
"python": { "python": {
"ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.4", "ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.4",
"sha256": "<sha256 of the resolved Python artifact>", "sha256": "<sha256 of the resolved Python artifact>",
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-0.1.4.spdx.json", "sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-0.1.4.spdx.json",
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-0.1.4.intoto.jsonl", "provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-0.1.4.intoto.jsonl",
"git_ref": "refs/tags/v0.1.4" "git_ref": "refs/tags/v0.1.4"
}, },
"webui": { "webui": {
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4", "ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.4",
"sha256": "<sha256 of the resolved WebUI package artifact>", "sha256": "<sha256 of the resolved WebUI package artifact>",
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-webui-0.1.4.spdx.json", "sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-webui-0.1.4.spdx.json",
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-webui-0.1.4.intoto.jsonl", "provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-webui-0.1.4.intoto.jsonl",
@@ -78,25 +78,33 @@
"version": "0.1.4", "version": "0.1.4",
"action": "install", "action": "install",
"python_package": "govoplan-mail", "python_package": "govoplan-mail",
"python_ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.4", "python_ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.4",
"webui_package": "@govoplan/mail-webui", "webui_package": "@govoplan/mail-webui",
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4", "webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.4",
"provides_interfaces": [ "provides_interfaces": [
{ {
"name": "mail.campaign_delivery", "name": "mail.campaign_delivery",
"version": "1.4.0" "version": "0.2.0"
}
],
"requires_interfaces": [
{
"name": "campaigns.access",
"version_min": "0.1.0",
"version_max_exclusive": "0.2.0",
"optional": true
} }
], ],
"artifact_integrity": { "artifact_integrity": {
"python": { "python": {
"ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.4", "ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.4",
"sha256": "<sha256 of the resolved Python artifact>", "sha256": "<sha256 of the resolved Python artifact>",
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-0.1.4.spdx.json", "sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-0.1.4.spdx.json",
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-0.1.4.intoto.jsonl", "provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-0.1.4.intoto.jsonl",
"git_ref": "refs/tags/v0.1.4" "git_ref": "refs/tags/v0.1.4"
}, },
"webui": { "webui": {
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4", "ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.4",
"sha256": "<sha256 of the resolved WebUI package artifact>", "sha256": "<sha256 of the resolved WebUI package artifact>",
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-webui-0.1.4.spdx.json", "sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-webui-0.1.4.spdx.json",
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-webui-0.1.4.intoto.jsonl", "provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-webui-0.1.4.intoto.jsonl",
@@ -113,19 +121,19 @@
"version": "0.1.6", "version": "0.1.6",
"action": "install", "action": "install",
"python_package": "govoplan-dashboard", "python_package": "govoplan-dashboard",
"python_ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git@v0.1.6", "python_ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.6",
"webui_package": "@govoplan/dashboard-webui", "webui_package": "@govoplan/dashboard-webui",
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.6", "webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.6",
"artifact_integrity": { "artifact_integrity": {
"python": { "python": {
"ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git@v0.1.6", "ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.6",
"sha256": "<sha256 of the resolved Python artifact>", "sha256": "<sha256 of the resolved Python artifact>",
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-0.1.6.spdx.json", "sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-0.1.6.spdx.json",
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-0.1.6.intoto.jsonl", "provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-0.1.6.intoto.jsonl",
"git_ref": "refs/tags/v0.1.6" "git_ref": "refs/tags/v0.1.6"
}, },
"webui": { "webui": {
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.6", "ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.6",
"sha256": "<sha256 of the resolved WebUI package artifact>", "sha256": "<sha256 of the resolved WebUI package artifact>",
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-webui-0.1.6.spdx.json", "sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-webui-0.1.6.spdx.json",
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-webui-0.1.6.intoto.jsonl", "provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-webui-0.1.6.intoto.jsonl",

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-core" name = "govoplan-core"
version = "0.1.8" version = "0.1.14"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components." description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@@ -27,6 +27,12 @@ where = ["src"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
govoplan_core = ["py.typed"] govoplan_core = ["py.typed"]
[tool.setuptools.data-files]
"govoplan_core_runtime" = ["alembic.ini"]
"govoplan_core_runtime/alembic" = ["alembic/env.py", "alembic/script.py.mako"]
"govoplan_core_runtime/alembic/versions" = ["alembic/versions/*.py"]
"govoplan_core_runtime/alembic/dev_versions" = ["alembic/dev_versions/*.py"]
[project.scripts] [project.scripts]
govoplan-config = "govoplan_core.commands.config:main" govoplan-config = "govoplan_core.commands.config:main"
govoplan-devserver = "govoplan_core.devserver:main" govoplan-devserver = "govoplan_core.devserver:main"
@@ -42,3 +48,7 @@ dev = [
"httpx==0.28.1", "httpx==0.28.1",
"httpx2>=2.5,<3", "httpx2>=2.5,<3",
] ]
[tool.ruff.lint.per-file-ignores]
"tests/test_api_smoke.py" = ["E402"]
"tests/test_module_system.py" = ["E402"]

View File

@@ -39,6 +39,24 @@ class DeltaCollectionResponse(BaseModel):
full: bool = False full: bool = False
class ReferenceOptionResponse(BaseModel):
value: str
label: str
description: str | None = None
kind: str | None = None
availability: Literal["available", "inactive", "unavailable"] = "available"
disabled: bool = False
source_module: str | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
class ReferenceOptionListResponse(BaseModel):
options: list[ReferenceOptionResponse] = Field(default_factory=list)
provider_available: bool = True
next_cursor: str | None = None
has_more: bool = False
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -94,6 +112,28 @@ class UserInfo(BaseModel):
ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences) ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences)
class AuthSessionUserInfo(BaseModel):
id: str
account_id: str
email: str
display_name: str | None = None
tenant_display_name: str | None = None
is_tenant_admin: bool = False
password_reset_required: bool = False
class AuthSessionResponse(BaseModel):
authenticated: bool = True
auth_method: Literal["session", "api_key"] = "session"
user: AuthSessionUserInfo
# Backwards-compatible alias for the active tenant.
tenant: TenantInfo
active_tenant: TenantInfo
session_id: str | None = None
api_key_id: str | None = None
expires_at: datetime | None = None
class ProfileUpdateRequest(BaseModel): class ProfileUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -143,9 +183,43 @@ class PrincipalContextInfo(BaseModel):
display_name: str | None = None display_name: str | None = None
class AuthShellResponse(BaseModel):
user: UserInfo
# Backwards-compatible alias for the active tenant.
tenant: TenantInfo
active_tenant: TenantInfo
tenants: list[TenantMembershipInfo] = Field(default_factory=list)
scopes: list[str]
principal: PrincipalContextInfo | None = None
profile_loaded: bool = False
roles_loaded: bool = False
groups_loaded: bool = False
class AuthProfileResponse(BaseModel):
user: UserInfo
# Backwards-compatible alias for the active tenant.
tenant: TenantInfo
active_tenant: TenantInfo
available_languages: list[LanguageInfo] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list)
default_language: str = "en"
profile_loaded: bool = True
class AuthRolesResponse(BaseModel):
roles: list[RoleInfo] = Field(default_factory=list)
roles_loaded: bool = True
class AuthGroupsResponse(BaseModel):
groups: list[GroupInfo] = Field(default_factory=list)
groups_loaded: bool = True
class LoginResponse(BaseModel): class LoginResponse(BaseModel):
access_token: str access_token: str
token_type: str = "bearer" token_type: str = "bearer" # noqa: S105 - OAuth token type, not a credential.
expires_at: datetime expires_at: datetime
user: UserInfo user: UserInfo
# Backwards-compatible alias for the active tenant. # Backwards-compatible alias for the active tenant.
@@ -159,6 +233,9 @@ class LoginResponse(BaseModel):
available_languages: list[LanguageInfo] = Field(default_factory=list) available_languages: list[LanguageInfo] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list) enabled_language_codes: list[str] = Field(default_factory=list)
default_language: str = "en" default_language: str = "en"
profile_loaded: bool = True
roles_loaded: bool = True
groups_loaded: bool = True
class MeResponse(BaseModel): class MeResponse(BaseModel):
@@ -174,3 +251,6 @@ class MeResponse(BaseModel):
available_languages: list[LanguageInfo] = Field(default_factory=list) available_languages: list[LanguageInfo] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list) enabled_language_codes: list[str] = Field(default_factory=list)
default_language: str = "en" default_language: str = "en"
profile_loaded: bool = True
roles_loaded: bool = True
groups_loaded: bool = True

View File

@@ -16,7 +16,7 @@ from govoplan_core.core.events import (
PlatformEvent, PlatformEvent,
current_event_trace, current_event_trace,
normalize_trace_id, normalize_trace_id,
publish_platform_event, emit_platform_event,
) )
from govoplan_core.core.runtime import get_registry from govoplan_core.core.runtime import get_registry
from govoplan_core.privacy.retention import sanitize_audit_details_for_policy from govoplan_core.privacy.retention import sanitize_audit_details_for_policy
@@ -171,9 +171,13 @@ class _NullAuditRecorder:
) )
def _publish_audit_platform_event(item: AuditRecordRef) -> None: def _publish_audit_platform_event(
session: Session,
item: AuditRecordRef,
) -> None:
trace = _compact_trace(item.details.get("_trace") if isinstance(item.details, Mapping) else None) trace = _compact_trace(item.details.get("_trace") if isinstance(item.details, Mapping) else None)
publish_platform_event( emit_platform_event(
session,
PlatformEvent( PlatformEvent(
type=item.action, type=item.action,
module_id=_module_id_for_audit_action(item.action), module_id=_module_id_for_audit_action(item.action),
@@ -252,7 +256,7 @@ def audit_event(
actor_id=user_id or api_key_id, actor_id=user_id or api_key_id,
payload={"scope": scope, "action": action, "object_type": object_type, "object_id": object_id}, payload={"scope": scope, "action": action, "object_type": object_type, "object_id": object_id},
) )
_publish_audit_platform_event(item) _publish_audit_platform_event(session, item)
if commit: if commit:
session.commit() session.commit()
return item return item

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
"""Core auth dependency facade. """Core auth dependency facade.
Routers depend on this module instead of a concrete access-provider package. Routers depend on this module instead of a concrete access-provider package.
@@ -7,6 +5,8 @@ The active auth module provides the request principal through the platform
capability registry. capability registry.
""" """
from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from fastapi import Depends, Header, HTTPException, Request, status from fastapi import Depends, Header, HTTPException, Request, status
@@ -118,7 +118,7 @@ def _registry_from_request(request: Request) -> PlatformRegistry | None:
def _api_principal_provider_from_request(request: Request) -> ApiPrincipalProvider: def _api_principal_provider_from_request(request: Request) -> ApiPrincipalProvider:
registry = _registry_from_request(request) registry = _registry_from_request(request)
if registry is None or not registry.has_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER): if registry is None or not registry.has_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Auth provider is not available") raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Auth provider is not available")
capability = registry.require_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER) capability = registry.require_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER)
if not isinstance(capability, ApiPrincipalProvider): if not isinstance(capability, ApiPrincipalProvider):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid auth provider capability") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid auth provider capability")

View File

@@ -1,10 +1,36 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone
from celery import Celery from celery import Celery
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_OUTBOX, CalendarOutboxProvider
from govoplan_core.core.dataflows import (
CAPABILITY_DATAFLOW_RUN_WORKER,
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER,
DataflowRunWorker,
DataflowTriggerDispatcher,
)
from govoplan_core.core.events import (
CAPABILITY_PLATFORM_EVENT_OUTBOX,
DurableEventConsumer,
PlatformEvent,
PlatformEventOutbox,
publish_platform_event,
)
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.modules import ModuleContext from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_ROUTING,
PostboxRoutingProvider,
)
from govoplan_core.core.workflows import (
CAPABILITY_WORKFLOW_RUNTIME_WORKER,
WorkflowRuntimeWorker,
)
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import configure_runtime from govoplan_core.core.runtime import configure_runtime
from govoplan_core.settings import settings from govoplan_core.settings import settings
from govoplan_core.db.session import configure_database from govoplan_core.db.session import configure_database
@@ -13,7 +39,7 @@ from govoplan_core.server.registry import available_module_manifests, build_plat
configure_database(settings.database_url) configure_database(settings.database_url)
celery = Celery( celery = Celery(
"multimailer", "govoplan",
broker=settings.redis_url, broker=settings.redis_url,
backend=settings.redis_url, backend=settings.redis_url,
) )
@@ -21,21 +47,73 @@ celery = Celery(
celery.conf.update( celery.conf.update(
task_default_queue="default", task_default_queue="default",
task_routes={ task_routes={
"multimailer.send_email": {"queue": "send_email"}, "govoplan.campaigns.send_email": {"queue": "send_email"},
"multimailer.append_sent": {"queue": "append_sent"}, "govoplan.campaigns.append_sent": {"queue": "append_sent"},
"govoplan.notifications.deliver": {"queue": "notifications"},
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
"govoplan.calendar.dispatch_outbox": {"queue": "calendar"},
"govoplan.dataflow.dispatch_runs": {"queue": "dataflow"},
"govoplan.dataflow.purge_runs": {"queue": "dataflow"},
"govoplan.dataflow.dispatch_triggers": {"queue": "dataflow"},
"govoplan.workflow.reconcile": {"queue": "workflow"},
"govoplan.postbox.dispatch_routes": {"queue": "postbox"},
"govoplan.events.dispatch_outbox": {"queue": "events"},
"govoplan.events.purge_outbox": {"queue": "events"},
}, },
worker_prefetch_multiplier=1, worker_prefetch_multiplier=1,
task_acks_late=True, task_acks_late=True,
task_reject_on_worker_lost=True, task_reject_on_worker_lost=True,
beat_schedule={
"calendar-outbox-every-minute": {
"task": "govoplan.calendar.dispatch_outbox",
"schedule": 60.0,
"args": (None, 100),
},
"dataflow-triggers-every-minute": {
"task": "govoplan.dataflow.dispatch_triggers",
"schedule": 60.0,
"args": (100,),
},
"dataflow-runs-every-five-seconds": {
"task": "govoplan.dataflow.dispatch_runs",
"schedule": 5.0,
"args": (10,),
},
"dataflow-run-retention-daily": {
"task": "govoplan.dataflow.purge_runs",
"schedule": 24 * 60 * 60.0,
"args": (500,),
},
"workflow-reconcile-every-five-seconds": {
"task": "govoplan.workflow.reconcile",
"schedule": 5.0,
"args": (50,),
},
"postbox-routes-every-minute": {
"task": "govoplan.postbox.dispatch_routes",
"schedule": 60.0,
"args": (None, 50),
},
"platform-events-every-ten-seconds": {
"task": "govoplan.events.dispatch_outbox",
"schedule": 10.0,
"args": (100,),
},
"platform-event-retention-daily": {
"task": "govoplan.events.purge_outbox",
"schedule": 24 * 60 * 60.0,
"args": (500,),
},
},
) )
@celery.task(name="multimailer.ping") @celery.task(name="govoplan.ping")
def ping(): def ping():
return "pong" return "pong"
def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider: def _platform_registry() -> PlatformRegistry:
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules) raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules) candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules)
available_modules = available_module_manifests(enabled_modules=candidate_modules, ignore_load_errors=True) available_modules = available_module_manifests(enabled_modules=candidate_modules, ignore_load_errors=True)
@@ -44,13 +122,100 @@ def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider:
context = ModuleContext(registry=registry, settings=settings) context = ModuleContext(registry=registry, settings=settings)
configure_runtime(context) configure_runtime(context)
registry.configure_capability_context(context) registry.configure_capability_context(context)
return registry
def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider:
registry = _platform_registry()
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_DELIVERY_TASKS) capability = registry.require_capability(CAPABILITY_CAMPAIGNS_DELIVERY_TASKS)
if not isinstance(capability, CampaignDeliveryTaskProvider): if not isinstance(capability, CampaignDeliveryTaskProvider):
raise RuntimeError("Campaign delivery task capability is invalid") raise RuntimeError("Campaign delivery task capability is invalid")
return capability return capability
@celery.task(name="multimailer.send_email", bind=True, max_retries=0) def _notification_dispatch() -> NotificationDispatchProvider:
registry = _platform_registry()
capability = registry.require_capability(CAPABILITY_NOTIFICATIONS_DISPATCH)
if not isinstance(capability, NotificationDispatchProvider):
raise RuntimeError("Notification dispatch capability is invalid")
return capability
def _calendar_outbox() -> CalendarOutboxProvider | None:
registry = _platform_registry()
if not registry.has_capability(CAPABILITY_CALENDAR_OUTBOX):
return None
capability = registry.require_capability(CAPABILITY_CALENDAR_OUTBOX)
if not isinstance(capability, CalendarOutboxProvider):
raise RuntimeError("Calendar outbox capability is invalid")
return capability
def _dataflow_trigger_dispatcher(
registry: PlatformRegistry | None = None,
) -> DataflowTriggerDispatcher | None:
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER):
return None
capability = registry.require_capability(
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER
)
if not isinstance(capability, DataflowTriggerDispatcher):
raise RuntimeError("Dataflow trigger dispatcher capability is invalid")
return capability
def _dataflow_run_worker(
registry: PlatformRegistry | None = None,
) -> DataflowRunWorker | None:
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_DATAFLOW_RUN_WORKER):
return None
capability = registry.require_capability(CAPABILITY_DATAFLOW_RUN_WORKER)
if not isinstance(capability, DataflowRunWorker):
raise RuntimeError("Dataflow run worker capability is invalid")
return capability
def _workflow_runtime_worker(
registry: PlatformRegistry | None = None,
) -> WorkflowRuntimeWorker | None:
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_WORKFLOW_RUNTIME_WORKER):
return None
capability = registry.require_capability(
CAPABILITY_WORKFLOW_RUNTIME_WORKER
)
if not isinstance(capability, WorkflowRuntimeWorker):
raise RuntimeError("Workflow runtime worker capability is invalid")
return capability
def _postbox_routing_provider(
registry: PlatformRegistry | None = None,
) -> PostboxRoutingProvider | None:
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_POSTBOX_ROUTING):
return None
capability = registry.require_capability(CAPABILITY_POSTBOX_ROUTING)
if not isinstance(capability, PostboxRoutingProvider):
raise RuntimeError("Postbox routing capability is invalid")
return capability
def _platform_event_outbox(
registry: PlatformRegistry | None = None,
) -> PlatformEventOutbox | None:
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX):
return None
capability = registry.require_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
if not isinstance(capability, PlatformEventOutbox):
raise RuntimeError("Platform event outbox capability is invalid")
return capability
@celery.task(name="govoplan.campaigns.send_email", bind=True, max_retries=0)
def send_email(self, job_id: str): def send_email(self, job_id: str):
"""Send one explicitly queued campaign job. """Send one explicitly queued campaign job.
@@ -65,7 +230,7 @@ def send_email(self, job_id: str):
return dict(_campaign_delivery_tasks().send_campaign_job(session, job_id=job_id, enqueue_imap_task=True)) return dict(_campaign_delivery_tasks().send_campaign_job(session, job_id=job_id, enqueue_imap_task=True))
@celery.task(name="multimailer.append_sent", bind=True, max_retries=None) @celery.task(name="govoplan.campaigns.append_sent", bind=True, max_retries=None)
def append_sent(self, job_id: str): def append_sent(self, job_id: str):
"""Append the exact sent MIME to the configured IMAP Sent folder.""" """Append the exact sent MIME to the configured IMAP Sent folder."""
@@ -78,3 +243,258 @@ def append_sent(self, job_id: str):
if getattr(exc, "temporary", None) is True: if getattr(exc, "temporary", None) is True:
raise self.retry(exc=exc, countdown=300) raise self.retry(exc=exc, countdown=300)
raise raise
@celery.task(name="govoplan.notifications.deliver", bind=True, max_retries=0)
def deliver_notification(self, notification_id: str):
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
result = dict(_notification_dispatch().deliver_notification(session, notification_id=notification_id))
session.commit()
return result
@celery.task(name="govoplan.notifications.deliver_pending", bind=True, max_retries=0)
def deliver_pending_notifications(self, tenant_id: str | None = None, limit: int = 50):
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
result = dict(_notification_dispatch().deliver_pending(session, tenant_id=tenant_id, limit=limit))
session.commit()
return result
@celery.task(name="govoplan.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

View File

@@ -5,7 +5,6 @@ import json
from pathlib import Path from pathlib import Path
import sys import sys
import time import time
from typing import Any
from govoplan_core.core.module_installer import ( from govoplan_core.core.module_installer import (
ModuleInstallerError, ModuleInstallerError,
@@ -27,6 +26,13 @@ from govoplan_core.core.module_installer import (
update_module_installer_request, update_module_installer_request,
update_module_installer_daemon_status, update_module_installer_daemon_status,
) )
from govoplan_core.core.module_installer_notifications import (
build_runtime_notification_registry,
emit_module_installer_notification,
installer_notification_body,
installer_notification_priority,
installer_notification_subject,
)
from govoplan_core.core.module_license import issue_module_license, module_license_diagnostics from govoplan_core.core.module_license import issue_module_license, module_license_diagnostics
from govoplan_core.core.module_package_catalog import sign_module_package_catalog, validate_module_package_catalog from govoplan_core.core.module_package_catalog import sign_module_package_catalog, validate_module_package_catalog
from govoplan_core.core.module_management import ( from govoplan_core.core.module_management import (
@@ -39,7 +45,7 @@ from govoplan_core.server.registry import available_module_manifests
from govoplan_core.settings import settings from govoplan_core.settings import settings
def main() -> int: def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Preflight, apply, or roll back a GovOPlaN module package install plan.") parser = argparse.ArgumentParser(description="Preflight, apply, or roll back a GovOPlaN module package install plan.")
parser.add_argument("--database-url", default=settings.database_url, help="Database URL containing system_settings.") parser.add_argument("--database-url", default=settings.database_url, help="Database URL containing system_settings.")
parser.add_argument("--runtime-dir", type=Path, help="Directory for installer locks and run snapshots.") parser.add_argument("--runtime-dir", type=Path, help="Directory for installer locks and run snapshots.")
@@ -95,202 +101,295 @@ def main() -> int:
parser.add_argument("--license-signing-private-key", type=Path, help="PEM Ed25519 private key for --issue-license.") parser.add_argument("--license-signing-private-key", type=Path, help="PEM Ed25519 private key for --issue-license.")
parser.add_argument("--license-issuer", help="Optional issuer string for --issue-license.") parser.add_argument("--license-issuer", help="Optional issuer string for --issue-license.")
parser.add_argument("--license-notes", help="Optional operator note for --issue-license.") parser.add_argument("--license-notes", help="Optional operator note for --issue-license.")
args = parser.parse_args() return parser
def main() -> int:
args = _build_parser().parse_args()
runtime_dir = args.runtime_dir or default_installer_runtime_dir(args.database_url) runtime_dir = args.runtime_dir or default_installer_runtime_dir(args.database_url)
try: try:
if args.issue_license: return _dispatch_command(args=args, runtime_dir=runtime_dir)
if not args.license_id or not args.license_subject or not args.license_valid_until:
raise ModuleInstallerError("--issue-license requires --license-id, --license-subject, and --license-valid-until.")
if not args.license_signing_key_id or not args.license_signing_private_key:
raise ModuleInstallerError("--issue-license requires --license-signing-key-id and --license-signing-private-key.")
if not [item for item in args.license_feature if item.strip()]:
raise ModuleInstallerError("--issue-license requires at least one --license-feature.")
try:
path = issue_module_license(
path=args.issue_license,
license_id=args.license_id,
subject=args.license_subject,
features=args.license_feature,
valid_from=args.license_valid_from,
valid_until=args.license_valid_until,
signing_key_id=args.license_signing_key_id,
signing_private_key_path=args.license_signing_private_key,
issuer=args.license_issuer,
notes=args.license_notes,
)
except (OSError, ValueError) as exc:
raise ModuleInstallerError(str(exc)) from exc
_print_result(
{
"issued": True,
"path": str(path),
"license_id": args.license_id,
"subject": args.license_subject,
"features": list(dict.fromkeys(item.strip() for item in args.license_feature if item.strip())),
"valid_until": args.license_valid_until,
"key_id": args.license_signing_key_id,
},
output_format=args.format,
)
return 0
if args.validate_license is not None:
path = Path(args.validate_license).expanduser() if args.validate_license else None
result = module_license_diagnostics(
path,
require_trusted=args.require_trusted_license,
trusted_keys=_trusted_keys_from_args(args.license_trusted_key, flag_name="--license-trusted-key"),
required_features=args.license_required_feature,
)
_print_result(result, output_format=args.format)
return 0 if result.get("valid") and not result.get("missing_features") else 1
if args.sign_package_catalog:
if not args.catalog_signing_key_id or not args.catalog_signing_private_key:
raise ModuleInstallerError("--sign-package-catalog requires --catalog-signing-key-id and --catalog-signing-private-key.")
path = sign_module_package_catalog(
path=args.sign_package_catalog,
key_id=args.catalog_signing_key_id,
private_key_path=args.catalog_signing_private_key,
output_path=args.catalog_output,
)
_print_result({"signed": True, "path": str(path), "key_id": args.catalog_signing_key_id}, output_format=args.format)
return 0
if args.validate_package_catalog is not None:
path = Path(args.validate_package_catalog).expanduser() if args.validate_package_catalog else None
result = validate_module_package_catalog(
path,
require_trusted=args.require_signed_catalog,
approved_channels=tuple(args.approved_catalog_channel),
trusted_keys=_trusted_keys_from_args(args.catalog_trusted_key, flag_name="--catalog-trusted-key"),
)
_print_result(result, output_format=args.format)
return 0 if result.get("valid") else 1
if args.daemon_status:
_print_result(module_installer_daemon_status(runtime_dir=runtime_dir), output_format=args.format)
return 0
if args.daemon or args.daemon_once:
return _run_daemon(args=args, runtime_dir=runtime_dir)
if args.list_requests:
_print_result({
"requests": list(list_module_installer_requests(runtime_dir=runtime_dir)),
"daemon": module_installer_daemon_status(runtime_dir=runtime_dir),
}, output_format=args.format)
return 0
if args.show_request:
_print_result(read_module_installer_request(runtime_dir=runtime_dir, request_id=args.show_request), output_format=args.format)
return 0
if args.cancel_request:
_print_result(cancel_module_installer_request(runtime_dir=runtime_dir, request_id=args.cancel_request, cancelled_by="cli"), output_format=args.format)
return 0
if args.retry_request:
_print_result(retry_module_installer_request(runtime_dir=runtime_dir, request_id=args.retry_request, requested_by="cli"), output_format=args.format)
return 0
if args.enqueue_supervised:
request = queue_module_installer_request(
runtime_dir=runtime_dir,
requested_by="cli",
options=_request_options_from_args(args),
)
_print_result(request, output_format=args.format)
return 0
if args.list_runs:
_print_result({
"runs": list(list_module_installer_runs(runtime_dir=runtime_dir)),
"lock": module_installer_lock_status(runtime_dir=runtime_dir),
}, output_format=args.format)
return 0
if args.show_run:
_print_result(read_module_installer_run(runtime_dir=runtime_dir, run_id=args.show_run), output_format=args.format)
return 0
if args.lock_status:
_print_result(module_installer_lock_status(runtime_dir=runtime_dir), output_format=args.format)
return 0
if args.rollback:
result = rollback_module_install_run(
run_id=args.rollback,
runtime_dir=runtime_dir,
npm_bin=args.npm_bin,
build_webui=args.build_webui,
database_restore_command=args.database_restore_command,
database_url=str(args.database_url),
)
_print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1
configure_database(str(args.database_url))
available = available_module_manifests(ignore_load_errors=True)
with get_database().session() as session:
configured = configured_enabled_modules(settings.enabled_modules)
desired = saved_desired_enabled_modules(session, configured)
plan = saved_module_install_plan(session)
if args.supervise:
result = supervise_module_install_plan(
session=session,
plan=plan,
available=available,
current_enabled=desired,
desired_enabled=desired,
database_url=str(args.database_url),
runtime_dir=runtime_dir,
webui_root=args.webui_root,
npm_bin=args.npm_bin,
build_webui=args.build_webui,
migrate_database=args.migrate,
database_backup_command=args.database_backup_command,
database_restore_command=args.database_restore_command,
database_restore_check_command=args.database_restore_check_command,
activate_installed_modules=not args.no_activate_installed_modules,
remove_uninstalled_modules_from_desired=not args.keep_uninstalled_modules_in_desired,
restart_command=args.restart_command,
health_url=args.health_url,
health_timeout_seconds=args.health_timeout_seconds,
health_interval_seconds=args.health_interval_seconds,
)
_print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1
if args.apply or args.dry_run:
result = run_module_install_plan(
session=session,
plan=plan,
available=available,
current_enabled=desired,
desired_enabled=desired,
database_url=str(args.database_url),
runtime_dir=runtime_dir,
webui_root=args.webui_root,
npm_bin=args.npm_bin,
build_webui=args.build_webui,
migrate_database=args.migrate,
database_backup_command=args.database_backup_command,
database_restore_command=args.database_restore_command,
database_restore_check_command=args.database_restore_check_command,
activate_installed_modules=not args.no_activate_installed_modules,
remove_uninstalled_modules_from_desired=not args.keep_uninstalled_modules_in_desired,
dry_run=args.dry_run,
)
_print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1
from govoplan_core.core.maintenance import saved_maintenance_mode
maintenance = saved_maintenance_mode(session)
preflight = module_install_preflight(
plan=plan,
available=available,
current_enabled=desired,
desired_enabled=desired,
maintenance_mode=maintenance.enabled,
session=session,
webui_root=args.webui_root,
runtime_dir=runtime_dir,
)
_print_preflight(preflight.as_dict(), output_format=args.format)
return 0 if preflight.allowed else 1
except ModuleInstallerError as exc: except ModuleInstallerError as exc:
print(f"error: {exc}", file=sys.stderr) print(f"error: {exc}", file=sys.stderr)
return 1 return 1
def _dispatch_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
for handler in (
_handle_license_command,
_handle_catalog_command,
_handle_runtime_command,
_handle_queue_command,
_handle_run_history_command,
):
result = handler(args=args, runtime_dir=runtime_dir)
if result is not None:
return result
return _handle_install_command(args=args, runtime_dir=runtime_dir)
def _handle_license_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
del runtime_dir
if args.issue_license:
return _issue_license_command(args)
if args.validate_license is not None:
return _validate_license_command(args)
return None
def _issue_license_command(args: argparse.Namespace) -> int:
if not args.license_id or not args.license_subject or not args.license_valid_until:
raise ModuleInstallerError("--issue-license requires --license-id, --license-subject, and --license-valid-until.")
if not args.license_signing_key_id or not args.license_signing_private_key:
raise ModuleInstallerError("--issue-license requires --license-signing-key-id and --license-signing-private-key.")
if not [item for item in args.license_feature if item.strip()]:
raise ModuleInstallerError("--issue-license requires at least one --license-feature.")
try:
path = issue_module_license(
path=args.issue_license,
license_id=args.license_id,
subject=args.license_subject,
features=args.license_feature,
valid_from=args.license_valid_from,
valid_until=args.license_valid_until,
signing_key_id=args.license_signing_key_id,
signing_private_key_path=args.license_signing_private_key,
issuer=args.license_issuer,
notes=args.license_notes,
)
except (OSError, ValueError) as exc:
raise ModuleInstallerError(str(exc)) from exc
_print_result(
{
"issued": True,
"path": str(path),
"license_id": args.license_id,
"subject": args.license_subject,
"features": list(dict.fromkeys(item.strip() for item in args.license_feature if item.strip())),
"valid_until": args.license_valid_until,
"key_id": args.license_signing_key_id,
},
output_format=args.format,
)
return 0
def _validate_license_command(args: argparse.Namespace) -> int:
path = Path(args.validate_license).expanduser() if args.validate_license else None
result = module_license_diagnostics(
path,
require_trusted=args.require_trusted_license,
trusted_keys=_trusted_keys_from_args(args.license_trusted_key, flag_name="--license-trusted-key"),
required_features=args.license_required_feature,
)
_print_result(result, output_format=args.format)
return 0 if result.get("valid") and not result.get("missing_features") else 1
def _handle_catalog_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
del runtime_dir
if args.sign_package_catalog:
return _sign_package_catalog_command(args)
if args.validate_package_catalog is not None:
return _validate_package_catalog_command(args)
return None
def _sign_package_catalog_command(args: argparse.Namespace) -> int:
if not args.catalog_signing_key_id or not args.catalog_signing_private_key:
raise ModuleInstallerError("--sign-package-catalog requires --catalog-signing-key-id and --catalog-signing-private-key.")
path = sign_module_package_catalog(
path=args.sign_package_catalog,
key_id=args.catalog_signing_key_id,
private_key_path=args.catalog_signing_private_key,
output_path=args.catalog_output,
)
_print_result({"signed": True, "path": str(path), "key_id": args.catalog_signing_key_id}, output_format=args.format)
return 0
def _validate_package_catalog_command(args: argparse.Namespace) -> int:
path = Path(args.validate_package_catalog).expanduser() if args.validate_package_catalog else None
result = validate_module_package_catalog(
path,
require_trusted=args.require_signed_catalog,
approved_channels=tuple(args.approved_catalog_channel),
trusted_keys=_trusted_keys_from_args(args.catalog_trusted_key, flag_name="--catalog-trusted-key"),
)
_print_result(result, output_format=args.format)
return 0 if result.get("valid") else 1
def _handle_runtime_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
if args.daemon_status:
_print_result(module_installer_daemon_status(runtime_dir=runtime_dir), output_format=args.format)
return 0
if args.daemon or args.daemon_once:
return _run_daemon(args=args, runtime_dir=runtime_dir)
return None
def _handle_queue_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
if args.list_requests:
_print_result({
"requests": list(list_module_installer_requests(runtime_dir=runtime_dir)),
"daemon": module_installer_daemon_status(runtime_dir=runtime_dir),
}, output_format=args.format)
return 0
if args.show_request:
_print_result(read_module_installer_request(runtime_dir=runtime_dir, request_id=args.show_request), output_format=args.format)
return 0
if args.cancel_request:
_print_result(cancel_module_installer_request(runtime_dir=runtime_dir, request_id=args.cancel_request, cancelled_by="cli"), output_format=args.format)
return 0
if args.retry_request:
_print_result(retry_module_installer_request(runtime_dir=runtime_dir, request_id=args.retry_request, requested_by="cli"), output_format=args.format)
return 0
if args.enqueue_supervised:
request = queue_module_installer_request(runtime_dir=runtime_dir, requested_by="cli", options=_request_options_from_args(args))
_print_result(request, output_format=args.format)
return 0
return None
def _handle_run_history_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
if args.list_runs:
_print_result({
"runs": list(list_module_installer_runs(runtime_dir=runtime_dir)),
"lock": module_installer_lock_status(runtime_dir=runtime_dir),
}, output_format=args.format)
return 0
if args.show_run:
_print_result(read_module_installer_run(runtime_dir=runtime_dir, run_id=args.show_run), output_format=args.format)
return 0
if args.lock_status:
_print_result(module_installer_lock_status(runtime_dir=runtime_dir), output_format=args.format)
return 0
if args.rollback:
return _rollback_command(args=args, runtime_dir=runtime_dir)
return None
def _rollback_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
result = rollback_module_install_run(
run_id=args.rollback,
runtime_dir=runtime_dir,
npm_bin=args.npm_bin,
build_webui=args.build_webui,
database_restore_command=args.database_restore_command,
database_url=str(args.database_url),
)
_print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1
def _handle_install_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
configure_database(str(args.database_url))
available = available_module_manifests(ignore_load_errors=True)
with get_database().session() as session:
configured = configured_enabled_modules(settings.enabled_modules)
desired = saved_desired_enabled_modules(session, configured)
plan = saved_module_install_plan(session)
if args.supervise:
return _supervise_install_command(args=args, runtime_dir=runtime_dir, session=session, available=available, desired=desired, plan=plan)
if args.apply or args.dry_run:
return _apply_install_command(args=args, runtime_dir=runtime_dir, session=session, available=available, desired=desired, plan=plan)
return _preflight_install_command(args=args, runtime_dir=runtime_dir, session=session, available=available, desired=desired, plan=plan)
def _supervise_install_command(
*,
args: argparse.Namespace,
runtime_dir: Path,
session: object,
available: object,
desired: object,
plan: object,
) -> int:
result = supervise_module_install_plan(
session=session,
plan=plan,
available=available,
current_enabled=desired,
desired_enabled=desired,
database_url=str(args.database_url),
runtime_dir=runtime_dir,
webui_root=args.webui_root,
npm_bin=args.npm_bin,
build_webui=args.build_webui,
migrate_database=args.migrate,
database_backup_command=args.database_backup_command,
database_restore_command=args.database_restore_command,
database_restore_check_command=args.database_restore_check_command,
activate_installed_modules=not args.no_activate_installed_modules,
remove_uninstalled_modules_from_desired=not args.keep_uninstalled_modules_in_desired,
restart_command=args.restart_command,
health_url=args.health_url,
health_timeout_seconds=args.health_timeout_seconds,
health_interval_seconds=args.health_interval_seconds,
)
_print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1
def _apply_install_command(
*,
args: argparse.Namespace,
runtime_dir: Path,
session: object,
available: object,
desired: object,
plan: object,
) -> int:
result = run_module_install_plan(
session=session,
plan=plan,
available=available,
current_enabled=desired,
desired_enabled=desired,
database_url=str(args.database_url),
runtime_dir=runtime_dir,
webui_root=args.webui_root,
npm_bin=args.npm_bin,
build_webui=args.build_webui,
migrate_database=args.migrate,
database_backup_command=args.database_backup_command,
database_restore_command=args.database_restore_command,
database_restore_check_command=args.database_restore_check_command,
activate_installed_modules=not args.no_activate_installed_modules,
remove_uninstalled_modules_from_desired=not args.keep_uninstalled_modules_in_desired,
dry_run=args.dry_run,
)
_print_result(result.as_dict(), output_format=args.format)
return 0 if result.return_code == 0 else 1
def _preflight_install_command(
*,
args: argparse.Namespace,
runtime_dir: Path,
session: object,
available: object,
desired: object,
plan: object,
) -> int:
from govoplan_core.core.maintenance import saved_maintenance_mode
maintenance = saved_maintenance_mode(session)
preflight = module_install_preflight(
plan=plan,
available=available,
current_enabled=desired,
desired_enabled=desired,
maintenance_mode=maintenance.enabled,
session=session,
webui_root=args.webui_root,
runtime_dir=runtime_dir,
)
_print_preflight(preflight.as_dict(), output_format=args.format)
return 0 if preflight.allowed else 1
def _default_webui_root() -> Path: def _default_webui_root() -> Path:
return Path(__file__).resolve().parents[3] / "webui" return Path(__file__).resolve().parents[3] / "webui"
@@ -355,6 +454,7 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
request_id = str(request["request_id"]) request_id = str(request["request_id"])
options = request.get("options") if isinstance(request.get("options"), dict) else {} options = request.get("options") if isinstance(request.get("options"), dict) else {}
configure_database(str(args.database_url)) configure_database(str(args.database_url))
_emit_installer_daemon_notification(request, event_kind="module_installer.request.running")
try: try:
available = available_module_manifests(ignore_load_errors=True) available = available_module_manifests(ignore_load_errors=True)
with get_database().session() as session: with get_database().session() as session:
@@ -384,7 +484,7 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
health_interval_seconds=_float_option(options, "health_interval_seconds", args.health_interval_seconds), health_interval_seconds=_float_option(options, "health_interval_seconds", args.health_interval_seconds),
request_context=_request_context(request), request_context=_request_context(request),
) )
update_module_installer_request( updated_request = update_module_installer_request(
runtime_dir=runtime_dir, runtime_dir=runtime_dir,
request_id=request_id, request_id=request_id,
patch={ patch={
@@ -393,8 +493,12 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
"result": result.as_dict(), "result": result.as_dict(),
}, },
) )
_emit_installer_daemon_notification(
updated_request,
event_kind="module_installer.request.completed" if result.return_code == 0 else "module_installer.request.failed",
)
except Exception as exc: except Exception as exc:
update_module_installer_request( updated_request = update_module_installer_request(
runtime_dir=runtime_dir, runtime_dir=runtime_dir,
request_id=request_id, request_id=request_id,
patch={ patch={
@@ -403,6 +507,34 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
"error": str(exc), "error": str(exc),
}, },
) )
_emit_installer_daemon_notification(updated_request, event_kind="module_installer.request.failed")
def _emit_installer_daemon_notification(request: dict[str, object], *, event_kind: str) -> None:
tenant_id = str(request.get("tenant_id") or "")
if not tenant_id:
return
registry = build_runtime_notification_registry(settings)
if registry is None:
return
status_value = str(request.get("status") or event_kind.rsplit(".", 1)[-1])
try:
with get_database().SessionLocal() as session:
emitted = emit_module_installer_notification(
session=session,
registry=registry,
tenant_id=tenant_id,
request=request,
event_kind=event_kind,
subject=installer_notification_subject(event_kind, request),
body_text=installer_notification_body(event_kind, request),
recipient_id=str(request.get("requested_by") or "") or None,
priority=installer_notification_priority(status_value),
)
if emitted:
session.commit()
except Exception: # noqa: BLE001 - notification bridge must not block installer work.
return
def _request_options_from_args(args: argparse.Namespace) -> dict[str, object]: def _request_options_from_args(args: argparse.Namespace) -> dict[str, object]:
@@ -427,6 +559,7 @@ def _request_context(request: dict[str, object]) -> dict[str, object]:
context: dict[str, object] = { context: dict[str, object] = {
"request_id": request.get("request_id"), "request_id": request.get("request_id"),
"requested_by": request.get("requested_by"), "requested_by": request.get("requested_by"),
"tenant_id": request.get("tenant_id"),
} }
if request.get("retry_of"): if request.get("retry_of"):
context["retry_of"] = request["retry_of"] context["retry_of"] = request["retry_of"]

View File

@@ -28,6 +28,9 @@ CAPABILITY_AUDIT_RECORDER = "audit.recorder"
CAPABILITY_AUDIT_RETENTION = "audit.retention" CAPABILITY_AUDIT_RETENTION = "audit.retention"
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER = "auth.apiPrincipalProvider" CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER = "auth.apiPrincipalProvider"
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER = (
"auth.automationPrincipalProvider"
)
CAPABILITY_AUTH_PRINCIPAL_RESOLVER = "auth.principalResolver" CAPABILITY_AUTH_PRINCIPAL_RESOLVER = "auth.principalResolver"
CAPABILITY_AUTH_PERMISSION_EVALUATOR = "auth.permissionEvaluator" CAPABILITY_AUTH_PERMISSION_EVALUATOR = "auth.permissionEvaluator"
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER = "auth.tenantContextSwitcher" CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER = "auth.tenantContextSwitcher"
@@ -48,6 +51,7 @@ ACCESS_CAPABILITY_NAMES = frozenset(
CAPABILITY_AUDIT_RECORDER, CAPABILITY_AUDIT_RECORDER,
CAPABILITY_AUDIT_RETENTION, CAPABILITY_AUDIT_RETENTION,
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER, CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER, CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
@@ -57,12 +61,19 @@ ACCESS_CAPABILITY_NAMES = frozenset(
AUTH_CAPABILITY_NAMES = frozenset( AUTH_CAPABILITY_NAMES = frozenset(
{ {
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER, CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER, CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
} }
) )
DEFAULT_CAPABILITY_PROVIDERS: Mapping[str, str] = {
CAPABILITY_AUTH_PRINCIPAL_RESOLVER: ACCESS_MODULE_ID,
CAPABILITY_AUTH_PERMISSION_EVALUATOR: ACCESS_MODULE_ID,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER: ACCESS_MODULE_ID,
}
AuthMethod = Literal["session", "api_key", "service_account"] AuthMethod = Literal["session", "api_key", "service_account"]
AccessSubjectKind = Literal[ AccessSubjectKind = Literal[
"identity", "identity",

View 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",
]

View 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

View File

@@ -6,7 +6,7 @@ from typing import Any, Iterable, Sequence
from sqlalchemy import BigInteger, DateTime, Index, Integer, JSON, String, UniqueConstraint, func from sqlalchemy import BigInteger, DateTime, Index, Integer, JSON, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, Session, mapped_column from sqlalchemy.orm import Mapped, Session, mapped_column
from govoplan_core.core.events import EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, publish_platform_event from govoplan_core.core.events import EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, emit_platform_event
from govoplan_core.db.base import Base, utcnow from govoplan_core.db.base import Base, utcnow
WATERMARK_PREFIX = "seq:" WATERMARK_PREFIX = "seq:"
@@ -96,13 +96,14 @@ def record_change(
payload=payload or {}, payload=payload or {},
) )
session.add(entry) session.add(entry)
_publish_change_event(entry) _publish_change_event(session, entry)
return entry return entry
def _publish_change_event(entry: ChangeSequenceEntry) -> None: def _publish_change_event(session: Session, entry: ChangeSequenceEntry) -> None:
event_type = _change_event_type(entry.module_id, entry.resource_type, entry.operation) event_type = _change_event_type(entry.module_id, entry.resource_type, entry.operation)
publish_platform_event( emit_platform_event(
session,
PlatformEvent( PlatformEvent(
type=event_type, type=event_type,
module_id=entry.module_id, module_id=entry.module_id,

View File

@@ -8,8 +8,6 @@ from pathlib import Path
import json import json
import os import os
from typing import Any, Literal, Protocol, runtime_checkable from typing import Any, Literal, Protocol, runtime_checkable
import urllib.error
import urllib.request
from govoplan_core.core.module_package_catalog import ( from govoplan_core.core.module_package_catalog import (
_canonical_catalog_bytes, _canonical_catalog_bytes,
@@ -23,6 +21,7 @@ from govoplan_core.core.module_package_catalog import (
_load_private_key, _load_private_key,
_parse_trusted_keys, _parse_trusted_keys,
) )
from govoplan_core.security.http_fetch import fetch_http_text
CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider" CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider"
@@ -31,6 +30,20 @@ DiagnosticSeverity = Literal["blocker", "warning", "info"]
PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"] PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"]
@dataclass(frozen=True, slots=True)
class _ConfigurationCatalogValidationState:
packages: tuple[dict[str, object], ...]
channel: str | None
sequence: int | None
generated_at: str | None
not_before: str | None
expires_at: str | None
signature_state: dict[str, object]
freshness: dict[str, object]
replay: dict[str, object]
read_state: dict[str, object]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ConfigurationModuleRequirement: class ConfigurationModuleRequirement:
module_id: str module_id: str
@@ -461,55 +474,148 @@ def validate_configuration_package_catalog(
configured = source is not None and _catalog_source_exists(source) configured = source is not None and _catalog_source_exists(source)
if source is not None and not _catalog_source_exists(source): if source is not None and not _catalog_source_exists(source):
return _validation_result(source, configured=True, packages=(), error=f"Configuration package catalog does not exist: {source}") return _validation_result(source, configured=True, packages=(), error=f"Configuration package catalog does not exist: {source}")
read_state = {"cache_used": False, "cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() else None} read_state = _configuration_catalog_default_read_state()
try: try:
payload, read_state = _read_catalog_payload_with_metadata(source)
packages = _normalize_catalog_packages(payload)
channel = _catalog_channel(payload)
sequence = _catalog_sequence(payload)
generated_at = _catalog_optional_text(payload, "generated_at")
not_before = _catalog_optional_text(payload, "not_before")
expires_at = _catalog_optional_text(payload, "expires_at")
effective_require_trusted = _configured_require_signature() if require_trusted is None else require_trusted effective_require_trusted = _configured_require_signature() if require_trusted is None else require_trusted
effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels
effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys() effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys()
signature_state = _catalog_signature_state(payload, trusted_keys=effective_trusted_keys) state = _configuration_catalog_validation_state(source, trusted_keys=effective_trusted_keys)
freshness = _catalog_freshness_state(payload) read_state = state.read_state
replay = _catalog_replay_state(channel=channel, sequence=sequence)
except Exception as exc: except Exception as exc:
return _validation_result(source, configured=configured, read_state=read_state, packages=(), error=str(exc)) return _validation_result(source, configured=configured, read_state=read_state, packages=(), error=str(exc))
if signature_state.get("fatal"): policy_error = _configuration_catalog_policy_error(
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(signature_state["error"])) source,
if effective_approved_channels and channel not in effective_approved_channels: configured=configured,
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=f"Configuration package catalog channel {channel!r} is not approved. Approved channels: {', '.join(effective_approved_channels)}.") state=state,
if effective_require_trusted and not signature_state["trusted"]: require_trusted=effective_require_trusted,
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(signature_state["error"] or "Configuration package catalog must be signed by a trusted key.")) approved_channels=effective_approved_channels,
if not freshness["valid"]: )
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(freshness["error"])) if policy_error is not None:
if not replay["valid"]: return policy_error
return _validation_result(source, configured=configured, read_state=read_state, packages=(), channel=channel, sequence=sequence, generated_at=generated_at, not_before=not_before, expires_at=expires_at, signature_state=signature_state, error=str(replay["error"]))
warnings = [str(item) for item in freshness.get("warnings", ()) if item]
warnings.extend(str(item) for item in replay.get("warnings", ()) if item)
if not signature_state["signed"]:
warnings.append("Configuration package catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
elif not signature_state["trusted"]:
warnings.append(str(signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
return _validation_result( return _validation_result(
source, source,
valid=True, valid=True,
configured=configured, configured=configured,
read_state=read_state, read_state=state.read_state,
packages=packages, packages=state.packages,
channel=state.channel,
sequence=state.sequence,
generated_at=state.generated_at,
not_before=state.not_before,
expires_at=state.expires_at,
signature_state=state.signature_state,
warnings=_configuration_catalog_warnings(state),
)
def _configuration_catalog_validation_state(
source: Path | str | None,
*,
trusted_keys: dict[str, str],
) -> _ConfigurationCatalogValidationState:
payload, read_state = _read_catalog_payload_with_metadata(source)
channel = _catalog_channel(payload)
sequence = _catalog_sequence(payload)
return _ConfigurationCatalogValidationState(
packages=_normalize_catalog_packages(payload),
channel=channel, channel=channel,
sequence=sequence, sequence=sequence,
generated_at=generated_at, generated_at=_catalog_optional_text(payload, "generated_at"),
not_before=not_before, not_before=_catalog_optional_text(payload, "not_before"),
expires_at=expires_at, expires_at=_catalog_optional_text(payload, "expires_at"),
signature_state=signature_state, signature_state=_catalog_signature_state(payload, trusted_keys=trusted_keys),
warnings=warnings, freshness=_catalog_freshness_state(payload),
replay=_catalog_replay_state(channel=channel, sequence=sequence),
read_state=read_state,
) )
def _configuration_catalog_policy_error(
source: Path | str | None,
*,
configured: bool,
state: _ConfigurationCatalogValidationState,
require_trusted: bool,
approved_channels: tuple[str, ...],
) -> dict[str, object] | None:
if state.signature_state.get("fatal"):
return _configuration_catalog_invalid_result(
source,
configured=configured,
state=state,
error=str(state.signature_state["error"]),
)
if approved_channels and state.channel not in approved_channels:
return _configuration_catalog_invalid_result(
source,
configured=configured,
state=state,
error=(
f"Configuration package catalog channel {state.channel!r} is not approved. "
f"Approved channels: {', '.join(approved_channels)}."
),
)
if require_trusted and not state.signature_state["trusted"]:
return _configuration_catalog_invalid_result(
source,
configured=configured,
state=state,
error=str(state.signature_state["error"] or "Configuration package catalog must be signed by a trusted key."),
)
if not state.freshness["valid"]:
return _configuration_catalog_invalid_result(
source,
configured=configured,
state=state,
error=str(state.freshness["error"]),
)
if not state.replay["valid"]:
return _configuration_catalog_invalid_result(
source,
configured=configured,
state=state,
error=str(state.replay["error"]),
)
return None
def _configuration_catalog_invalid_result(
source: Path | str | None,
*,
configured: bool,
state: _ConfigurationCatalogValidationState,
error: str,
) -> dict[str, object]:
return _validation_result(
source,
configured=configured,
read_state=state.read_state,
packages=(),
channel=state.channel,
sequence=state.sequence,
generated_at=state.generated_at,
not_before=state.not_before,
expires_at=state.expires_at,
signature_state=state.signature_state,
error=error,
)
def _configuration_catalog_warnings(state: _ConfigurationCatalogValidationState) -> list[str]:
warnings = [str(item) for item in state.freshness.get("warnings", ()) if item]
warnings.extend(str(item) for item in state.replay.get("warnings", ()) if item)
if not state.signature_state["signed"]:
warnings.append("Configuration package catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
elif not state.signature_state["trusted"]:
warnings.append(str(state.signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
return warnings
def _configuration_catalog_default_read_state() -> dict[str, object]:
cache_path = _configured_catalog_cache_path()
return {"cache_used": False, "cache_path": str(cache_path) if cache_path else None}
def sign_configuration_package_catalog(*, path: Path, key_id: str, private_key_path: Path, output_path: Path | None = None) -> Path: def sign_configuration_package_catalog(*, path: Path, key_id: str, private_key_path: Path, output_path: Path | None = None) -> Path:
payload = _read_catalog_payload(path) payload = _read_catalog_payload(path)
if not isinstance(payload, dict): if not isinstance(payload, dict):
@@ -686,17 +792,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
def _read_trusted_keys_url(url: str) -> str: def _read_trusted_keys_url(url: str) -> str:
if not _is_http_url(url):
raise ValueError("Trusted configuration catalog key URL must use http:// or https://.")
cache_path = _configured_trusted_keys_cache_path() cache_path = _configured_trusted_keys_cache_path()
try: try:
with urllib.request.urlopen(url, timeout=15) as response: body = fetch_http_text(url, timeout=15, label="Trusted configuration catalog key URL")
body = response.read().decode("utf-8")
if cache_path is not None: if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8") cache_path.write_text(body, encoding="utf-8")
return body return body
except (OSError, urllib.error.URLError): except OSError:
if cache_path is not None and cache_path.exists(): if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8") return cache_path.read_text(encoding="utf-8")
raise raise
@@ -714,13 +817,12 @@ def _read_catalog_payload_with_metadata(source: Path | str | None) -> tuple[obje
return {"packages": []}, metadata return {"packages": []}, metadata
if isinstance(source, str) and _is_http_url(source): if isinstance(source, str) and _is_http_url(source):
try: try:
with urllib.request.urlopen(source, timeout=15) as response: body = fetch_http_text(source, timeout=15, label="Configuration package catalog URL")
body = response.read().decode("utf-8")
if cache_path is not None: if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8") cache_path.write_text(body, encoding="utf-8")
return json.loads(body), metadata return json.loads(body), metadata
except (OSError, urllib.error.URLError): except OSError:
if cache_path is not None and cache_path.exists(): if cache_path is not None and cache_path.exists():
metadata["cache_used"] = True metadata["cache_used"] = True
return json.loads(cache_path.read_text(encoding="utf-8")), metadata return json.loads(cache_path.read_text(encoding="utf-8")), metadata

View File

@@ -1,8 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from collections.abc import Mapping from typing import Literal
from typing import Any, Literal
from govoplan_core.security.permissions import scopes_grant from govoplan_core.security.permissions import scopes_grant
from govoplan_core.security.redaction import contains_plain_secret from govoplan_core.security.redaction import contains_plain_secret
@@ -22,7 +21,7 @@ class ConfigurationFieldSafety:
storage: str storage: str
ui_managed: bool ui_managed: bool
risk: ConfigurationRisk risk: ConfigurationRisk
secret_handling: SecretHandling = "none" secret_handling: SecretHandling = "none" # noqa: S105 - policy vocabulary.
required_scopes: tuple[str, ...] = () required_scopes: tuple[str, ...] = ()
dry_run_required: bool = False dry_run_required: bool = False
validation_required: bool = True validation_required: bool = True
@@ -69,7 +68,7 @@ class ConfigurationChangeSafetyPlan:
maintenance_required: bool = False maintenance_required: bool = False
maintenance_satisfied: bool = False maintenance_satisfied: bool = False
rollback_history_required: bool = False rollback_history_required: bool = False
secret_handling: SecretHandling = "none" secret_handling: SecretHandling = "none" # noqa: S105 - policy vocabulary.
audit_event: str | None = None audit_event: str | None = None
policy_explanation: str | None = None policy_explanation: str | None = None
blockers: tuple[str, ...] = () blockers: tuple[str, ...] = ()
@@ -97,6 +96,16 @@ class ConfigurationChangeSafetyPlan:
} }
@dataclass(frozen=True, slots=True)
class _ConfigurationChangeSafetyState:
field: ConfigurationFieldSafety
missing_scopes: tuple[str, ...]
blockers: tuple[str, ...]
warnings: tuple[str, ...]
approval_required: bool
approval_satisfied: bool
_CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = ( _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
ConfigurationFieldSafety( ConfigurationFieldSafety(
key="module_management.desired_enabled", key="module_management.desired_enabled",
@@ -230,7 +239,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
storage="module_settings", storage="module_settings",
ui_managed=True, ui_managed=True,
risk="high", risk="high",
secret_handling="reference_only", secret_handling="reference_only", # noqa: S106 # nosec B106 - policy vocabulary.
required_scopes=("mail_servers:manage_credentials",), required_scopes=("mail_servers:manage_credentials",),
validation_required=True, validation_required=True,
audit_event="mail_server_profile.credential_updated", audit_event="mail_server_profile.credential_updated",
@@ -246,14 +255,14 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
storage="module_settings", storage="module_settings",
ui_managed=True, ui_managed=True,
risk="high", risk="high",
secret_handling="reference_only", secret_handling="reference_only", # noqa: S106 # nosec B106 - policy vocabulary.
required_scopes=("files:file:admin",), required_scopes=("files:file:admin",),
dry_run_required=True, dry_run_required=True,
policy_explanation_required=True, policy_explanation_required=True,
audit_event="files.connector_profile.updated", audit_event="files.connector_profile.updated",
two_person_approval_required=True, two_person_approval_required=True,
rollback_history_required=True, rollback_history_required=True,
notes="Connector endpoints are UI-manageable, but passwords/tokens remain env or secret refs.", notes="Connector endpoints are UI-manageable. API-managed credentials are encrypted or use scoped secret refs; process-environment references remain deployment-owned.",
), ),
ConfigurationFieldSafety( ConfigurationFieldSafety(
key="DATABASE_URL", key="DATABASE_URL",
@@ -263,7 +272,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
storage="environment", storage="environment",
ui_managed=False, ui_managed=False,
risk="destructive", risk="destructive",
secret_handling="env_only", secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True, maintenance_required=True,
notes="Database connectivity remains deployment-managed and must not be changed from the running UI.", notes="Database connectivity remains deployment-managed and must not be changed from the running UI.",
), ),
@@ -275,11 +284,179 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
storage="environment", storage="environment",
ui_managed=False, ui_managed=False,
risk="destructive", risk="destructive",
secret_handling="env_only", secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True, maintenance_required=True,
two_person_approval_required=True, two_person_approval_required=True,
notes="Encryption roots remain out of band; UI may only report missing/rotated state.", notes="Encryption roots remain out of band; UI may only report missing/rotated state.",
), ),
ConfigurationFieldSafety(
key="GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
label="Private-network connector access",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="This deployment-wide egress boundary remains out of band and applies to every connector worker.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
label="Structured connector response limit",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="A deployment-wide memory-safety limit applied consistently by API and worker processes.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES",
label="Connector file-transfer limit",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="A deployment-wide hard ceiling; individual module upload policies may impose lower limits.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
label="Connector secret environment allowlist",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="destructive",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Exact environment names available only to deployment-owned connector profiles; tenant/API profiles cannot select process variables.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST",
label="Connector CA bundle allowlist",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Exact absolute CA bundle paths approved by the deployment and mounted consistently on every connector worker.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES",
label="HTTP request-body limit",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="A deployment-wide hard ceiling; endpoint-specific upload policies may impose lower limits.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_HTTP_HSTS_SECONDS",
label="HTTP Strict Transport Security duration",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Deployment-managed browser transport policy; use only after the public service is HTTPS-only.",
),
ConfigurationFieldSafety(
key="AUTH_LOGIN_THROTTLE_ENABLED",
label="Interactive login throttling",
owner_module="access",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Disabling the deployment login throttle weakens protection against password guessing.",
),
ConfigurationFieldSafety(
key="AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT",
label="Login failures per identity",
owner_module="access",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Shared through Redis when available, with a bounded process-local fallback.",
),
ConfigurationFieldSafety(
key="AUTH_LOGIN_THROTTLE_CLIENT_LIMIT",
label="Login failures per client",
owner_module="access",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Counts the direct peer after the deployment's trusted-proxy boundary is applied.",
),
ConfigurationFieldSafety(
key="AUTH_LOGIN_THROTTLE_WINDOW_SECONDS",
label="Login throttle window",
owner_module="access",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Fixed counter window shared by identity and direct-client limits.",
),
ConfigurationFieldSafety(
key="AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS",
label="Login throttle Redis retry interval",
owner_module="access",
scope="system",
storage="environment",
ui_managed=False,
risk="medium",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Controls how quickly a worker retries the distributed counter after falling back locally.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_TRUSTED_HOSTS",
label="Trusted HTTP hosts",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Host-header validation is deployment-managed and must cover every public API host.",
),
ConfigurationFieldSafety(
key="FORWARDED_ALLOW_IPS",
label="Trusted reverse proxy addresses",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Uvicorn trusts forwarded client and scheme data only from this deployment-managed boundary.",
),
ConfigurationFieldSafety( ConfigurationFieldSafety(
key="GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS", key="GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS",
label="Module package catalog trusted keys", label="Module package catalog trusted keys",
@@ -288,7 +465,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
storage="environment", storage="environment",
ui_managed=False, ui_managed=False,
risk="high", risk="high",
secret_handling="env_only", secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
notes="Trust roots are deployment-managed; UI can validate catalogs but should not edit key material.", notes="Trust roots are deployment-managed; UI can validate catalogs but should not edit key material.",
), ),
ConfigurationFieldSafety( ConfigurationFieldSafety(
@@ -299,7 +476,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
storage="environment", storage="environment",
ui_managed=False, ui_managed=False,
risk="high", risk="high",
secret_handling="env_only", secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
notes="Configuration package trust roots are deployment-managed.", notes="Configuration package trust roots are deployment-managed.",
), ),
) )
@@ -339,23 +516,56 @@ def plan_configuration_change(
) -> ConfigurationChangeSafetyPlan: ) -> ConfigurationChangeSafetyPlan:
field = classify_configuration_field(key) field = classify_configuration_field(key)
if field is None: if field is None:
return ConfigurationChangeSafetyPlan( return _unknown_configuration_plan(key)
key=key,
allowed=False,
field=None,
blockers=("unknown_configuration_field",),
policy_explanation="This setting is not in the configuration safety catalog.",
)
if not include_env_only and not field.ui_managed: if not include_env_only and not field.ui_managed:
return ConfigurationChangeSafetyPlan( return _deployment_managed_configuration_plan(key, field)
key=key, state = _configuration_change_safety_state(
allowed=False, field,
field=field, actor_scopes=actor_scopes,
risk=field.risk, value=value,
secret_handling=field.secret_handling, dry_run=dry_run,
blockers=("deployment_managed",), maintenance_mode=maintenance_mode,
policy_explanation="This setting is deployment-managed and cannot be edited through the UI.", approval_count=approval_count,
) )
return _configuration_change_safety_plan(
field,
state=state,
dry_run=dry_run,
maintenance_mode=maintenance_mode,
)
def _unknown_configuration_plan(key: str) -> ConfigurationChangeSafetyPlan:
return ConfigurationChangeSafetyPlan(
key=key,
allowed=False,
field=None,
blockers=("unknown_configuration_field",),
policy_explanation="This setting is not in the configuration safety catalog.",
)
def _deployment_managed_configuration_plan(key: str, field: ConfigurationFieldSafety) -> ConfigurationChangeSafetyPlan:
return ConfigurationChangeSafetyPlan(
key=key,
allowed=False,
field=field,
risk=field.risk,
secret_handling=field.secret_handling,
blockers=("deployment_managed",),
policy_explanation="This setting is deployment-managed and cannot be edited through the UI.",
)
def _configuration_change_safety_state(
field: ConfigurationFieldSafety,
*,
actor_scopes: tuple[str, ...] | list[str],
value: object,
dry_run: bool,
maintenance_mode: bool,
approval_count: int,
) -> _ConfigurationChangeSafetyState:
blockers: list[str] = [] blockers: list[str] = []
warnings: list[str] = [] warnings: list[str] = []
missing_scopes = tuple(scope for scope in field.required_scopes if not scopes_grant(actor_scopes, scope)) missing_scopes = tuple(scope for scope in field.required_scopes if not scopes_grant(actor_scopes, scope))
@@ -371,30 +581,47 @@ def plan_configuration_change(
approval_satisfied = not approval_required or approval_count >= 2 approval_satisfied = not approval_required or approval_count >= 2
if approval_required and not approval_satisfied: if approval_required and not approval_satisfied:
blockers.append("two_person_approval_required") blockers.append("two_person_approval_required")
if field.secret_handling == "reference_only" and _contains_plain_secret(value): if field.secret_handling == "reference_only" and _contains_plain_secret(value): # noqa: S105 # nosec B105 - policy vocabulary.
blockers.append("secret_reference_required") blockers.append("secret_reference_required")
if field.secret_handling == "env_only" and value is not None: if field.secret_handling == "env_only" and value is not None: # noqa: S105 # nosec B105 - policy vocabulary.
blockers.append("env_only_secret") blockers.append("env_only_secret")
if field.rollback_history_required: if field.rollback_history_required:
warnings.append("rollback_history_required") warnings.append("rollback_history_required")
return ConfigurationChangeSafetyPlan( return _ConfigurationChangeSafetyState(
key=field.key,
allowed=not blockers,
field=field, field=field,
risk=field.risk,
missing_scopes=missing_scopes, missing_scopes=missing_scopes,
dry_run_required=field.dry_run_required, blockers=tuple(dict.fromkeys(blockers)),
dry_run_satisfied=not field.dry_run_required or dry_run, warnings=tuple(dict.fromkeys(warnings)),
approval_required=approval_required, approval_required=approval_required,
approval_satisfied=approval_satisfied, approval_satisfied=approval_satisfied,
)
def _configuration_change_safety_plan(
field: ConfigurationFieldSafety,
*,
state: _ConfigurationChangeSafetyState,
dry_run: bool,
maintenance_mode: bool,
) -> ConfigurationChangeSafetyPlan:
return ConfigurationChangeSafetyPlan(
key=field.key,
allowed=not state.blockers,
field=field,
risk=field.risk,
missing_scopes=state.missing_scopes,
dry_run_required=field.dry_run_required,
dry_run_satisfied=not field.dry_run_required or dry_run,
approval_required=state.approval_required,
approval_satisfied=state.approval_satisfied,
maintenance_required=field.maintenance_required, maintenance_required=field.maintenance_required,
maintenance_satisfied=not field.maintenance_required or maintenance_mode, maintenance_satisfied=not field.maintenance_required or maintenance_mode,
rollback_history_required=field.rollback_history_required, rollback_history_required=field.rollback_history_required,
secret_handling=field.secret_handling, secret_handling=field.secret_handling,
audit_event=field.audit_event, audit_event=field.audit_event,
policy_explanation=_policy_explanation(field), policy_explanation=_policy_explanation(field),
blockers=tuple(dict.fromkeys(blockers)), blockers=state.blockers,
warnings=tuple(dict.fromkeys(warnings)), warnings=state.warnings,
) )
@@ -406,7 +633,7 @@ def _policy_explanation(field: ConfigurationFieldSafety) -> str:
parts.append("requires two-person approval") parts.append("requires two-person approval")
if field.maintenance_required: if field.maintenance_required:
parts.append("requires maintenance mode") parts.append("requires maintenance mode")
if field.secret_handling != "none": if field.secret_handling != "none": # noqa: S105 # nosec B105 - policy vocabulary.
parts.append(f"uses {field.secret_handling} secret handling") parts.append(f"uses {field.secret_handling} secret handling")
return "; ".join(parts) + "." return "; ".join(parts) + "."

View 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",
]

View File

@@ -0,0 +1,438 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
CAPABILITY_DATASOURCE_CATALOGUE = "datasources.catalogue"
CAPABILITY_DATASOURCE_LIFECYCLE = "datasources.lifecycle"
CAPABILITY_DATASOURCE_PUBLICATION = "datasources.publication"
CAPABILITY_DATASOURCE_ORIGINS = "connectors.datasourceOrigins"
DatasourceMode = Literal["live", "cached", "static"]
DatasourceKind = Literal[
"upload",
"database",
"http",
"rest",
"directory",
"file",
"feed",
"custom",
]
DatasourceShape = Literal["tabular", "document", "binary", "directory", "stream"]
DatasourceConsistency = Literal["current", "live", "frozen"]
class DatasourceError(ValueError):
"""Stable base error for provider-neutral datasource operations."""
class DatasourceNotFoundError(DatasourceError):
pass
class DatasourceAccessError(DatasourceError):
pass
class DatasourceValidationError(DatasourceError):
pass
class DatasourceUnavailableError(DatasourceError):
pass
@dataclass(frozen=True, slots=True)
class DatasourceField:
name: str
data_type: str
nullable: bool = True
@dataclass(frozen=True, slots=True)
class DatasourceDescriptor:
ref: str
source_name: str
name: str
kind: DatasourceKind
mode: DatasourceMode
shape: DatasourceShape
status: str = "active"
description: str | None = None
provider: str | None = None
provider_ref: str | None = None
schema: tuple[DatasourceField, ...] = ()
schema_version: str = "1"
fingerprint: str = ""
current_materialization_ref: str | None = None
row_count: int | None = None
byte_count: int | None = None
updated_at: datetime | None = None
capabilities: tuple[str, ...] = ("read",)
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DatasourceMaterialization:
ref: str
datasource_ref: str
revision: int
state: str
fingerprint: str
schema: tuple[DatasourceField, ...] = ()
row_count: int | None = None
byte_count: int | None = None
frozen_at: datetime | None = None
frozen_label: str | None = None
source_timestamp: datetime | None = None
created_at: datetime | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DatasourceStage:
ref: str
name: str
source_name: str
kind: DatasourceKind
mode: DatasourceMode
shape: DatasourceShape
state: str
target_datasource_ref: str | None = None
fingerprint: str = ""
schema: tuple[DatasourceField, ...] = ()
row_count: int | None = None
byte_count: int | None = None
validation: Mapping[str, object] = field(default_factory=dict)
created_at: datetime | None = None
promoted_at: datetime | None = None
promoted_materialization_ref: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DatasourceReadRequest:
datasource_ref: str
materialization_ref: str | None = None
consistency: DatasourceConsistency = "current"
limit: int = 250
offset: int = 0
columns: tuple[str, ...] = ()
expected_fingerprint: str | None = None
@dataclass(frozen=True, slots=True)
class DatasourceReadResult:
datasource: DatasourceDescriptor
rows: tuple[Mapping[str, object], ...]
total_rows: int
truncated: bool
materialization: DatasourceMaterialization | None = None
@dataclass(frozen=True, slots=True)
class DatasourceStageInput:
name: str
source_name: str
kind: DatasourceKind
mode: DatasourceMode
shape: DatasourceShape
rows: tuple[Mapping[str, object], ...]
description: str | None = None
target_datasource_ref: str | None = None
provider: str | None = None
provider_ref: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DatasourcePublicationRequest:
producer_module: str
producer_run_ref: str
idempotency_key: str
rows: tuple[Mapping[str, object], ...]
target_datasource_ref: str | None = None
name: str | None = None
source_name: str | None = None
description: str | None = None
freeze: bool = False
frozen_label: str | None = None
set_current: bool = True
source_timestamp: datetime | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DatasourcePublicationResult:
ref: str
status: str
datasource: DatasourceDescriptor
materialization: DatasourceMaterialization
replayed: bool = False
@dataclass(frozen=True, slots=True)
class DatasourceOrigin:
ref: str
source_name: str
name: str
kind: DatasourceKind
shape: DatasourceShape
supported_modes: tuple[DatasourceMode, ...]
provider: str
description: str | None = None
schema: tuple[DatasourceField, ...] = ()
schema_version: str = "1"
fingerprint: str = ""
row_count: int | None = None
byte_count: int | None = None
updated_at: datetime | None = None
capabilities: tuple[str, ...] = ("read",)
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DatasourceOriginReadRequest:
origin_ref: str
limit: int = 250
offset: int = 0
columns: tuple[str, ...] = ()
expected_fingerprint: str | None = None
@dataclass(frozen=True, slots=True)
class DatasourceOriginReadResult:
origin: DatasourceOrigin
rows: tuple[Mapping[str, object], ...]
total_rows: int
truncated: bool
@runtime_checkable
class DatasourceCatalogueProvider(Protocol):
def list_datasources(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
) -> Sequence[DatasourceDescriptor]:
...
def get_datasource(
self,
session: object,
principal: object,
*,
datasource_ref: str,
) -> DatasourceDescriptor | None:
...
def read_datasource(
self,
session: object,
principal: object,
*,
request: DatasourceReadRequest,
) -> DatasourceReadResult:
...
def list_materializations(
self,
session: object,
principal: object,
*,
datasource_ref: str,
) -> Sequence[DatasourceMaterialization]:
...
@runtime_checkable
class DatasourceLifecycleProvider(Protocol):
def list_stages(
self,
session: object,
principal: object,
*,
limit: int = 100,
) -> Sequence[DatasourceStage]:
...
def create_stage(
self,
session: object,
principal: object,
*,
stage: DatasourceStageInput,
) -> DatasourceStage:
...
def promote_stage(
self,
session: object,
principal: object,
*,
stage_ref: str,
freeze: bool = False,
frozen_label: str | None = None,
) -> tuple[DatasourceDescriptor, DatasourceMaterialization]:
...
def register_origin(
self,
session: object,
principal: object,
*,
origin_ref: str,
name: str,
source_name: str,
mode: DatasourceMode,
description: str | None = None,
) -> DatasourceDescriptor:
...
def refresh_datasource(
self,
session: object,
principal: object,
*,
datasource_ref: str,
) -> tuple[DatasourceDescriptor, DatasourceMaterialization]:
...
def freeze_datasource(
self,
session: object,
principal: object,
*,
datasource_ref: str,
label: str | None = None,
) -> DatasourceMaterialization:
...
def retire_datasource(
self,
session: object,
principal: object,
*,
datasource_ref: str,
) -> DatasourceDescriptor:
...
@runtime_checkable
class DatasourcePublicationProvider(Protocol):
def publish_rows(
self,
session: object,
principal: object,
*,
request: DatasourcePublicationRequest,
) -> DatasourcePublicationResult:
...
@runtime_checkable
class DatasourceOriginProvider(Protocol):
def list_origins(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
) -> Sequence[DatasourceOrigin]:
...
def get_origin(
self,
session: object,
principal: object,
*,
origin_ref: str,
) -> DatasourceOrigin | None:
...
def read_origin(
self,
session: object,
principal: object,
*,
request: DatasourceOriginReadRequest,
) -> DatasourceOriginReadResult:
...
def datasource_catalogue(registry: object | None) -> DatasourceCatalogueProvider | None:
capability = _capability(registry, CAPABILITY_DATASOURCE_CATALOGUE)
return capability if isinstance(capability, DatasourceCatalogueProvider) else None
def datasource_lifecycle(registry: object | None) -> DatasourceLifecycleProvider | None:
capability = _capability(registry, CAPABILITY_DATASOURCE_LIFECYCLE)
return capability if isinstance(capability, DatasourceLifecycleProvider) else None
def datasource_publication(
registry: object | None,
) -> DatasourcePublicationProvider | None:
capability = _capability(registry, CAPABILITY_DATASOURCE_PUBLICATION)
return capability if isinstance(capability, DatasourcePublicationProvider) else None
def datasource_origins(registry: object | None) -> DatasourceOriginProvider | None:
capability = _capability(registry, CAPABILITY_DATASOURCE_ORIGINS)
return capability if isinstance(capability, DatasourceOriginProvider) else None
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
__all__ = [
"CAPABILITY_DATASOURCE_CATALOGUE",
"CAPABILITY_DATASOURCE_LIFECYCLE",
"CAPABILITY_DATASOURCE_ORIGINS",
"DatasourceAccessError",
"DatasourceCatalogueProvider",
"DatasourceConsistency",
"DatasourceDescriptor",
"DatasourceError",
"DatasourceField",
"DatasourceKind",
"DatasourceLifecycleProvider",
"DatasourceMaterialization",
"DatasourceMode",
"DatasourceNotFoundError",
"DatasourceOrigin",
"DatasourceOriginProvider",
"DatasourceOriginReadRequest",
"DatasourceOriginReadResult",
"DatasourceReadRequest",
"DatasourceReadResult",
"DatasourceShape",
"DatasourceStage",
"DatasourceStageInput",
"DatasourceUnavailableError",
"DatasourceValidationError",
"datasource_catalogue",
"datasource_lifecycle",
"datasource_origins",
]

View File

@@ -0,0 +1,455 @@
from __future__ import annotations
from collections import deque
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True, slots=True)
class DefinitionPort:
id: str
label: str
required: bool = True
multiple: bool = False
minimum_connections: int = 1
@dataclass(frozen=True, slots=True)
class DefinitionConfigField:
id: str
label: str
kind: str
required: bool = False
description: str | None = None
options: tuple[tuple[str, str], ...] = ()
@dataclass(frozen=True, slots=True)
class DefinitionNodeType:
type: str
category: str
label: str
description: str
icon: str
input_ports: tuple[DefinitionPort, ...] = ()
output_ports: tuple[DefinitionPort, ...] = (
DefinitionPort(id="output", label="Output"),
)
config_fields: tuple[DefinitionConfigField, ...] = ()
default_config: Mapping[str, Any] = field(default_factory=dict)
metadata: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DefinitionNode:
id: str
type: str
@dataclass(frozen=True, slots=True)
class DefinitionEdge:
id: str
source: str
target: str
source_port: str = "output"
target_port: str = "input"
@dataclass(frozen=True, slots=True)
class DefinitionNodeCountConstraint:
code: str
label: str
minimum: int = 0
maximum: int | None = None
node_types: tuple[str, ...] = ()
type_prefixes: tuple[str, ...] = ()
categories: tuple[str, ...] = ()
def matches(self, node: DefinitionNode, node_type: DefinitionNodeType | None) -> bool:
return (
node.type in self.node_types
or any(node.type.startswith(prefix) for prefix in self.type_prefixes)
or node_type is not None
and node_type.category in self.categories
)
@dataclass(frozen=True, slots=True)
class DefinitionGraphConstraints:
max_nodes: int = 100
max_edges: int = 200
allow_cycles: bool = False
require_connected: bool = True
node_counts: tuple[DefinitionNodeCountConstraint, ...] = ()
@dataclass(frozen=True, slots=True)
class DefinitionGraphLibrary:
id: str
version: str
category_labels: Mapping[str, str]
node_types: tuple[DefinitionNodeType, ...]
constraints: DefinitionGraphConstraints = field(default_factory=DefinitionGraphConstraints)
def node_type(self, type_id: str) -> DefinitionNodeType | None:
return next((item for item in self.node_types if item.type == type_id), None)
@dataclass(frozen=True, slots=True)
class DefinitionDiagnostic:
severity: str
code: str
message: str
node_id: str | None = None
field: str | None = None
def validate_definition_graph(
library: DefinitionGraphLibrary,
*,
nodes: Sequence[DefinitionNode],
edges: Sequence[DefinitionEdge],
) -> tuple[DefinitionDiagnostic, ...]:
if not nodes:
return (_error("graph.empty", "Add nodes before saving the definition."),)
constraints = library.constraints
diagnostics = _graph_size_diagnostics(
node_count=len(nodes),
edge_count=len(edges),
constraints=constraints,
)
node_by_id = {node.id: node for node in nodes}
definitions: dict[str, DefinitionNodeType] = {}
for item in library.node_types:
definitions.setdefault(item.type, item)
diagnostics.extend(_identifier_diagnostics(nodes=nodes, edges=edges))
incoming, undirected, topology_edges, edge_diagnostics = (
_validate_definition_edges(
edges=edges,
node_by_id=node_by_id,
definitions=definitions,
)
)
diagnostics.extend(edge_diagnostics)
diagnostics.extend(
_node_port_diagnostics(
nodes=nodes,
definitions=definitions,
incoming=incoming,
library_id=library.id,
)
)
diagnostics.extend(
_node_count_diagnostics(
nodes=nodes,
definitions=definitions,
constraints=constraints,
)
)
diagnostics.extend(
_topology_diagnostics(
nodes=nodes,
node_by_id=node_by_id,
topology_edges=topology_edges,
undirected=undirected,
constraints=constraints,
)
)
return tuple(diagnostics)
def _graph_size_diagnostics(
*,
node_count: int,
edge_count: int,
constraints: DefinitionGraphConstraints,
) -> list[DefinitionDiagnostic]:
diagnostics: list[DefinitionDiagnostic] = []
if node_count > constraints.max_nodes:
diagnostics.append(
_error(
"graph.node_limit",
f"Definitions are limited to {constraints.max_nodes:,} nodes.",
)
)
if edge_count > constraints.max_edges:
diagnostics.append(
_error(
"graph.edge_limit",
f"Definitions are limited to {constraints.max_edges:,} edges.",
)
)
return diagnostics
def _identifier_diagnostics(
*,
nodes: Sequence[DefinitionNode],
edges: Sequence[DefinitionEdge],
) -> list[DefinitionDiagnostic]:
diagnostics: list[DefinitionDiagnostic] = []
if len({node.id for node in nodes}) != len(nodes):
diagnostics.append(_error("graph.duplicate_node", "Node identifiers must be unique."))
if len({edge.id for edge in edges}) != len(edges):
diagnostics.append(_error("graph.duplicate_edge", "Edge identifiers must be unique."))
return diagnostics
def _validate_definition_edges(
*,
edges: Sequence[DefinitionEdge],
node_by_id: Mapping[str, DefinitionNode],
definitions: Mapping[str, DefinitionNodeType],
) -> tuple[
dict[str, list[DefinitionEdge]],
dict[str, set[str]],
list[DefinitionEdge],
list[DefinitionDiagnostic],
]:
incoming: dict[str, list[DefinitionEdge]] = {node_id: [] for node_id in node_by_id}
undirected: dict[str, set[str]] = {node_id: set() for node_id in node_by_id}
topology_edges: list[DefinitionEdge] = []
diagnostics: list[DefinitionDiagnostic] = []
for edge in edges:
structural_error = _edge_structure_diagnostic(edge, node_by_id)
if structural_error is not None:
diagnostics.append(structural_error)
continue
topology_edges.append(edge)
port_error = _edge_port_diagnostic(
edge,
source_definition=definitions.get(node_by_id[edge.source].type),
target_definition=definitions.get(node_by_id[edge.target].type),
)
if port_error is not None:
diagnostics.append(port_error)
continue
incoming[edge.target].append(edge)
undirected[edge.source].add(edge.target)
undirected[edge.target].add(edge.source)
return incoming, undirected, topology_edges, diagnostics
def _edge_structure_diagnostic(
edge: DefinitionEdge,
node_by_id: Mapping[str, DefinitionNode],
) -> DefinitionDiagnostic | None:
if edge.source not in node_by_id:
return _error(
"edge.unknown_source",
f"Edge {edge.id!r} references an unknown source node.",
)
if edge.target not in node_by_id:
return _error(
"edge.unknown_target",
f"Edge {edge.id!r} references an unknown target node.",
)
if edge.source == edge.target:
return _error(
"edge.self_reference",
"A node cannot connect to itself.",
node_id=edge.source,
)
return None
def _edge_port_diagnostic(
edge: DefinitionEdge,
*,
source_definition: DefinitionNodeType | None,
target_definition: DefinitionNodeType | None,
) -> DefinitionDiagnostic | None:
if source_definition is not None and edge.source_port not in {
port.id for port in source_definition.output_ports
}:
return _error(
"edge.unknown_source_port",
f"Node {edge.source!r} has no output port {edge.source_port!r}.",
node_id=edge.source,
)
if target_definition is not None and edge.target_port not in {
port.id for port in target_definition.input_ports
}:
return _error(
"edge.unknown_target_port",
f"Node {edge.target!r} has no input port {edge.target_port!r}.",
node_id=edge.target,
)
return None
def _node_port_diagnostics(
*,
nodes: Sequence[DefinitionNode],
definitions: Mapping[str, DefinitionNodeType],
incoming: Mapping[str, Sequence[DefinitionEdge]],
library_id: str,
) -> list[DefinitionDiagnostic]:
diagnostics: list[DefinitionDiagnostic] = []
for node in nodes:
definition = definitions.get(node.type)
if definition is None:
diagnostics.append(
_error(
"node.unsupported_type",
f"Node type {node.type!r} is not in the {library_id!r} library.",
node_id=node.id,
field="type",
)
)
continue
for port in definition.input_ports:
connections = [
edge for edge in incoming.get(node.id, ()) if edge.target_port == port.id
]
minimum = port.minimum_connections if port.required else 0
if len(connections) < minimum:
diagnostics.append(
_error(
"node.input_required",
(
f"{definition.label} requires {minimum} "
f"{port.label.lower()} connection"
f"{'' if minimum == 1 else 's'}."
),
node_id=node.id,
)
)
if not port.multiple and len(connections) > 1:
diagnostics.append(
_error(
"node.input_multiple",
f"{port.label} accepts only one connection.",
node_id=node.id,
)
)
return diagnostics
def _node_count_diagnostics(
*,
nodes: Sequence[DefinitionNode],
definitions: Mapping[str, DefinitionNodeType],
constraints: DefinitionGraphConstraints,
) -> list[DefinitionDiagnostic]:
diagnostics: list[DefinitionDiagnostic] = []
for count_constraint in constraints.node_counts:
count = sum(
count_constraint.matches(node, definitions.get(node.type))
for node in nodes
)
if count < count_constraint.minimum:
diagnostics.append(
_error(
count_constraint.code,
(
f"A definition needs at least {count_constraint.minimum} "
f"{count_constraint.label} node"
f"{'' if count_constraint.minimum == 1 else 's'}."
),
)
)
if count_constraint.maximum is not None and count > count_constraint.maximum:
diagnostics.append(
_error(
count_constraint.code,
(
f"A definition allows at most {count_constraint.maximum} "
f"{count_constraint.label} node"
f"{'' if count_constraint.maximum == 1 else 's'}."
),
)
)
return diagnostics
def _topology_diagnostics(
*,
nodes: Sequence[DefinitionNode],
node_by_id: Mapping[str, DefinitionNode],
topology_edges: Sequence[DefinitionEdge],
undirected: Mapping[str, set[str]],
constraints: DefinitionGraphConstraints,
) -> list[DefinitionDiagnostic]:
diagnostics: list[DefinitionDiagnostic] = []
_, cyclic = definition_topological_order(nodes, topology_edges)
if cyclic and not constraints.allow_cycles:
diagnostics.append(_error("graph.cycle", "Definition edges must form an acyclic graph."))
if constraints.require_connected and len(node_by_id) > 1:
connected = _connected_nodes(next(iter(node_by_id)), undirected)
if len(connected) != len(node_by_id):
diagnostics.append(
_error("graph.disconnected", "Every node must belong to one connected definition.")
)
return diagnostics
def definition_topological_order(
nodes: Sequence[DefinitionNode],
edges: Sequence[DefinitionEdge],
) -> tuple[tuple[str, ...], bool]:
node_ids = [node.id for node in nodes]
incoming_count = {node_id: 0 for node_id in node_ids}
outgoing: dict[str, list[str]] = {node_id: [] for node_id in node_ids}
for edge in edges:
if (
edge.source in outgoing
and edge.target in incoming_count
and edge.source != edge.target
):
outgoing[edge.source].append(edge.target)
incoming_count[edge.target] += 1
ready = deque(node_id for node_id in node_ids if incoming_count[node_id] == 0)
ordered: list[str] = []
while ready:
node_id = ready.popleft()
ordered.append(node_id)
for target in outgoing[node_id]:
incoming_count[target] -= 1
if incoming_count[target] == 0:
ready.append(target)
return tuple(ordered), len(ordered) != len(node_ids)
def _connected_nodes(start: str, adjacency: Mapping[str, set[str]]) -> set[str]:
seen: set[str] = set()
pending = [start]
while pending:
node_id = pending.pop()
if node_id in seen:
continue
seen.add(node_id)
pending.extend(adjacency.get(node_id, ()))
return seen
def _error(
code: str,
message: str,
*,
node_id: str | None = None,
field: str | None = None,
) -> DefinitionDiagnostic:
return DefinitionDiagnostic(
severity="error",
code=code,
message=message,
node_id=node_id,
field=field,
)
__all__ = [
"DefinitionConfigField",
"DefinitionDiagnostic",
"DefinitionEdge",
"DefinitionGraphConstraints",
"DefinitionGraphLibrary",
"DefinitionNode",
"DefinitionNodeCountConstraint",
"DefinitionNodeType",
"DefinitionPort",
"definition_topological_order",
"validate_definition_graph",
]

View File

@@ -1,17 +1,23 @@
from __future__ import annotations from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from collections.abc import Callable, Mapping from collections.abc import Callable, Mapping, Sequence
from contextlib import contextmanager from contextlib import contextmanager
from contextvars import ContextVar from contextvars import ContextVar
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
import re import re
from typing import Any, Literal from typing import Any, Literal, Protocol, runtime_checkable
import uuid import uuid
from sqlalchemy import event as sqlalchemy_event
from sqlalchemy.orm import Session
_TRACE_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$") _TRACE_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
_CONSUMER_ID_RE = re.compile(r"^[a-z][a-z0-9_.:-]{0,127}$")
_PENDING_EVENTS_KEY = "govoplan.pending_platform_events"
CAPABILITY_PLATFORM_EVENT_OUTBOX = "platform.eventOutbox"
def new_event_id() -> str: def new_event_id() -> str:
@@ -100,6 +106,105 @@ class PlatformEvent:
EventHandler = Callable[[PlatformEvent], None] EventHandler = Callable[[PlatformEvent], None]
DurableEventHandler = Callable[[PlatformEvent, str], None]
@dataclass(frozen=True, slots=True)
class DurableEventConsumer:
"""Allowlisted durable consumer with an explicit disclosure boundary."""
consumer_id: str
handler: DurableEventHandler
event_types: frozenset[str] = field(
default_factory=lambda: frozenset({"*"})
)
classifications: frozenset[EventClassification] = field(
default_factory=lambda: frozenset({"public", "internal"})
)
policy_decision_ref: str | None = None
def __post_init__(self) -> None:
if not _CONSUMER_ID_RE.fullmatch(self.consumer_id):
raise ValueError("Durable event consumer id is invalid")
if not self.event_types or any(
item != "*" and not _TRACE_ID_RE.fullmatch(item)
for item in self.event_types
):
raise ValueError(
"Durable event consumers require valid event-type allowlists"
)
invalid_classifications = set(self.classifications) - {
"public",
"internal",
"confidential",
"restricted",
}
if not self.classifications or invalid_classifications:
raise ValueError(
"Durable event consumer classifications are invalid"
)
if (
self.classifications & {"confidential", "restricted"}
and not normalize_trace_id(self.policy_decision_ref)
):
raise ValueError(
"Confidential or restricted event subscriptions require "
"an explicit policy decision reference"
)
def accepts(self, event: PlatformEvent) -> bool:
return (
event.classification in self.classifications
and (
"*" in self.event_types
or event.type in self.event_types
)
)
def delivery_key(self, event: PlatformEvent) -> str:
return f"{event.event_id}:{self.consumer_id}"
@runtime_checkable
class PlatformEventOutbox(Protocol):
def enqueue(self, session: object, event: PlatformEvent) -> object:
...
def dispatch_pending(
self,
session: object,
*,
consumers: Sequence[DurableEventConsumer] = (),
observer: EventHandler | None = None,
limit: int = 100,
) -> Mapping[str, int]:
...
def replay_delivery(
self,
session: object,
*,
event_id: str,
consumer_id: str,
operator_id: str,
reason: str,
) -> Mapping[str, object]:
...
def purge_terminal(
self,
session: object,
*,
before: datetime,
limit: int = 500,
) -> Mapping[str, int]:
...
def delivery_metrics(
self,
session: object,
) -> Mapping[str, object]:
...
def current_event_trace() -> EventTrace | None: def current_event_trace() -> EventTrace | None:
@@ -189,5 +294,85 @@ def publish_platform_event(event: PlatformEvent) -> None:
platform_event_bus().publish(event) platform_event_bus().publish(event)
def platform_event_outbox(
registry: object | None = None,
) -> PlatformEventOutbox | None:
if registry is None:
from govoplan_core.core.runtime import get_registry
registry = get_registry()
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
):
return None
capability = registry.capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
return capability if isinstance(capability, PlatformEventOutbox) else None
def emit_platform_event(
session: Session,
event: PlatformEvent,
*,
registry: object | None = None,
) -> None:
"""Persist an event with its transaction or publish it after commit.
The durable outbox is optional so reduced module combinations remain
usable. Without it, the event is kept on the SQLAlchemy session and only
delivered to the process-local bus after the outer transaction commits.
"""
traced = ensure_event_trace(event)
outbox = platform_event_outbox(registry)
if outbox is not None:
outbox.enqueue(session, traced)
return
transaction = (
session.get_nested_transaction()
or session.get_transaction()
or session.begin()
)
pending_by_transaction = session.info.setdefault(
_PENDING_EVENTS_KEY,
{},
)
pending_by_transaction.setdefault(transaction, []).append(
(platform_event_bus(), traced)
)
@sqlalchemy_event.listens_for(Session, "after_commit")
def _publish_committed_events(session: Session) -> None:
transaction = session.get_nested_transaction() or session.get_transaction()
if transaction is None:
return
pending_by_transaction = session.info.get(_PENDING_EVENTS_KEY)
if not isinstance(pending_by_transaction, dict):
return
pending = pending_by_transaction.pop(transaction, ())
parent = transaction.parent
if parent is not None:
pending_by_transaction.setdefault(parent, []).extend(pending)
else:
for bus, event in pending:
bus.publish(event)
if not pending_by_transaction:
session.info.pop(_PENDING_EVENTS_KEY, None)
@sqlalchemy_event.listens_for(Session, "after_rollback")
def _discard_rolled_back_events(session: Session) -> None:
transaction = session.get_nested_transaction() or session.get_transaction()
pending_by_transaction = session.info.get(_PENDING_EVENTS_KEY)
if transaction is None or not isinstance(pending_by_transaction, dict):
return
pending_by_transaction.pop(transaction, None)
if transaction.parent is None or not pending_by_transaction:
session.info.pop(_PENDING_EVENTS_KEY, None)
def _compact_dict(value: Mapping[str, Any]) -> dict[str, Any]: def _compact_dict(value: Mapping[str, Any]) -> dict[str, Any]:
return {key: item for key, item in value.items() if item is not None} return {key: item for key, item in value.items() if item is not None}

View 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",
]

View File

@@ -7,6 +7,7 @@ from typing import Literal, Protocol, runtime_checkable
IDENTITY_MODULE_ID = "identity" IDENTITY_MODULE_ID = "identity"
CAPABILITY_IDENTITY_DIRECTORY = "identity.directory" CAPABILITY_IDENTITY_DIRECTORY = "identity.directory"
CAPABILITY_IDENTITY_SEARCH = "identity.search"
IdentityStatus = Literal["active", "inactive", "suspended"] IdentityStatus = Literal["active", "inactive", "suspended"]
@@ -44,3 +45,15 @@ class IdentityDirectory(Protocol):
def accounts_for_identity(self, identity_id: str) -> Sequence[IdentityAccountLinkRef]: def accounts_for_identity(self, identity_id: str) -> Sequence[IdentityAccountLinkRef]:
... ...
@runtime_checkable
class IdentitySearchProvider(Protocol):
def search_identities(
self,
query: str | None = None,
*,
include_inactive: bool = False,
limit: int = 25,
) -> Sequence[IdentityRef]:
...

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence from collections.abc import Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Literal, Protocol, runtime_checkable from typing import Literal, Protocol, runtime_checkable
@@ -8,6 +8,7 @@ from typing import Literal, Protocol, runtime_checkable
IDM_MODULE_ID = "idm" IDM_MODULE_ID = "idm"
CAPABILITY_IDM_DIRECTORY = "idm.directory" CAPABILITY_IDM_DIRECTORY = "idm.directory"
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS = "idm.function_assignments"
IdmStatus = Literal["active", "inactive", "suspended"] IdmStatus = Literal["active", "inactive", "suspended"]
OrganizationFunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"] OrganizationFunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
@@ -30,6 +31,18 @@ class OrganizationFunctionAssignmentRef:
status: IdmStatus = "active" status: IdmStatus = "active"
@dataclass(frozen=True, slots=True)
class OrganizationFunctionIncumbencyRef:
tenant_id: str
function_id: str
assignments: tuple[OrganizationFunctionAssignmentRef, ...] = ()
function_active: bool = True
@property
def vacant(self) -> bool:
return not self.assignments
@runtime_checkable @runtime_checkable
class IdmDirectory(Protocol): class IdmDirectory(Protocol):
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None: def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
@@ -40,6 +53,7 @@ class IdmDirectory(Protocol):
identity_id: str, identity_id: str,
*, *,
tenant_id: str | None = None, tenant_id: str | None = None,
effective_at: datetime | None = None,
) -> Sequence[OrganizationFunctionAssignmentRef]: ) -> Sequence[OrganizationFunctionAssignmentRef]:
... ...
@@ -48,5 +62,47 @@ class IdmDirectory(Protocol):
account_id: str, account_id: str,
*, *,
tenant_id: str | None = None, tenant_id: str | None = None,
effective_at: datetime | None = None,
) -> Sequence[OrganizationFunctionAssignmentRef]: ) -> Sequence[OrganizationFunctionAssignmentRef]:
... ...
def organization_function_assignments_for_identities(
self,
identity_ids: Sequence[str],
*,
tenant_id: str | None = None,
effective_at: datetime | None = None,
) -> Mapping[str, Sequence[OrganizationFunctionAssignmentRef]]:
...
def organization_function_assignments_for_accounts(
self,
account_ids: Sequence[str],
*,
tenant_id: str | None = None,
effective_at: datetime | None = None,
) -> Mapping[str, Sequence[OrganizationFunctionAssignmentRef]]:
...
@runtime_checkable
class IdmFunctionAssignmentDirectory(Protocol):
"""Reverse lookup for effective incumbency and vacancy decisions."""
def organization_function_assignments_for_function(
self,
function_id: str,
*,
tenant_id: str | None = None,
effective_at: datetime | None = None,
) -> Sequence[OrganizationFunctionAssignmentRef]:
...
def organization_function_incumbencies(
self,
function_ids: Sequence[str],
*,
tenant_id: str,
effective_at: datetime | None = None,
) -> Mapping[str, OrganizationFunctionIncumbencyRef]:
...

View File

@@ -3,9 +3,11 @@ from __future__ import annotations
import base64 import base64
import json import json
import os import os
import re
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Literal from pathlib import Path
from typing import Literal
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from sqlalchemy.engine import make_url from sqlalchemy.engine import make_url
@@ -72,6 +74,25 @@ class ConfigValidationResult:
return "\n".join(lines) return "\n".join(lines)
@dataclass(frozen=True, slots=True)
class _RuntimeProfile:
name: str
production: bool
production_like: bool
local: bool
@dataclass(slots=True)
class _ConfigIssueCollector:
strict: bool
issues: list[ConfigIssue]
def add(self, level: ConfigIssueLevel, key: str, message: str, action: str) -> None:
if self.strict and level == "warning":
level = "error"
self.issues.append(ConfigIssue(level=level, key=key, message=message, action=action))
_LOCAL_PROFILES = {"dev", "local", "local-dev", "test"} _LOCAL_PROFILES = {"dev", "local", "local-dev", "test"}
_PRODUCTION_PROFILES = {"prod", "production", "self-hosted"} _PRODUCTION_PROFILES = {"prod", "production", "self-hosted"}
_PRODUCTION_LIKE_PROFILES = {"staging", "production-like", "production-like-dev"} _PRODUCTION_LIKE_PROFILES = {"staging", "production-like", "production-like-dev"}
@@ -114,95 +135,192 @@ def validate_runtime_configuration(
strict: bool = False, strict: bool = False,
) -> ConfigValidationResult: ) -> ConfigValidationResult:
env = dict(os.environ if environ is None else environ) env = dict(os.environ if environ is None else environ)
clean_profile = normalize_install_profile(profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV")) runtime = _runtime_profile(env, profile=profile)
issues: list[ConfigIssue] = [] collector = _ConfigIssueCollector(strict=strict, issues=[])
_validate_app_env(env, runtime, collector)
_validate_database_settings(env, runtime, collector)
_validate_master_key(env, runtime, collector)
_validate_enabled_modules(env, runtime, collector)
_validate_async_and_auth_settings(env, runtime, collector)
_validate_cors_settings(env, runtime, collector)
_validate_file_storage_settings(env, runtime, collector)
_validate_outbound_connector_policy(env, runtime, collector)
_validate_module_catalog_trust(env, runtime, collector)
return ConfigValidationResult(profile=runtime.name, issues=tuple(collector.issues))
def _runtime_profile(env: Mapping[str, str], *, profile: str | None) -> _RuntimeProfile:
clean_profile = normalize_install_profile(profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV"))
production = clean_profile in _PRODUCTION_PROFILES or env.get("APP_ENV", "").strip().lower() in {"prod", "production"} production = clean_profile in _PRODUCTION_PROFILES or env.get("APP_ENV", "").strip().lower() in {"prod", "production"}
production_like = production or clean_profile in _PRODUCTION_LIKE_PROFILES production_like = production or clean_profile in _PRODUCTION_LIKE_PROFILES
local = clean_profile in _LOCAL_PROFILES and not production_like return _RuntimeProfile(
name=clean_profile,
production=production,
production_like=production_like,
local=clean_profile in _LOCAL_PROFILES and not production_like,
)
def issue(level: ConfigIssueLevel, key: str, message: str, action: str) -> None:
if strict and level == "warning":
level = "error"
issues.append(ConfigIssue(level=level, key=key, message=message, action=action))
def _validate_app_env(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
app_env = _clean(env.get("APP_ENV")) app_env = _clean(env.get("APP_ENV"))
if not app_env and production_like: if not app_env and runtime.production_like:
issue("error", "APP_ENV", "APP_ENV is missing for a production-like install.", "Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.") collector.add("error", "APP_ENV", "APP_ENV is missing for a production-like install.", "Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.")
elif app_env.lower() in {"dev", "test", "local"} and production_like: elif app_env.lower() in {"dev", "test", "local"} and runtime.production_like:
issue("error", "APP_ENV", f"APP_ENV={app_env!r} is not valid for profile {clean_profile!r}.", "Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.") collector.add("error", "APP_ENV", f"APP_ENV={app_env!r} is not valid for profile {runtime.name!r}.", "Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.")
def _validate_database_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
database_url = _clean(env.get("DATABASE_URL")) database_url = _clean(env.get("DATABASE_URL"))
if not database_url: if not database_url:
issue("error", "DATABASE_URL", "DATABASE_URL is missing.", "Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.") collector.add("error", "DATABASE_URL", "DATABASE_URL is missing.", "Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.")
else: return
backend = _database_backend(database_url) backend = _database_backend(database_url)
if backend is None: if backend is None:
issue("error", "DATABASE_URL", "DATABASE_URL is not a valid SQLAlchemy URL.", "Use a value like postgresql+psycopg://user:password@host:5432/database.") collector.add("error", "DATABASE_URL", "DATABASE_URL is not a valid SQLAlchemy URL.", "Use a value like postgresql+psycopg://user:password@host:5432/database.")
elif backend == "sqlite" and production_like: return
issue("error", "DATABASE_URL", "SQLite is only supported for disposable local development.", "Use PostgreSQL for production-like and self-hosted installs.") if backend == "sqlite" and runtime.production_like:
elif backend != "postgresql" and production: collector.add("error", "DATABASE_URL", "SQLite is only supported for disposable local development.", "Use PostgreSQL for production-like and self-hosted installs.")
issue("warning", "DATABASE_URL", f"Database backend {backend!r} is not the preferred production target.", "Use PostgreSQL unless this deployment has an explicit support decision.") elif backend != "postgresql" and runtime.production:
if backend == "postgresql" and not _clean(env.get("GOVOPLAN_DATABASE_URL_PGTOOLS")): collector.add("warning", "DATABASE_URL", f"Database backend {backend!r} is not the preferred production target.", "Use PostgreSQL unless this deployment has an explicit support decision.")
issue("warning", "GOVOPLAN_DATABASE_URL_PGTOOLS", "PostgreSQL backup/restore tools URL is missing.", "Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.") if backend == "postgresql" and not _clean(env.get("GOVOPLAN_DATABASE_URL_PGTOOLS")):
collector.add("warning", "GOVOPLAN_DATABASE_URL_PGTOOLS", "PostgreSQL backup/restore tools URL is missing.", "Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.")
def _validate_master_key(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
master_key = _clean(env.get("MASTER_KEY_B64")) master_key = _clean(env.get("MASTER_KEY_B64"))
if not master_key and not local: if not master_key and not runtime.local:
issue("error", "MASTER_KEY_B64", "MASTER_KEY_B64 is required outside local dev/test.", "Generate a Fernet key with `govoplan-config env-template --generate-secrets` or `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` and store it in deployment secrets.") collector.add("error", "MASTER_KEY_B64", "MASTER_KEY_B64 is required outside local dev/test.", "Generate a Fernet key with `govoplan-config env-template --generate-secrets` or `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` and store it in deployment secrets.")
elif master_key: return
error = _master_key_error(master_key) if not master_key:
if error: return
issue("error", "MASTER_KEY_B64", error, "Replace MASTER_KEY_B64 with a Fernet key or base64-encoded 32-byte key.") error = _master_key_error(master_key)
elif "change-me" in master_key.lower() or "generate" in master_key.lower(): if error:
issue("error", "MASTER_KEY_B64", "MASTER_KEY_B64 still looks like a placeholder.", "Generate a real deployment key and store it outside git.") collector.add("error", "MASTER_KEY_B64", error, "Replace MASTER_KEY_B64 with a Fernet key or base64-encoded 32-byte key.")
elif "change-me" in master_key.lower() or "generate" in master_key.lower():
collector.add("error", "MASTER_KEY_B64", "MASTER_KEY_B64 still looks like a placeholder.", "Generate a real deployment key and store it outside git.")
def _validate_enabled_modules(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
enabled_modules = _csv(env.get("ENABLED_MODULES")) enabled_modules = _csv(env.get("ENABLED_MODULES"))
if not enabled_modules and production_like: if not enabled_modules and runtime.production_like:
issue("error", "ENABLED_MODULES", "ENABLED_MODULES is missing.", "Set ENABLED_MODULES explicitly so startup module composition is intentional.") collector.add("error", "ENABLED_MODULES", "ENABLED_MODULES is missing.", "Set ENABLED_MODULES explicitly so startup module composition is intentional.")
elif "access" not in enabled_modules and production_like: elif "access" not in enabled_modules and runtime.production_like:
issue("error", "ENABLED_MODULES", "The access module is not enabled.", "Include `access` unless this deployment has a replacement auth/principal provider.") collector.add("error", "ENABLED_MODULES", "The access module is not enabled.", "Include `access` unless this deployment has a replacement auth/principal provider.")
elif enabled_modules and "admin" not in enabled_modules and production_like: elif enabled_modules and "admin" not in enabled_modules and runtime.production_like:
issue("warning", "ENABLED_MODULES", "The admin module is not enabled.", "Keep `admin` enabled for operator UI unless this is a deliberately headless install.") collector.add("warning", "ENABLED_MODULES", "The admin module is not enabled.", "Keep `admin` enabled for operator UI unless this is a deliberately headless install.")
celery_enabled = _truthy(env.get("CELERY_ENABLED"))
if celery_enabled and not _clean(env.get("REDIS_URL")):
issue("error", "REDIS_URL", "CELERY_ENABLED=true but REDIS_URL is missing.", "Set REDIS_URL to the Redis broker/result backend used by workers.")
if production and _truthy(env.get("DEV_BOOTSTRAP_ENABLED")): def _validate_async_and_auth_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
issue("error", "DEV_BOOTSTRAP_ENABLED", "Development bootstrap is enabled in production.", "Set DEV_BOOTSTRAP_ENABLED=false and create first administrators through the controlled bootstrap path.") if _truthy(env.get("CELERY_ENABLED")) and not _clean(env.get("REDIS_URL")):
collector.add("error", "REDIS_URL", "CELERY_ENABLED=true but REDIS_URL is missing.", "Set REDIS_URL to the Redis broker/result backend used by workers.")
if runtime.production and _truthy(env.get("DEV_BOOTSTRAP_ENABLED")):
collector.add("error", "DEV_BOOTSTRAP_ENABLED", "Development bootstrap is enabled in production.", "Set DEV_BOOTSTRAP_ENABLED=false and create first administrators through the controlled bootstrap path.")
if runtime.production and not _truthy(env.get("AUTH_COOKIE_SECURE")):
collector.add("error", "AUTH_COOKIE_SECURE", "Secure auth cookies are disabled for production.", "Set AUTH_COOKIE_SECURE=true behind HTTPS.")
if production and not _truthy(env.get("AUTH_COOKIE_SECURE")):
issue("error", "AUTH_COOKIE_SECURE", "Secure auth cookies are disabled for production.", "Set AUTH_COOKIE_SECURE=true behind HTTPS.")
def _validate_cors_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
cors_origins = _csv(env.get("CORS_ORIGINS")) cors_origins = _csv(env.get("CORS_ORIGINS"))
if production_like and not cors_origins: if runtime.production_like and not cors_origins:
issue("error", "CORS_ORIGINS", "CORS_ORIGINS is missing.", "Set CORS_ORIGINS to the exact WebUI origin or origins.") collector.add("error", "CORS_ORIGINS", "CORS_ORIGINS is missing.", "Set CORS_ORIGINS to the exact WebUI origin or origins.")
elif "*" in cors_origins and production_like: elif "*" in cors_origins and runtime.production_like:
issue("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.") collector.add("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.")
elif production and set(cors_origins) <= _DEFAULT_LOCAL_CORS: elif runtime.production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
issue("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.") collector.add("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.")
trusted_hosts = _csv(env.get("GOVOPLAN_TRUSTED_HOSTS"))
if runtime.production_like and not trusted_hosts:
collector.add("error", "GOVOPLAN_TRUSTED_HOSTS", "Trusted HTTP hosts are not configured.", "Set GOVOPLAN_TRUSTED_HOSTS to the exact API host names accepted by this deployment.")
elif "*" in trusted_hosts and runtime.production_like:
collector.add("error", "GOVOPLAN_TRUSTED_HOSTS", "Wildcard trusted hosts are not allowed for production-like installs.", "Replace `*` with exact host names or narrowly scoped `*.example.org` entries.")
forwarded_allow_ips = _csv(env.get("FORWARDED_ALLOW_IPS"))
if runtime.production_like and "*" in forwarded_allow_ips:
collector.add("error", "FORWARDED_ALLOW_IPS", "Proxy headers must not be trusted from every address.", "Set FORWARDED_ALLOW_IPS to the reverse proxy address or network passed to Uvicorn.")
def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
storage_backend = _clean(env.get("FILE_STORAGE_BACKEND")) or "local" storage_backend = _clean(env.get("FILE_STORAGE_BACKEND")) or "local"
if storage_backend == "local": if storage_backend == "local":
if not _clean(env.get("FILE_STORAGE_LOCAL_ROOT")) and production_like: _validate_local_file_storage(env, runtime, collector)
issue("error", "FILE_STORAGE_LOCAL_ROOT", "Local file storage root is missing.", "Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.")
elif production:
issue("warning", "FILE_STORAGE_BACKEND", "Production is configured for local file storage.", "Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.")
elif storage_backend == "s3": elif storage_backend == "s3":
for key in ("FILE_STORAGE_S3_ENDPOINT_URL", "FILE_STORAGE_S3_REGION", "FILE_STORAGE_S3_ACCESS_KEY_ID", "FILE_STORAGE_S3_SECRET_ACCESS_KEY", "FILE_STORAGE_S3_BUCKET"): _validate_s3_file_storage(env, collector)
if not _clean(env.get(key)):
issue("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
else: else:
issue("error", "FILE_STORAGE_BACKEND", f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.", "Use `local` or `s3`.") collector.add("error", "FILE_STORAGE_BACKEND", f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.", "Use `local` or `s3`.")
def _validate_local_file_storage(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
if not _clean(env.get("FILE_STORAGE_LOCAL_ROOT")) and runtime.production_like:
collector.add("error", "FILE_STORAGE_LOCAL_ROOT", "Local file storage root is missing.", "Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.")
elif runtime.production:
collector.add("warning", "FILE_STORAGE_BACKEND", "Production is configured for local file storage.", "Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.")
def _validate_s3_file_storage(env: Mapping[str, str], collector: _ConfigIssueCollector) -> None:
for key in ("FILE_STORAGE_S3_ENDPOINT_URL", "FILE_STORAGE_S3_REGION", "FILE_STORAGE_S3_ACCESS_KEY_ID", "FILE_STORAGE_S3_SECRET_ACCESS_KEY", "FILE_STORAGE_S3_BUCKET"):
if not _clean(env.get(key)):
collector.add("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
def _validate_outbound_connector_policy(
env: Mapping[str, str],
runtime: _RuntimeProfile,
collector: _ConfigIssueCollector,
) -> None:
private_networks = _clean(env.get("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS")).lower()
if runtime.production_like and private_networks not in {"true", "false", "1", "0", "yes", "no", "on", "off"}:
collector.add(
"error",
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
"Private-network connector access must be an explicit deployment decision.",
"Set GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false for public-only egress, or true when this deployment must reach internal services.",
)
for key in (
"GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
"GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES",
"GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES",
):
value = _clean(env.get(key))
if not value:
continue
try:
parsed = int(value)
except ValueError:
parsed = 0
if parsed <= 0:
collector.add("error", key, f"{key} must be a positive byte count.", "Use a positive integer byte limit.")
secret_env_names = [
item.strip()
for item in env.get("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST", "").split(",")
if item.strip()
]
if any(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", item) is None for item in secret_env_names):
collector.add(
"error",
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
"Connector secret environment allowlist contains an invalid variable name.",
"Use a comma-separated list of exact environment variable names, or leave the setting empty.",
)
ca_bundle_paths = [
item.strip()
for item in env.get("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST", "").split(",")
if item.strip()
]
if any(not Path(item).is_absolute() for item in ca_bundle_paths):
collector.add(
"error",
"GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST",
"Connector CA bundle allowlist contains a non-absolute path.",
"Use comma-separated absolute paths mounted on every API and connector worker, or leave the setting empty.",
)
def _validate_module_catalog_trust(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG")) catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG"))
if production and catalog_source: if not runtime.production or not catalog_source:
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE")): return
issue("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "A module catalog source is configured without a trusted keyring file.", "Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.") if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE")):
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL")): collector.add("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "A module catalog source is configured without a trusted keyring file.", "Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.")
issue("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL", "A module catalog source is configured without an approved release channel.", "Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.") if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL")):
collector.add("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL", "A module catalog source is configured without an approved release channel.", "Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.")
return ConfigValidationResult(profile=clean_profile, issues=tuple(issues))
def _self_hosted_env_template(master_key: str) -> str: def _self_hosted_env_template(master_key: str) -> str:
@@ -220,9 +338,34 @@ ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,aud
CELERY_ENABLED=true CELERY_ENABLED=true
REDIS_URL=redis://127.0.0.1:6379/0 REDIS_URL=redis://127.0.0.1:6379/0
CELERY_QUEUES=send_email,append_sent,default CELERY_QUEUES=send_email,append_sent,notifications,calendar,dataflow,events,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
# Deployment-wide connector egress policy. Enable private networks only when
# this installation intentionally integrates with internal services.
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES=536870912
# Exact names/paths only. Keep empty until a deployment-owned connector needs them.
GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=
GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=
GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES=536870912
GOVOPLAN_HTTP_HSTS_SECONDS=31536000
AUTH_LOGIN_THROTTLE_ENABLED=true
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS=30
CORS_ORIGINS=https://govoplan.example.org CORS_ORIGINS=https://govoplan.example.org
GOVOPLAN_TRUSTED_HOSTS=govoplan.example.org
# Uvicorn reads FORWARDED_ALLOW_IPS when proxy headers are enabled. Keep this
# restricted to the actual reverse proxy address or network.
FORWARDED_ALLOW_IPS=127.0.0.1
AUTH_COOKIE_SECURE=true AUTH_COOKIE_SECURE=true
AUTH_COOKIE_SAMESITE=lax AUTH_COOKIE_SAMESITE=lax
AUTH_COOKIE_DOMAIN= AUTH_COOKIE_DOMAIN=
@@ -263,10 +406,30 @@ DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
REDIS_URL=redis://127.0.0.1:56379/0 REDIS_URL=redis://127.0.0.1:56379/0
CELERY_ENABLED=true CELERY_ENABLED=true
CELERY_QUEUES=send_email,append_sent,default CELERY_QUEUES=send_email,append_sent,notifications,calendar,dataflow,events,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES=536870912
GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=
GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=
GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES=536870912
GOVOPLAN_HTTP_HSTS_SECONDS=0
AUTH_LOGIN_THROTTLE_ENABLED=true
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS=30
ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173 CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
GOVOPLAN_TRUSTED_HOSTS=127.0.0.1,localhost,testserver
FORWARDED_ALLOW_IPS=127.0.0.1
AUTH_COOKIE_SECURE=false AUTH_COOKIE_SECURE=false
FILE_STORAGE_BACKEND=local FILE_STORAGE_BACKEND=local
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
@@ -299,8 +462,8 @@ def _master_key_error(value: str) -> str | None:
try: try:
Fernet(candidate) Fernet(candidate)
return None return None
except Exception: except (TypeError, ValueError):
pass raw = None
try: try:
raw = base64.b64decode(candidate) raw = base64.b64decode(candidate)
except Exception: except Exception:
@@ -309,6 +472,6 @@ def _master_key_error(value: str) -> str | None:
return "MASTER_KEY_B64 must decode to exactly 32 bytes." return "MASTER_KEY_B64 must decode to exactly 32 bytes."
try: try:
Fernet(base64.urlsafe_b64encode(raw)) Fernet(base64.urlsafe_b64encode(raw))
except Exception: except (TypeError, ValueError):
return "MASTER_KEY_B64 is not usable as a Fernet key." return "MASTER_KEY_B64 is not usable as a Fernet key."
return None return None

View File

@@ -67,6 +67,17 @@ class ModuleLifecycleManager:
def mounted_module_ids(self) -> tuple[str, ...]: def mounted_module_ids(self) -> tuple[str, ...]:
return tuple(sorted(self._mounted_modules)) return tuple(sorted(self._mounted_modules))
def live_apply_enabled(self) -> bool:
configured = getattr(
self.settings,
"module_live_apply_enabled",
None,
)
if configured is not None:
return bool(configured)
app_env = str(getattr(self.settings, "app_env", "dev")).casefold()
return app_env in {"dev", "development", "local", "test", "testing"}
def apply_enabled_modules( def apply_enabled_modules(
self, self,
requested_enabled: Sequence[str], requested_enabled: Sequence[str],

View File

@@ -79,15 +79,19 @@ def drop_table_retirement_provider(
warnings.append("Tables not present and therefore skipped: " + ", ".join(missing_names)) warnings.append("Tables not present and therefore skipped: " + ", ".join(missing_names))
def executor(execute_session: object, _module_id: str) -> None: def executor(execute_session: object, _module_id: str) -> None:
if not hasattr(execute_session, "get_bind"): if not hasattr(execute_session, "connection"):
raise RuntimeError("No database session is available for destructive table retirement.") raise RuntimeError("No database session is available for destructive table retirement.")
execute_bind = execute_session.get_bind() # type: ignore[attr-defined] # Enlist schema retirement in the caller's active transaction. An
live_inspector = inspect(execute_bind) # Engine returned by Session.get_bind() may acquire a second
# connection, separating the DROP from module-specific secret
# scrubbing/audit writes and deadlocking on their uncommitted locks.
execute_connection = execute_session.connection() # type: ignore[attr-defined]
live_inspector = inspect(execute_connection)
live_tables = [table for table in tables if live_inspector.has_table(table.name)] live_tables = [table for table in tables if live_inspector.has_table(table.name)]
if not live_tables: if not live_tables:
return return
metadata = live_tables[0].metadata metadata = live_tables[0].metadata
metadata.drop_all(bind=execute_bind, tables=live_tables, checkfirst=True) metadata.drop_all(bind=execute_connection, tables=live_tables, checkfirst=True)
return MigrationRetirementPlan( return MigrationRetirementPlan(
supported=True, supported=True,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,126 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
INSTALLER_NOTIFICATION_ACTION_URL = "/admin?section=modules"
def emit_module_installer_notification(
*,
session: object,
registry: object | None,
tenant_id: str | None,
request: Mapping[str, object],
event_kind: str,
subject: str,
body_text: str,
recipient_id: str | None = None,
priority: int = 5,
) -> bool:
if not tenant_id:
return False
provider = notification_dispatch_provider(registry)
if provider is None:
return False
request_id = str(request.get("request_id") or "")
if not request_id:
return False
provider.enqueue_notification(
session,
NotificationDispatchRequest(
tenant_id=tenant_id,
source_module="core",
source_resource_type="module_install_request",
source_resource_id=request_id,
event_kind=event_kind,
channel="inbox",
recipient_type="user" if recipient_id else None,
recipient_id=recipient_id,
subject=subject,
body_text=body_text,
action_url=INSTALLER_NOTIFICATION_ACTION_URL,
priority=priority,
payload={
"request_id": request_id,
"status": request.get("status"),
"run_id": _result_run_id(request),
},
metadata={
"trace": request.get("trace") if isinstance(request.get("trace"), Mapping) else None,
"retry_of": request.get("retry_of"),
},
),
enqueue_delivery=False,
)
return True
def build_runtime_notification_registry(settings: object) -> object | None:
try:
raw_enabled_modules = load_startup_enabled_modules(str(getattr(settings, "enabled_modules", "") or ""))
candidate_modules = startup_candidate_module_ids(str(getattr(settings, "enabled_modules", "") or ""), raw_enabled_modules)
available_modules = available_module_manifests(enabled_modules=candidate_modules, ignore_load_errors=True)
enabled_modules = load_startup_enabled_modules(str(getattr(settings, "enabled_modules", "") or ""), available=available_modules)
if "notifications" not in enabled_modules:
return None
registry = build_platform_registry(enabled_modules)
registry.configure_capability_context(ModuleContext(registry=registry, settings=settings))
return registry
except Exception: # noqa: BLE001 - notification bridge must not block installer work.
return None
def installer_notification_subject(event_kind: str, request: Mapping[str, object]) -> str:
request_id = str(request.get("request_id") or "unknown")
status = str(request.get("status") or event_kind.rsplit(".", 1)[-1])
prefixes = {
"queued": "Module installer request queued",
"running": "Module installer request started",
"completed": "Module installer request completed",
"failed": "Module installer request failed",
"cancelled": "Module installer request cancelled",
}
prefix = prefixes.get(status, "Module installer request updated")
return ": ".join((prefix, request_id))
def _installer_status_sentence(request_id: str, status: str) -> str:
return " ".join(("Installer request", request_id, "is", status))
def installer_notification_body(event_kind: str, request: Mapping[str, object]) -> str:
status = str(request.get("status") or event_kind.rsplit(".", 1)[-1])
request_id = str(request.get("request_id") or "unknown")
result = request.get("result") if isinstance(request.get("result"), Mapping) else {}
error = str(request.get("error") or result.get("error") or "").strip()
sentence = _installer_status_sentence(request_id, status)
if error:
return ". Error: ".join((sentence, error))
run_id = _result_run_id(request)
if run_id:
return ". Run: ".join((sentence, run_id))
# This helper returns plain notification text, not an HTTP/HTML response.
return sentence + "." # nosemgrep: python.flask.security.audit.directly-returned-format-string.directly-returned-format-string
def installer_notification_priority(status: str) -> int:
if status == "failed":
return 20
if status in {"completed", "cancelled"}:
return 8
if status == "running":
return 4
return 5
def _result_run_id(request: Mapping[str, object]) -> str | None:
result = request.get("result") if isinstance(request.get("result"), Mapping) else {}
run_id: Any = result.get("run_id") if isinstance(result, Mapping) else None
return str(run_id) if run_id else None

View File

@@ -6,13 +6,11 @@ from datetime import UTC, datetime
import json import json
import os import os
from pathlib import Path from pathlib import Path
from typing import Any
import urllib.error
import urllib.request
from cryptography.exceptions import InvalidSignature from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from govoplan_core.security.http_fetch import fetch_http_text
def module_license_decision(required_features: list[str] | tuple[str, ...]) -> dict[str, object]: def module_license_decision(required_features: list[str] | tuple[str, ...]) -> dict[str, object]:
@@ -257,17 +255,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
def _read_trusted_keys_url(url: str) -> str: def _read_trusted_keys_url(url: str) -> str:
if not url.startswith(("https://", "http://")):
raise ValueError("Trusted license key URL must use http:// or https://.")
cache_path = _configured_trusted_keys_cache_path() cache_path = _configured_trusted_keys_cache_path()
try: try:
with urllib.request.urlopen(url, timeout=15) as response: body = fetch_http_text(url, timeout=15, label="Trusted license key URL")
body = response.read().decode("utf-8")
if cache_path is not None: if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8") cache_path.write_text(body, encoding="utf-8")
return body return body
except (OSError, urllib.error.URLError): except OSError:
if cache_path is not None and cache_path.exists(): if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8") return cache_path.read_text(encoding="utf-8")
raise raise

View File

@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from govoplan_core.admin.models import SystemSettings from govoplan_core.admin.models import SystemSettings
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID, get_system_settings from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID, get_system_settings
from govoplan_core.core.access import DEFAULT_CAPABILITY_PROVIDERS
from govoplan_core.core.discovery import iter_module_entry_points from govoplan_core.core.discovery import iter_module_entry_points
from govoplan_core.core.modules import ModuleManifest from govoplan_core.core.modules import ModuleManifest
from govoplan_core.db.session import get_database from govoplan_core.db.session import get_database
@@ -17,8 +18,8 @@ from govoplan_core.server.registry import parse_enabled_modules
MODULE_SETTINGS_KEY = "module_management" MODULE_SETTINGS_KEY = "module_management"
INSTALL_PLAN_KEY = "install_plan" INSTALL_PLAN_KEY = "install_plan"
REQUIRED_PLATFORM_MODULES = ("access",) REQUIRED_PLATFORM_MODULES: tuple[str, ...] = ()
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin") PROTECTED_MODULES = ("admin",)
INSTALL_PLAN_ACTIONS = ("install", "update", "uninstall") INSTALL_PLAN_ACTIONS = ("install", "update", "uninstall")
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked") INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
INSTALL_PLAN_SOURCES = ("manual", "catalog") INSTALL_PLAN_SOURCES = ("manual", "catalog")
@@ -73,6 +74,23 @@ class ModuleInstallPlanItem:
return payload return payload
@dataclass(frozen=True, slots=True)
class _NormalizedModuleInstallPlanItem:
module_id: str
action: str
source: str
catalog: Mapping[str, object] | None
status: str
python_package: str | None
python_ref: str | None
webui_package: str | None
webui_ref: str | None
artifact_integrity: Mapping[str, object] | None
data_safety_acknowledged: bool
destroy_data: bool
notes: str | None
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ModuleInstallPlan: class ModuleInstallPlan:
items: tuple[ModuleInstallPlanItem, ...] = () items: tuple[ModuleInstallPlanItem, ...] = ()
@@ -92,6 +110,7 @@ def startup_candidate_module_ids(
if desired is not None: if desired is not None:
candidates.extend(str(item).strip() for item in desired if str(item).strip()) candidates.extend(str(item).strip() for item in desired if str(item).strip())
candidates.extend(REQUIRED_PLATFORM_MODULES) candidates.extend(REQUIRED_PLATFORM_MODULES)
candidates.extend(DEFAULT_CAPABILITY_PROVIDERS.values())
if "admin" in fallback: if "admin" in fallback:
candidates.append("admin") candidates.append("admin")
return tuple(dict.fromkeys(candidates)) return tuple(dict.fromkeys(candidates))
@@ -291,6 +310,28 @@ def desired_modules_after_package_plan(
def normalize_module_install_plan_item( def normalize_module_install_plan_item(
item: Mapping[str, object] | ModuleInstallPlanItem, item: Mapping[str, object] | ModuleInstallPlanItem,
) -> ModuleInstallPlanItem: ) -> ModuleInstallPlanItem:
normalized = _normalized_module_install_plan_item(item)
_validate_module_install_plan_item(normalized)
return ModuleInstallPlanItem(
module_id=normalized.module_id,
action=normalized.action,
source=normalized.source,
catalog=normalized.catalog,
python_package=normalized.python_package,
python_ref=normalized.python_ref,
webui_package=normalized.webui_package,
webui_ref=normalized.webui_ref,
artifact_integrity=normalized.artifact_integrity,
data_safety_acknowledged=normalized.data_safety_acknowledged,
destroy_data=normalized.destroy_data,
status=normalized.status,
notes=normalized.notes,
)
def _normalized_module_install_plan_item(
item: Mapping[str, object] | ModuleInstallPlanItem,
) -> _NormalizedModuleInstallPlanItem:
if isinstance(item, ModuleInstallPlanItem): if isinstance(item, ModuleInstallPlanItem):
raw = item.as_dict() raw = item.as_dict()
elif isinstance(item, Mapping): elif isinstance(item, Mapping):
@@ -311,33 +352,12 @@ def normalize_module_install_plan_item(
data_safety_acknowledged = _clean_bool(raw.get("data_safety_acknowledged")) data_safety_acknowledged = _clean_bool(raw.get("data_safety_acknowledged"))
destroy_data = _clean_bool(raw.get("destroy_data")) destroy_data = _clean_bool(raw.get("destroy_data"))
notes = _clean_optional_string(raw.get("notes")) notes = _clean_optional_string(raw.get("notes"))
return _NormalizedModuleInstallPlanItem(
if action not in INSTALL_PLAN_ACTIONS:
raise ModuleManagementError(f"Unsupported install plan action for {module_id!r}: {action!r}.")
if status not in INSTALL_PLAN_STATUSES:
raise ModuleManagementError(f"Unsupported install plan status for {module_id!r}: {status!r}.")
if source not in INSTALL_PLAN_SOURCES:
raise ModuleManagementError(f"Unsupported install plan source for {module_id!r}: {source!r}.")
if action in {"install", "update"} and not python_ref:
raise ModuleManagementError(f"Install plan item {module_id!r} needs a Python package reference.")
if action == "uninstall" and not python_package:
raise ModuleManagementError(f"Uninstall plan item {module_id!r} needs a Python package name.")
if action != "uninstall" and destroy_data:
raise ModuleManagementError(f"Install plan item {module_id!r} can only destroy data during uninstall.")
if action in {"install", "update"} and bool(webui_package) != bool(webui_ref):
raise ModuleManagementError(f"Install plan item {module_id!r} needs both WebUI package and WebUI reference, or neither.")
if action == "uninstall" and webui_ref and not webui_package:
raise ModuleManagementError(f"Uninstall plan item {module_id!r} has a WebUI reference but no WebUI package.")
if python_ref:
_validate_dependency_ref(python_ref, field="python_ref", module_id=module_id)
if webui_ref:
_validate_dependency_ref(webui_ref, field="webui_ref", module_id=module_id)
return ModuleInstallPlanItem(
module_id=module_id, module_id=module_id,
action=action, action=action,
source=source, source=source,
catalog=catalog, catalog=catalog,
status=status,
python_package=python_package, python_package=python_package,
python_ref=python_ref, python_ref=python_ref,
webui_package=webui_package, webui_package=webui_package,
@@ -345,57 +365,189 @@ def normalize_module_install_plan_item(
artifact_integrity=artifact_integrity, artifact_integrity=artifact_integrity,
data_safety_acknowledged=data_safety_acknowledged, data_safety_acknowledged=data_safety_acknowledged,
destroy_data=destroy_data, destroy_data=destroy_data,
status=status,
notes=notes, notes=notes,
) )
def _validate_module_install_plan_item(item: _NormalizedModuleInstallPlanItem) -> None:
if item.action not in INSTALL_PLAN_ACTIONS:
raise ModuleManagementError(f"Unsupported install plan action for {item.module_id!r}: {item.action!r}.")
if item.status not in INSTALL_PLAN_STATUSES:
raise ModuleManagementError(f"Unsupported install plan status for {item.module_id!r}: {item.status!r}.")
if item.source not in INSTALL_PLAN_SOURCES:
raise ModuleManagementError(f"Unsupported install plan source for {item.module_id!r}: {item.source!r}.")
_validate_module_install_plan_item_requirements(item)
_validate_module_install_plan_item_refs(item)
def _validate_module_install_plan_item_requirements(item: _NormalizedModuleInstallPlanItem) -> None:
if item.action in {"install", "update"} and not item.python_ref:
raise ModuleManagementError(f"Install plan item {item.module_id!r} needs a Python package reference.")
if item.action == "uninstall" and not item.python_package:
raise ModuleManagementError(f"Uninstall plan item {item.module_id!r} needs a Python package name.")
if item.action != "uninstall" and item.destroy_data:
raise ModuleManagementError(f"Install plan item {item.module_id!r} can only destroy data during uninstall.")
if item.action in {"install", "update"} and bool(item.webui_package) != bool(item.webui_ref):
raise ModuleManagementError(f"Install plan item {item.module_id!r} needs both WebUI package and WebUI reference, or neither.")
if item.action == "uninstall" and item.webui_ref and not item.webui_package:
raise ModuleManagementError(f"Uninstall plan item {item.module_id!r} has a WebUI reference but no WebUI package.")
def _validate_module_install_plan_item_refs(item: _NormalizedModuleInstallPlanItem) -> None:
if item.python_ref:
_validate_dependency_ref(item.python_ref, field="python_ref", module_id=item.module_id)
if item.webui_ref:
_validate_dependency_ref(item.webui_ref, field="webui_ref", module_id=item.module_id)
def plan_desired_enabled_modules( def plan_desired_enabled_modules(
requested_enabled: Iterable[str], requested_enabled: Iterable[str],
available: Mapping[str, ModuleManifest], available: Mapping[str, ModuleManifest],
*, *,
protected_modules: Iterable[str] = PROTECTED_MODULES, protected_modules: Iterable[str] = PROTECTED_MODULES,
) -> ModuleStatePlan: ) -> ModuleStatePlan:
requested = {str(item).strip() for item in requested_enabled if str(item).strip()} requested = _normalized_module_ids(requested_enabled)
protected = {str(item).strip() for item in protected_modules if str(item).strip()} requested.update(_normalized_module_ids(protected_modules))
requested.update(protected)
missing = sorted(module_id for module_id in requested if module_id not in available) missing = sorted(module_id for module_id in requested if module_id not in available)
if missing: if missing:
raise ModuleManagementError("Unknown or uninstalled modules: " + ", ".join(missing)) raise ModuleManagementError("Unknown or uninstalled modules: " + ", ".join(missing))
added_dependencies: set[str] = set() added_dependencies = _expand_enabled_module_closure(requested, available)
visiting: set[str] = set() ordered = _order_enabled_modules(requested, available, added_dependencies)
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)
return ModuleStatePlan( return ModuleStatePlan(
enabled_modules=tuple(dict.fromkeys(ordered)), enabled_modules=tuple(dict.fromkeys(ordered)),
added_dependencies=tuple(sorted(added_dependencies)), 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, ...]]: def module_dependents(available: Mapping[str, ModuleManifest]) -> dict[str, tuple[str, ...]]:
dependents: dict[str, list[str]] = {module_id: [] for module_id in available} dependents: dict[str, list[str]] = {module_id: [] for module_id in available}
for manifest in available.values(): for manifest in available.values():

View File

@@ -3,20 +3,20 @@ from __future__ import annotations
import base64 import base64
import binascii import binascii
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
import json import json
import os import os
import re import re
from typing import Any from typing import Any
import urllib.error
import urllib.request
from cryptography.exceptions import InvalidSignature from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
from govoplan_core.security.http_fetch import fetch_http_text, is_http_url
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$") _INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
CATALOG_MIGRATION_SAFETY = ("automatic", "requires_review", "forward_only", "destructive") CATALOG_MIGRATION_SAFETY = ("automatic", "requires_review", "forward_only", "destructive")
@@ -28,6 +28,20 @@ CATALOG_MIGRATION_TASK_PHASES = (
) )
@dataclass(frozen=True, slots=True)
class _CatalogValidationState:
modules: tuple[dict[str, object], ...]
channel: str | None
sequence: int | None
generated_at: str | None
not_before: str | None
expires_at: str | None
signature_state: dict[str, object]
freshness: dict[str, object]
replay: dict[str, object]
read_state: dict[str, object]
def module_package_catalog( def module_package_catalog(
path: Path | str | None = None, path: Path | str | None = None,
*, *,
@@ -61,178 +75,143 @@ def validate_module_package_catalog(
effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels effective_approved_channels = _configured_approved_channels() if approved_channels is None else approved_channels
effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys() effective_trusted_keys = trusted_keys if trusted_keys is not None else _configured_trusted_keys()
if catalog_source is not None and not _catalog_source_exists(catalog_source): if catalog_source is not None and not _catalog_source_exists(catalog_source):
return { return _catalog_error_result(catalog_source, error=f"Module package catalog does not exist: {catalog_source}")
"valid": False,
"configured": True,
"path": str(catalog_source),
"source": str(catalog_source),
"source_type": _catalog_source_type(catalog_source),
"cache_used": False,
"cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() is not None else None,
"modules": [],
"channel": None,
"sequence": None,
"generated_at": None,
"not_before": None,
"expires_at": None,
"signed": False,
"trusted": False,
"key_id": None,
"warnings": [],
"error": f"Module package catalog does not exist: {catalog_source}",
}
warnings: list[str] = []
read_state = {"cache_used": False, "cache_path": str(_configured_catalog_cache_path()) if _configured_catalog_cache_path() is not None else None}
try: try:
payload, read_state = _read_catalog_payload_with_metadata(catalog_source) state = _catalog_validation_state(catalog_source, trusted_keys=effective_trusted_keys)
modules = _normalize_catalog_modules(payload)
channel = _catalog_channel(payload)
sequence = _catalog_sequence(payload)
generated_at = _catalog_optional_text(payload, "generated_at")
not_before = _catalog_optional_text(payload, "not_before")
expires_at = _catalog_optional_text(payload, "expires_at")
signature_state = _catalog_signature_state(payload, trusted_keys=effective_trusted_keys)
freshness = _catalog_freshness_state(payload)
replay = _catalog_replay_state(channel=channel, sequence=sequence)
except Exception as exc: except Exception as exc:
return { return _catalog_error_result(catalog_source, error=str(exc))
"valid": False, policy_error = _catalog_policy_error(
"configured": catalog_source is not None, catalog_source,
"path": str(catalog_source) if catalog_source is not None else None, state,
"source": str(catalog_source) if catalog_source is not None else None, require_trusted=effective_require_trusted,
"source_type": _catalog_source_type(catalog_source), approved_channels=effective_approved_channels,
"cache_used": bool(read_state.get("cache_used")), )
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None, if policy_error is not None:
"modules": [], return policy_error
"channel": None, return _valid_catalog_result(catalog_source, state)
"sequence": None,
"generated_at": None,
"not_before": None, def _catalog_validation_state(
"expires_at": None, source: Path | str | None,
"signed": False, *,
"trusted": False, trusted_keys: dict[str, str],
"key_id": None, ) -> _CatalogValidationState:
"warnings": [], payload, read_state = _read_catalog_payload_with_metadata(source)
"error": str(exc), modules = _normalize_catalog_modules(payload)
} channel = _catalog_channel(payload)
if signature_state.get("fatal"): sequence = _catalog_sequence(payload)
return { return _CatalogValidationState(
"valid": False, modules=modules,
"configured": catalog_source is not None, channel=channel,
"path": str(catalog_source) if catalog_source is not None else None, sequence=sequence,
"source": str(catalog_source) if catalog_source is not None else None, generated_at=_catalog_optional_text(payload, "generated_at"),
"source_type": _catalog_source_type(catalog_source), not_before=_catalog_optional_text(payload, "not_before"),
"cache_used": bool(read_state.get("cache_used")), expires_at=_catalog_optional_text(payload, "expires_at"),
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None, signature_state=_catalog_signature_state(payload, trusted_keys=trusted_keys),
"modules": [], freshness=_catalog_freshness_state(payload),
"channel": channel, replay=_catalog_replay_state(channel=channel, sequence=sequence),
"sequence": sequence, read_state=read_state,
"generated_at": generated_at, )
"not_before": not_before,
"expires_at": expires_at,
"signed": signature_state["signed"], def _catalog_policy_error(
"trusted": signature_state["trusted"], source: Path | str | None,
"key_id": signature_state["key_id"], state: _CatalogValidationState,
"warnings": [], *,
"error": str(signature_state["error"] or "Module package catalog signature is invalid."), require_trusted: bool,
} approved_channels: tuple[str, ...],
if effective_approved_channels and channel not in effective_approved_channels: ) -> dict[str, object] | None:
return { if state.signature_state.get("fatal"):
"valid": False, return _invalid_catalog_state_result(source, state, error=str(state.signature_state["error"] or "Module package catalog signature is invalid."))
"configured": catalog_source is not None, if approved_channels and state.channel not in approved_channels:
"path": str(catalog_source) if catalog_source is not None else None, return _invalid_catalog_state_result(
"source": str(catalog_source) if catalog_source is not None else None, source,
"source_type": _catalog_source_type(catalog_source), state,
"cache_used": bool(read_state.get("cache_used")), error=f"Module package catalog channel {state.channel!r} is not approved. Approved channels: {', '.join(approved_channels)}.",
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
"modules": [],
"channel": channel,
"sequence": sequence,
"generated_at": generated_at,
"not_before": not_before,
"expires_at": expires_at,
"signed": signature_state["signed"],
"trusted": signature_state["trusted"],
"key_id": signature_state["key_id"],
"warnings": [],
"error": f"Module package catalog channel {channel!r} is not approved. Approved channels: {', '.join(effective_approved_channels)}.",
}
if effective_require_trusted and not signature_state["trusted"]:
return {
"valid": False,
"configured": catalog_source is not None,
"path": str(catalog_source) if catalog_source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None,
"source_type": _catalog_source_type(catalog_source),
"cache_used": bool(read_state.get("cache_used")),
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
"modules": [],
"channel": channel,
"sequence": sequence,
"generated_at": generated_at,
"not_before": not_before,
"expires_at": expires_at,
"signed": signature_state["signed"],
"trusted": False,
"key_id": signature_state["key_id"],
"warnings": [],
"error": str(signature_state["error"] or "Module package catalog must be signed by a trusted key."),
}
if not freshness["valid"]:
return _invalid_catalog_result(
catalog_source,
modules=(),
channel=channel,
sequence=sequence,
generated_at=generated_at,
not_before=not_before,
expires_at=expires_at,
signature_state=signature_state,
read_state=read_state,
error=str(freshness["error"]),
) )
if not replay["valid"]: if require_trusted and not state.signature_state["trusted"]:
return _invalid_catalog_result( return _invalid_catalog_state_result(source, state, error=str(state.signature_state["error"] or "Module package catalog must be signed by a trusted key."))
catalog_source, if not state.freshness["valid"]:
modules=(), return _invalid_catalog_state_result(source, state, error=str(state.freshness["error"]))
channel=channel, if not state.replay["valid"]:
sequence=sequence, return _invalid_catalog_state_result(source, state, error=str(state.replay["error"]))
generated_at=generated_at, return None
not_before=not_before,
expires_at=expires_at,
signature_state=signature_state, def _catalog_error_result(source: Path | str | None, *, error: str) -> dict[str, object]:
read_state=read_state, return _invalid_catalog_result(
error=str(replay["error"]), source,
) modules=(),
warnings.extend(str(item) for item in freshness.get("warnings", ()) if item) channel=None,
warnings.extend(str(item) for item in replay.get("warnings", ()) if item) sequence=None,
warnings.extend(_catalog_interface_warnings(modules)) generated_at=None,
if not signature_state["signed"]: not_before=None,
warnings.append("Catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.") expires_at=None,
elif not signature_state["trusted"]: signature_state=_unsigned_catalog_signature_state(),
warnings.append(str(signature_state["error"] or "Catalog signature could not be verified against a trusted key.")) read_state=_default_catalog_read_state(),
error=error,
)
def _invalid_catalog_state_result(source: Path | str | None, state: _CatalogValidationState, *, error: str) -> dict[str, object]:
return _invalid_catalog_result(
source,
modules=(),
channel=state.channel,
sequence=state.sequence,
generated_at=state.generated_at,
not_before=state.not_before,
expires_at=state.expires_at,
signature_state=state.signature_state,
read_state=state.read_state,
error=error,
)
def _valid_catalog_result(source: Path | str | None, state: _CatalogValidationState) -> dict[str, object]:
return { return {
"valid": True, "valid": True,
"configured": catalog_source is not None and _catalog_source_exists(catalog_source), "configured": source is not None and _catalog_source_exists(source),
"path": str(catalog_source) if catalog_source is not None else None, "path": str(source) if source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None, "source": str(source) if source is not None else None,
"source_type": _catalog_source_type(catalog_source), "source_type": _catalog_source_type(source),
"cache_used": bool(read_state.get("cache_used")), "cache_used": bool(state.read_state.get("cache_used")),
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None, "cache_path": state.read_state.get("cache_path") if isinstance(state.read_state.get("cache_path"), str) else None,
"modules": list(modules), "modules": list(state.modules),
"channel": channel, "channel": state.channel,
"sequence": sequence, "sequence": state.sequence,
"generated_at": generated_at, "generated_at": state.generated_at,
"not_before": not_before, "not_before": state.not_before,
"expires_at": expires_at, "expires_at": state.expires_at,
"signed": signature_state["signed"], "signed": state.signature_state["signed"],
"trusted": signature_state["trusted"], "trusted": state.signature_state["trusted"],
"key_id": signature_state["key_id"], "key_id": state.signature_state["key_id"],
"warnings": warnings, "warnings": _catalog_validation_warnings(state),
"error": None, "error": None,
} }
def _catalog_validation_warnings(state: _CatalogValidationState) -> list[str]:
warnings: list[str] = []
warnings.extend(str(item) for item in state.freshness.get("warnings", ()) if item)
warnings.extend(str(item) for item in state.replay.get("warnings", ()) if item)
warnings.extend(_catalog_interface_warnings(state.modules))
if not state.signature_state["signed"]:
warnings.append("Catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
elif not state.signature_state["trusted"]:
warnings.append(str(state.signature_state["error"] or "Catalog signature could not be verified against a trusted key."))
return warnings
def _default_catalog_read_state() -> dict[str, object]:
cache_path = _configured_catalog_cache_path()
return {"cache_used": False, "cache_path": str(cache_path) if cache_path is not None else None}
def _unsigned_catalog_signature_state() -> dict[str, object]:
return {"signed": False, "trusted": False, "key_id": None}
def sign_module_package_catalog( def sign_module_package_catalog(
*, *,
path: Path, path: Path,
@@ -350,17 +329,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
def _read_trusted_keys_url(url: str) -> str: def _read_trusted_keys_url(url: str) -> str:
if not _is_http_url(url):
raise ValueError("Trusted catalog key URL must use http:// or https://.")
cache_path = _configured_trusted_keys_cache_path() cache_path = _configured_trusted_keys_cache_path()
try: try:
with urllib.request.urlopen(url, timeout=15) as response: body = fetch_http_text(url, timeout=15, label="Trusted catalog key URL")
body = response.read().decode("utf-8")
if cache_path is not None: if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8") cache_path.write_text(body, encoding="utf-8")
return body return body
except (OSError, urllib.error.URLError): except OSError:
if cache_path is not None and cache_path.exists(): if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8") return cache_path.read_text(encoding="utf-8")
raise raise
@@ -421,13 +397,12 @@ def _read_catalog_url(url: str) -> str:
def _read_catalog_url_with_metadata(url: str) -> tuple[str, bool]: def _read_catalog_url_with_metadata(url: str) -> tuple[str, bool]:
cache_path = _configured_catalog_cache_path() cache_path = _configured_catalog_cache_path()
try: try:
with urllib.request.urlopen(url, timeout=15) as response: body = fetch_http_text(url, timeout=15, label="Module package catalog URL")
body = response.read().decode("utf-8")
if cache_path is not None: if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8") cache_path.write_text(body, encoding="utf-8")
return body, False return body, False
except (OSError, urllib.error.URLError): except OSError:
if cache_path is not None and cache_path.exists(): if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8"), True return cache_path.read_text(encoding="utf-8"), True
raise raise
@@ -935,7 +910,7 @@ def _catalog_source_type(source: Path | str | None) -> str | None:
def _is_http_url(value: str) -> bool: def _is_http_url(value: str) -> bool:
return value.startswith(("https://", "http://")) return is_http_url(value)
def _invalid_catalog_result( def _invalid_catalog_result(

View File

@@ -4,8 +4,14 @@ from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Literal, Protocol, TYPE_CHECKING from typing import Any, Literal, Protocol, TYPE_CHECKING
from govoplan_core.core.views import ViewSurface
if TYPE_CHECKING: if TYPE_CHECKING:
from fastapi import APIRouter from fastapi import APIRouter
from govoplan_core.core.search import (
SearchProviderRegistration,
SearchSourceProviderRegistration,
)
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1" SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
@@ -45,6 +51,7 @@ class RoleTemplate:
level: PermissionLevel = "tenant" level: PermissionLevel = "tenant"
managed: bool = True managed: bool = True
protected: bool = False protected: bool = False
default_authenticated: bool = False
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -56,6 +63,7 @@ class NavItem:
required_all: tuple[str, ...] = () required_all: tuple[str, ...] = ()
required_any: tuple[str, ...] = () required_any: tuple[str, ...] = ()
order: int = 100 order: int = 100
surface_id: str | None = None
@@ -67,6 +75,16 @@ class FrontendRoute:
required_all: tuple[str, ...] = () required_all: tuple[str, ...] = ()
required_any: tuple[str, ...] = () required_any: tuple[str, ...] = ()
order: int = 100 order: int = 100
surface_id: str | None = None
@dataclass(frozen=True, slots=True)
class PublicFrontendRoute:
"""Explicitly allowlisted route that can render without authentication."""
path: str
component: str
order: int = 100
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -79,8 +97,10 @@ class FrontendModule:
asset_manifest_integrity: str | None = None asset_manifest_integrity: str | None = None
asset_manifest_contract_version: str = "1" asset_manifest_contract_version: str = "1"
routes: tuple[FrontendRoute, ...] = () routes: tuple[FrontendRoute, ...] = ()
public_routes: tuple[PublicFrontendRoute, ...] = ()
nav_items: tuple[NavItem, ...] = () nav_items: tuple[NavItem, ...] = ()
settings_routes: tuple[FrontendRoute, ...] = () settings_routes: tuple[FrontendRoute, ...] = ()
view_surfaces: tuple[ViewSurface, ...] = ()
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -236,6 +256,35 @@ class DocumentationTopic:
metadata: Mapping[str, Any] = field(default_factory=dict) metadata: Mapping[str, Any] = field(default_factory=dict)
def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str, ...]:
"""Return fail-closed authoring issues for a user-facing workflow topic.
Topic conditions are alternatives. Every alternative therefore needs an
explicit scope constraint; otherwise one unscoped alternative would make
the workflow visible regardless of the actor's task authority.
"""
raw_kind = topic.metadata.get("kind")
kind = raw_kind.strip().lower().replace("_", "-") if isinstance(raw_kind, str) else ""
if kind != "workflow" or "user" not in topic.documentation_types:
return ()
if not topic.conditions:
return ("user workflow topics must declare at least one scope-conditioned alternative",)
unscoped_alternatives = tuple(
index
for index, condition in enumerate(topic.conditions, start=1)
if not any(scope.strip() for scope in (*condition.required_scopes, *condition.any_scopes))
)
if not unscoped_alternatives:
return ()
alternatives = ", ".join(str(index) for index in unscoped_alternatives)
return (
"every user workflow condition alternative must declare required_scopes or any_scopes; "
f"unscoped alternative(s): {alternatives}",
)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class DocumentationContext: class DocumentationContext:
registry: object registry: object
@@ -261,6 +310,10 @@ class ResourceAclProvider(Protocol):
TenantSummaryProvider = Callable[[object, str], Mapping[str, int]] TenantSummaryProvider = Callable[[object, str], Mapping[str, int]]
TenantSummaryBatchProvider = Callable[
[object, Sequence[str]],
Mapping[str, Mapping[str, int]],
]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -308,9 +361,12 @@ class ModuleManifest:
frontend: FrontendModule | None = None frontend: FrontendModule | None = None
resource_acl_providers: tuple[ResourceAclProvider, ...] = () resource_acl_providers: tuple[ResourceAclProvider, ...] = ()
tenant_summary_providers: tuple[TenantSummaryProvider, ...] = () tenant_summary_providers: tuple[TenantSummaryProvider, ...] = ()
tenant_summary_batch_providers: tuple[TenantSummaryBatchProvider, ...] = ()
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict) delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = () uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = ()
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict) capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
search_providers: tuple["SearchProviderRegistration", ...] = ()
search_sources: tuple["SearchSourceProviderRegistration", ...] = ()
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility) compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
on_activate: LifecycleHook | None = None on_activate: LifecycleHook | None = None
on_deactivate: LifecycleHook | None = None on_deactivate: LifecycleHook | None = None

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
CAPABILITY_NOTIFICATIONS_DISPATCH = "notifications.dispatch"
@dataclass(frozen=True, slots=True)
class NotificationDispatchRequest:
tenant_id: str
source_module: str
source_resource_type: str
source_resource_id: str | None
event_kind: str
channel: str = "inbox"
recipient: str | None = None
recipient_type: str | None = None
recipient_id: str | None = None
recipient_label: str | None = None
subject: str | None = None
body_text: str | None = None
body_html: str | None = None
action_url: str | None = None
priority: int = 0
not_before_at: datetime | None = None
payload: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class NotificationDispatchProvider(Protocol):
def enqueue_notification(
self,
session: object,
request: NotificationDispatchRequest,
*,
enqueue_delivery: bool = True,
) -> Mapping[str, object]:
...
def deliver_notification(self, session: object, *, notification_id: str) -> Mapping[str, object]:
...
def deliver_pending(
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 50,
) -> Mapping[str, object]:
...
def notification_dispatch_provider(registry: object | None) -> NotificationDispatchProvider | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_NOTIFICATIONS_DISPATCH):
return None
capability = registry.capability(CAPABILITY_NOTIFICATIONS_DISPATCH)
return capability if isinstance(capability, NotificationDispatchProvider) else None

View File

@@ -2,13 +2,57 @@ from __future__ import annotations
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable from typing import Literal, Protocol, runtime_checkable
ORGANIZATIONS_MODULE_ID = "organizations" ORGANIZATIONS_MODULE_ID = "organizations"
CAPABILITY_ORGANIZATION_DIRECTORY = "organizations.directory" CAPABILITY_ORGANIZATION_DIRECTORY = "organizations.directory"
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY = (
"organizations.hierarchyDirectory"
)
OrganizationStatus = Literal["active", "inactive", "suspended"] OrganizationStatus = Literal["active", "inactive", "suspended"]
OrganizationResolutionStatus = Literal[
"active",
"inactive",
"missing",
"unreachable",
"invalid",
]
OrganizationHierarchyDirection = Literal["ancestors", "descendants"]
OrganizationLifecycleResource = Literal[
"unit_type",
"structure",
"relation_type",
"unit",
"relation",
"function_type",
"function",
]
OrganizationLifecycleAction = Literal[
"created",
"updated",
"moved",
"deactivated",
]
ORGANIZATION_LIFECYCLE_EVENT_SCHEMA_VERSION = 1
ORGANIZATION_LIFECYCLE_RESOURCES: tuple[
OrganizationLifecycleResource,
...,
] = (
"unit_type",
"structure",
"relation_type",
"unit",
"relation",
"function_type",
"function",
)
ORGANIZATION_LIFECYCLE_ACTIONS: tuple[
OrganizationLifecycleAction,
...,
] = ("created", "updated", "moved", "deactivated")
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -37,6 +81,141 @@ class OrganizationFunctionRef:
status: OrganizationStatus = "active" status: OrganizationStatus = "active"
@dataclass(frozen=True, slots=True)
class OrganizationUnitTypeRef:
id: str
tenant_id: str
slug: str
name: str
description: str | None = None
status: OrganizationStatus = "active"
@dataclass(frozen=True, slots=True)
class OrganizationFunctionTypeRef:
id: str
tenant_id: str
slug: str
name: str
organization_unit_type_id: str | None = None
description: str | None = None
delegable: bool = False
act_in_place_allowed: bool = False
status: OrganizationStatus = "active"
@dataclass(frozen=True, slots=True)
class OrganizationStructureRef:
id: str
tenant_id: str
slug: str
name: str
structure_kind: str
description: str | None = None
status: OrganizationStatus = "active"
@dataclass(frozen=True, slots=True)
class OrganizationRelationTypeRef:
id: str
tenant_id: str
slug: str
name: str
structure_id: str | None = None
source_unit_type_id: str | None = None
target_unit_type_id: str | None = None
is_hierarchical: bool = True
allow_cycles: bool = False
description: str | None = None
status: OrganizationStatus = "active"
@dataclass(frozen=True, slots=True)
class OrganizationHierarchyCatalogRef:
tenant_id: str
structures: tuple[OrganizationStructureRef, ...] = ()
relation_types: tuple[OrganizationRelationTypeRef, ...] = ()
@dataclass(frozen=True, slots=True)
class OrganizationHierarchyEdgeRef:
id: str
tenant_id: str
structure: OrganizationStructureRef
relation_type: OrganizationRelationTypeRef
source_unit_id: str
target_unit_id: str
valid_from: datetime | None = None
valid_until: datetime | None = None
status: OrganizationStatus = "active"
@dataclass(frozen=True, slots=True)
class OrganizationHierarchyMatchRef:
unit: OrganizationUnitRef
depth: int
path: tuple[OrganizationHierarchyEdgeRef, ...]
@dataclass(frozen=True, slots=True)
class OrganizationHierarchyResolution:
tenant_id: str
root_unit_id: str
direction: OrganizationHierarchyDirection
structure_id: str
relation_type_ids: tuple[str, ...]
max_depth: int
status: OrganizationResolutionStatus
root: OrganizationUnitRef | None = None
matches: tuple[OrganizationHierarchyMatchRef, ...] = ()
cycle_detected: bool = False
depth_limited: bool = False
diagnostics: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class OrganizationHierarchyPathResolution:
tenant_id: str
source_unit_id: str
target_unit_id: str
direction: OrganizationHierarchyDirection
structure_id: str
relation_type_ids: tuple[str, ...]
max_depth: int
status: OrganizationResolutionStatus
source: OrganizationUnitRef | None = None
target: OrganizationUnitRef | None = None
path: tuple[OrganizationHierarchyEdgeRef, ...] = ()
cycle_detected: bool = False
depth_limited: bool = False
diagnostics: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class OrganizationFunctionTypeResolution:
tenant_id: str
function_type_id: str
requested_unit_ids: tuple[str, ...]
status: OrganizationResolutionStatus
function_type: OrganizationFunctionTypeRef | None = None
matches: tuple[OrganizationFunctionRef, ...] = ()
missing_unit_ids: tuple[str, ...] = ()
inactive_unit_ids: tuple[str, ...] = ()
diagnostics: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class OrganizationUnitTypeResolution:
tenant_id: str
unit_type_id: str
status: OrganizationResolutionStatus
unit_type: OrganizationUnitTypeRef | None = None
structure_id: str | None = None
root_unit_id: str | None = None
matches: tuple[OrganizationUnitRef, ...] = ()
diagnostics: tuple[str, ...] = ()
@runtime_checkable @runtime_checkable
class OrganizationDirectory(Protocol): class OrganizationDirectory(Protocol):
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None: def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
@@ -55,3 +234,156 @@ class OrganizationDirectory(Protocol):
include_subunits: bool = False, include_subunits: bool = False,
) -> Sequence[OrganizationFunctionRef]: ) -> Sequence[OrganizationFunctionRef]:
... ...
@runtime_checkable
class OrganizationHierarchyDirectory(Protocol):
def hierarchy_catalog(
self,
tenant_id: str,
) -> OrganizationHierarchyCatalogRef:
...
def get_unit_type(
self,
tenant_id: str,
unit_type_id: str,
) -> OrganizationUnitTypeRef | None:
...
def get_function_type(
self,
tenant_id: str,
function_type_id: str,
) -> OrganizationFunctionTypeRef | None:
...
def resolve_functions_by_type(
self,
tenant_id: str,
function_type_id: str,
*,
organization_unit_ids: Sequence[str] = (),
) -> OrganizationFunctionTypeResolution:
...
def resolve_units_by_type(
self,
tenant_id: str,
unit_type_id: str,
*,
structure_id: str | None = None,
root_unit_id: str | None = None,
relation_type_ids: Sequence[str] = (),
direction: OrganizationHierarchyDirection = "descendants",
max_depth: int = 10,
) -> OrganizationUnitTypeResolution:
...
def resolve_hierarchy_relatives(
self,
tenant_id: str,
organization_unit_ids: Sequence[str],
*,
structure_id: str,
relation_type_ids: Sequence[str] = (),
direction: OrganizationHierarchyDirection = "ancestors",
max_depth: int = 10,
) -> Sequence[OrganizationHierarchyResolution]:
...
def resolve_hierarchy_paths(
self,
tenant_id: str,
unit_pairs: Sequence[tuple[str, str]],
*,
structure_id: str,
relation_type_ids: Sequence[str] = (),
direction: OrganizationHierarchyDirection = "descendants",
max_depth: int = 10,
) -> Sequence[OrganizationHierarchyPathResolution]:
...
def organization_directory(
registry: object | None,
) -> OrganizationDirectory | None:
capability = _capability(registry, CAPABILITY_ORGANIZATION_DIRECTORY)
return capability if isinstance(capability, OrganizationDirectory) else None
def organization_hierarchy_directory(
registry: object | None,
) -> OrganizationHierarchyDirectory | None:
capability = _capability(
registry,
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY,
)
return (
capability
if isinstance(capability, OrganizationHierarchyDirectory)
else None
)
def organization_lifecycle_event_type(
resource: OrganizationLifecycleResource,
action: OrganizationLifecycleAction,
) -> str:
if resource not in ORGANIZATION_LIFECYCLE_RESOURCES:
raise ValueError("Unsupported organization lifecycle resource.")
if action not in ORGANIZATION_LIFECYCLE_ACTIONS:
raise ValueError("Unsupported organization lifecycle action.")
return f"organizations.{resource}.{action}.v1"
ORGANIZATION_DIRECTORY_INVALIDATION_EVENT_TYPES = frozenset(
organization_lifecycle_event_type(resource, action)
for resource in ORGANIZATION_LIFECYCLE_RESOURCES
for action in ORGANIZATION_LIFECYCLE_ACTIONS
)
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
__all__ = [
"CAPABILITY_ORGANIZATION_DIRECTORY",
"CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY",
"ORGANIZATION_DIRECTORY_INVALIDATION_EVENT_TYPES",
"ORGANIZATION_LIFECYCLE_ACTIONS",
"ORGANIZATION_LIFECYCLE_EVENT_SCHEMA_VERSION",
"ORGANIZATION_LIFECYCLE_RESOURCES",
"ORGANIZATIONS_MODULE_ID",
"OrganizationDirectory",
"OrganizationFunctionRef",
"OrganizationFunctionTypeRef",
"OrganizationFunctionTypeResolution",
"OrganizationHierarchyDirection",
"OrganizationHierarchyCatalogRef",
"OrganizationHierarchyDirectory",
"OrganizationHierarchyEdgeRef",
"OrganizationHierarchyMatchRef",
"OrganizationHierarchyPathResolution",
"OrganizationHierarchyResolution",
"OrganizationLifecycleAction",
"OrganizationLifecycleResource",
"OrganizationRelationTypeRef",
"OrganizationResolutionStatus",
"OrganizationStatus",
"OrganizationStructureRef",
"OrganizationUnitRef",
"OrganizationUnitTypeRef",
"OrganizationUnitTypeResolution",
"organization_directory",
"organization_hierarchy_directory",
"organization_lifecycle_event_type",
]

View File

@@ -0,0 +1,169 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable
CAPABILITY_ACCESS_PEOPLE_SEARCH = "access.people_search"
CAPABILITY_ADDRESSES_PEOPLE_SEARCH = "addresses.people_search"
# Identity search is deliberately absent here. ``identity.search`` is an
# instance-wide canonical-identity directory and has no tenant/principal
# visibility contract. Ordinary task pickers must use principal-aware search
# providers instead.
DEFAULT_PEOPLE_SEARCH_CAPABILITIES = (
CAPABILITY_ACCESS_PEOPLE_SEARCH,
CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
)
class PeopleSearchError(ValueError):
"""Stable, non-diagnostic error raised by people-search providers."""
@dataclass(frozen=True, slots=True)
class PersonSearchCandidate:
"""A policy-filtered selection target returned by a directory provider."""
selection_key: str
kind: str
reference_id: str
display_name: str
email: str | None = None
source_module: str | None = None
source_label: str | None = None
source_ref: str | None = None
source_revision: str | None = None
description: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PeopleSearchGroup:
key: str
label: str
candidates: tuple[PersonSearchCandidate, ...] = ()
@runtime_checkable
class PeopleSearchProvider(Protocol):
"""Server-side, principal-aware people/delivery-target search.
Implementations must derive visibility from ``principal`` and may only
return records the principal can discover in its active tenant and policy
context. Callers remain responsible for authorizing the task for which the
picker is used (for example, creating a scheduling request).
"""
def search_people(
self,
session: object,
principal: object,
*,
query: str,
limit: int = 25,
) -> Sequence[PeopleSearchGroup]:
...
def people_search_providers(
registry: object | None,
*,
capability_names: Sequence[str] = DEFAULT_PEOPLE_SEARCH_CAPABILITIES,
) -> tuple[PeopleSearchProvider, ...]:
if registry is None or not hasattr(registry, "has_capability") or not hasattr(registry, "capability"):
return ()
providers: list[PeopleSearchProvider] = []
for capability_name in capability_names:
if not registry.has_capability(capability_name):
continue
capability = registry.capability(capability_name)
if isinstance(capability, PeopleSearchProvider):
providers.append(capability)
return tuple(providers)
def search_visible_people(
registry: object | None,
session: object,
principal: object,
*,
query: str,
limit: int = 25,
capability_names: Sequence[str] = DEFAULT_PEOPLE_SEARCH_CAPABILITIES,
) -> tuple[PeopleSearchGroup, ...]:
"""Collect grouped results without importing optional feature modules.
``limit`` is enforced per provider so one directory cannot starve another
result group. Providers are expected to return at most that many candidates
in total. Duplicate group keys are merged and candidate selection keys are
de-duplicated while preserving provider order.
"""
normalized_query = str(query or "").strip()
normalized_limit = max(1, min(int(limit), 100))
ordered_group_keys: list[str] = []
labels: dict[str, str] = {}
candidates_by_group: dict[str, list[PersonSearchCandidate]] = {}
candidate_keys_by_group: dict[str, set[str]] = {}
for provider in people_search_providers(registry, capability_names=capability_names):
groups = provider.search_people(
session,
principal,
query=normalized_query,
limit=normalized_limit,
)
provider_candidate_count = 0
for group in groups:
if not group.key:
continue
if group.key not in candidates_by_group:
ordered_group_keys.append(group.key)
labels[group.key] = group.label
candidates_by_group[group.key] = []
candidate_keys_by_group[group.key] = set()
for candidate in group.candidates:
if provider_candidate_count >= normalized_limit:
break
if not candidate.selection_key or candidate.selection_key in candidate_keys_by_group[group.key]:
continue
candidate_keys_by_group[group.key].add(candidate.selection_key)
candidates_by_group[group.key].append(candidate)
provider_candidate_count += 1
return tuple(
PeopleSearchGroup(
key=group_key,
label=labels[group_key],
candidates=tuple(candidates_by_group[group_key]),
)
for group_key in ordered_group_keys
if candidates_by_group[group_key]
)
def person_selection_key(kind: str, reference_id: str, *, email: str | None = None) -> str:
normalized_kind = str(kind).strip().casefold()
normalized_reference = str(reference_id).strip()
normalized_email = str(email or "").strip().casefold()
if not normalized_kind or not normalized_reference:
raise ValueError("Person selection keys require a kind and reference id.")
return ":".join(part for part in (normalized_kind, normalized_reference, normalized_email) if part)
__all__ = [
"CAPABILITY_ACCESS_PEOPLE_SEARCH",
"CAPABILITY_ADDRESSES_PEOPLE_SEARCH",
"DEFAULT_PEOPLE_SEARCH_CAPABILITIES",
"PeopleSearchError",
"PeopleSearchGroup",
"PeopleSearchProvider",
"PersonSearchCandidate",
"people_search_providers",
"person_selection_key",
"search_visible_people",
]

View File

@@ -4,9 +4,24 @@ from dataclasses import dataclass, field
from typing import Any, Iterable, Literal, Mapping, Protocol, cast, runtime_checkable from typing import Any, Iterable, Literal, Mapping, Protocol, cast, runtime_checkable
from urllib.parse import quote, unquote from urllib.parse import quote, unquote
from govoplan_core.core.access import PrincipalRef
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"] PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
SchedulingParticipantVisibility = Literal["aggregates_only", "names_and_statuses"]
DefinitionScopeType = Literal["system", "tenant", "group", "user"]
DefinitionKind = Literal["flow", "template"]
DefinitionGovernanceAction = Literal[
"view",
"edit",
"run",
"reuse",
"derive",
"automate",
]
CAPABILITY_POLICY_PRIVACY_RETENTION = "policy.privacyRetention" CAPABILITY_POLICY_PRIVACY_RETENTION = "policy.privacyRetention"
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY = "policy.schedulingParticipantPrivacy"
CAPABILITY_POLICY_DEFINITION_GOVERNANCE = "policy.definitionGovernance"
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign") POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign")
@@ -126,6 +141,112 @@ class PolicyDecision:
} }
@dataclass(frozen=True, slots=True)
class DefinitionScopeRef:
scope_type: DefinitionScopeType
scope_id: str | None = None
@property
def path(self) -> str:
return policy_source_path(self.scope_type, self.scope_id)
@dataclass(frozen=True, slots=True)
class DefinitionGovernanceRequest:
module_id: str
definition_ref: str
tenant_id: str
definition_scope: DefinitionScopeRef
target_scope: DefinitionScopeRef
definition_kind: DefinitionKind
action: DefinitionGovernanceAction
actor: PrincipalRef
status: str = "draft"
inherit_to_lower_scopes: bool = False
allow_run: bool = True
allow_reuse: bool = False
allow_automation: bool = False
context: Mapping[str, Any] = field(default_factory=dict)
@runtime_checkable
class DefinitionGovernancePolicy(Protocol):
def resolve_definition_action(
self,
*,
request: DefinitionGovernanceRequest,
) -> PolicyDecision:
...
def definition_governance_policy(
registry: object | None,
) -> DefinitionGovernancePolicy | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(
CAPABILITY_POLICY_DEFINITION_GOVERNANCE
)
):
return None
capability = registry.capability(CAPABILITY_POLICY_DEFINITION_GOVERNANCE)
return (
capability
if isinstance(capability, DefinitionGovernancePolicy)
else None
)
@dataclass(frozen=True, slots=True)
class SchedulingParticipantPrivacyRequest:
"""Context for resolving what one Scheduling participant may see.
``requested_visibility`` is the Scheduling-owned configuration result. A
policy provider may preserve or reduce it, but must not broaden it. Core
deliberately defines no fallback when the optional capability is absent;
that secure default remains the responsibility of Scheduling.
"""
tenant_id: str
scheduling_request_id: str
participant_id: str
requested_visibility: SchedulingParticipantVisibility
actor_user_id: str | None = None
context: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class SchedulingParticipantPrivacyDecision:
"""Policy-resolved visibility of other participants' names and statuses."""
effective_visibility: SchedulingParticipantVisibility
reason: str | None = None
source_path: tuple[PolicySourceStep, ...] = ()
details: Mapping[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"effective_visibility": self.effective_visibility,
"reason": self.reason,
"source_path": [step.to_dict() for step in self.source_path],
"details": dict(self.details),
}
@runtime_checkable
class SchedulingParticipantPrivacyPolicy(Protocol):
"""Optional policy hook for Scheduling participant-roster disclosure."""
def resolve_scheduling_participant_visibility(
self,
session: object,
*,
request: SchedulingParticipantPrivacyRequest,
) -> SchedulingParticipantPrivacyDecision:
...
@runtime_checkable @runtime_checkable
class PrivacyRetentionService(Protocol): class PrivacyRetentionService(Protocol):
def privacy_policy_from_settings(self, *args: Any, **kwargs: Any) -> Any: def privacy_policy_from_settings(self, *args: Any, **kwargs: Any) -> Any:
@@ -160,3 +281,16 @@ class PrivacyRetentionService(Protocol):
def apply_retention_policy(self, *args: Any, **kwargs: Any) -> Any: def apply_retention_policy(self, *args: Any, **kwargs: Any) -> Any:
... ...
def scheduling_participant_privacy_policy(
registry: object | None,
) -> SchedulingParticipantPrivacyPolicy | None:
"""Return the optional provider without selecting a visibility fallback."""
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY):
return None
capability = registry.capability(CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY)
return capability if isinstance(capability, SchedulingParticipantPrivacyPolicy) else None

View 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
)

View 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",
]

View File

@@ -0,0 +1,410 @@
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"]
@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
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 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:
...
@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

View File

@@ -0,0 +1,111 @@
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from govoplan_core.core.change_sequence import (
ChangeSequenceEntry,
retained_sequence_floor,
record_change,
)
AUTH_PRINCIPAL_REVISION_COLLECTION = "core.auth-principal-revisions"
@dataclass(frozen=True, slots=True)
class AuthPrincipalRevision:
system: int
tenant: int
def auth_principal_revision(
session: Session,
*,
tenant_id: str | None,
) -> AuthPrincipalRevision:
"""Return the durable global and tenant authorization revision.
The sequence rows make invalidation visible across processes. Retention
floors keep revisions monotonic if old change rows are pruned.
"""
rows = (
session.query(
ChangeSequenceEntry.tenant_id,
func.max(ChangeSequenceEntry.id),
)
.filter(
ChangeSequenceEntry.module_id == "core",
ChangeSequenceEntry.collection == AUTH_PRINCIPAL_REVISION_COLLECTION,
or_(
ChangeSequenceEntry.tenant_id.is_(None),
ChangeSequenceEntry.tenant_id == tenant_id,
),
)
.group_by(ChangeSequenceEntry.tenant_id)
.all()
)
revisions = {row_tenant_id: int(sequence_id or 0) for row_tenant_id, sequence_id in rows}
system = max(
revisions.get(None, 0),
retained_sequence_floor(
session,
tenant_id=None,
module_id="core",
collections=(AUTH_PRINCIPAL_REVISION_COLLECTION,),
),
)
tenant = 0
if tenant_id is not None:
tenant = max(
revisions.get(tenant_id, 0),
retained_sequence_floor(
session,
tenant_id=tenant_id,
module_id="core",
collections=(AUTH_PRINCIPAL_REVISION_COLLECTION,),
),
)
return AuthPrincipalRevision(system=system, tenant=tenant)
def invalidate_auth_principals(
session: Session,
*,
tenant_id: str | None,
source_module: str,
resource_type: str,
resource_id: str,
actor_type: str | None = None,
actor_id: str | None = None,
reason: str | None = None,
) -> ChangeSequenceEntry:
"""Advance the authorization revision in the caller's transaction."""
return record_change(
session,
tenant_id=tenant_id,
module_id="core",
collection=AUTH_PRINCIPAL_REVISION_COLLECTION,
resource_type="auth_principal_revision",
resource_id=tenant_id or "system",
operation="invalidated",
actor_type=actor_type,
actor_id=actor_id,
payload={
"source_module": source_module,
"resource_type": resource_type,
"resource_id": resource_id,
**({"reason": reason} if reason else {}),
},
)
__all__ = [
"AUTH_PRINCIPAL_REVISION_COLLECTION",
"AuthPrincipalRevision",
"auth_principal_revision",
"invalidate_auth_principals",
]

View File

@@ -0,0 +1,499 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.access import (
CAPABILITY_ACCESS_DIRECTORY,
AccessDirectory,
GroupRef,
UserRef,
)
CAPABILITY_ACCESS_REFERENCE_OPTIONS = "access.reference_options"
ReferenceAvailability = Literal["available", "inactive", "unavailable"]
@dataclass(frozen=True, slots=True)
class ReferenceOption:
"""A stable identifier paired with presentation-only directory metadata."""
value: str
label: str
description: str | None = None
kind: str | None = None
availability: ReferenceAvailability = "available"
disabled: bool = False
source_module: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
def to_dict(self) -> dict[str, object]:
return {
"value": self.value,
"label": self.label,
"description": self.description,
"kind": self.kind,
"availability": self.availability,
"disabled": self.disabled,
"source_module": self.source_module,
"provenance": dict(self.provenance),
}
@dataclass(frozen=True, slots=True)
class ReferenceSearchRequest:
kind: str
tenant_id: str | None
query: str = ""
selected_values: tuple[str, ...] = ()
limit: int = 50
cursor: str | None = None
context: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ReferenceSearchPage:
options: tuple[ReferenceOption, ...] = ()
next_cursor: str | None = None
has_more: bool = False
@runtime_checkable
class ReferenceOptionProvider(Protocol):
"""Optional module-neutral provider for typed reference selectors."""
def search_reference_options(
self,
session: object,
principal: object,
*,
request: ReferenceSearchRequest,
) -> ReferenceSearchPage:
...
def reference_option_provider(
registry: object | None,
capability_name: str,
) -> ReferenceOptionProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(capability_name)
):
return None
provider = registry.capability(capability_name)
return provider if isinstance(provider, ReferenceOptionProvider) else None
def access_scope_reference_options(
registry: object | None,
principal: object,
*,
scope_type: str,
query: str = "",
selected_values: Sequence[str] = (),
limit: int = 50,
administrative: bool = False,
session: object | None = None,
reference_kind: str | None = None,
) -> tuple[ReferenceOption, ...]:
"""Return canonical user-account or group references for definition scopes."""
return access_scope_reference_page(
registry,
principal,
scope_type=scope_type,
query=query,
selected_values=selected_values,
limit=limit,
administrative=administrative,
session=session,
reference_kind=reference_kind,
).options
def access_scope_reference_page(
registry: object | None,
principal: object,
*,
scope_type: str,
query: str = "",
selected_values: Sequence[str] = (),
limit: int = 50,
cursor: str | None = None,
administrative: bool = False,
session: object | None = None,
reference_kind: str | None = None,
) -> ReferenceSearchPage:
"""Search bounded Access references while retaining selected values."""
clean_type = str(scope_type or "").strip().casefold()
if clean_type not in {"user", "group"}:
raise ValueError("Scope reference type must be user or group.")
clean_kind = str(reference_kind or clean_type).strip().casefold()
if clean_kind not in (
{"user", "membership"} if clean_type == "user" else {"group"}
):
raise ValueError("Scope reference kind is incompatible with the scope type.")
normalized_limit = max(1, min(int(limit), 200))
selected = tuple(
dict.fromkeys(
str(value).strip() for value in selected_values if str(value).strip()
)
)[:200]
tenant_id = str(getattr(principal, "tenant_id", "") or "")
provider = reference_option_provider(
registry,
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
)
if provider is not None:
if not tenant_id:
return ReferenceSearchPage(
options=tuple(
_unavailable_option(value, kind=clean_kind)
for value in selected
)
)
page = provider.search_reference_options(
session,
principal,
request=ReferenceSearchRequest(
kind=clean_kind,
tenant_id=tenant_id,
query=str(query or "").strip().casefold(),
selected_values=selected,
limit=normalized_limit,
cursor=str(cursor).strip() if cursor else None,
context={"administrative": administrative},
),
)
return ReferenceSearchPage(
options=_bounded_page_options(
page.options,
selected_values=selected,
limit=normalized_limit,
kind=clean_kind,
),
next_cursor=page.next_cursor,
has_more=page.has_more,
)
directory = _access_directory(registry)
if directory is None:
return ReferenceSearchPage(
options=_fallback_scope_options(
principal,
scope_type=clean_type,
reference_kind=clean_kind,
query=query,
selected_values=selected,
limit=normalized_limit,
)
)
if not tenant_id:
return ReferenceSearchPage(
options=tuple(
_unavailable_option(value, kind=clean_kind) for value in selected
)
)
if clean_type == "user":
candidates = _user_options(
directory.users_for_tenant(tenant_id),
principal=principal,
administrative=administrative,
value_kind=clean_kind,
)
else:
candidates = _group_options(
directory.groups_for_tenant(tenant_id),
principal=principal,
administrative=administrative,
)
return ReferenceSearchPage(
options=_filter_and_retain_options(
candidates,
query=query,
selected_values=selected,
limit=normalized_limit,
kind=clean_kind,
)
)
def access_scope_reference_provider_available(registry: object | None) -> bool:
return (
reference_option_provider(
registry,
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
)
is not None
or _access_directory(registry) is not None
)
def validate_access_scope_reference(
registry: object | None,
*,
tenant_id: str | None,
scope_type: str,
scope_id: str | None,
preserve_existing: str | None = None,
) -> str | None:
"""Validate new directory-backed scope references while preserving history."""
clean_type = str(scope_type or "").strip().casefold()
clean_id = str(scope_id or "").strip() or None
if clean_type not in {"user", "group"}:
return clean_id
if not clean_id:
raise ValueError(f"{clean_type.capitalize()} definitions require a scope ID.")
if clean_id == preserve_existing:
return clean_id
directory = _access_directory(registry)
if directory is None:
return clean_id
if not tenant_id:
raise ValueError("Directory-backed scopes require an active tenant.")
if clean_type == "group":
group = directory.get_group(clean_id)
if group is None or group.tenant_id != tenant_id:
raise ValueError("The selected group is not available in the active tenant.")
if group.status != "active":
raise ValueError("Inactive groups cannot be selected for new definitions.")
return group.id
matching_user = next(
(
user
for user in directory.users_for_tenant(tenant_id)
if user.account_id == clean_id
),
None,
)
if matching_user is None:
raise ValueError("The selected user account is not available in the active tenant.")
if matching_user.status != "active":
raise ValueError("Inactive users cannot be selected for new definitions.")
account = directory.get_account(matching_user.account_id)
if account is not None and account.status != "active":
raise ValueError("Inactive user accounts cannot be selected for new definitions.")
return matching_user.account_id
def _access_directory(registry: object | None) -> AccessDirectory | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY)
):
return None
provider = registry.capability(CAPABILITY_ACCESS_DIRECTORY)
return provider if isinstance(provider, AccessDirectory) else None
def _user_options(
users: Sequence[UserRef],
*,
principal: object,
administrative: bool,
value_kind: str = "user",
) -> tuple[ReferenceOption, ...]:
principal_account_id = str(getattr(principal, "account_id", "") or "")
options: dict[str, ReferenceOption] = {}
for user in users:
if not administrative and user.account_id != principal_account_id:
continue
inactive = user.status != "active"
label = user.display_name or user.email or user.account_id
detail_parts = [part for part in (user.email, "Inactive" if inactive else None) if part]
value = user.id if value_kind == "membership" else user.account_id
options[value] = ReferenceOption(
value=value,
label=label,
description=" · ".join(detail_parts) or None,
kind=value_kind,
availability="inactive" if inactive else "available",
disabled=inactive,
source_module="access",
provenance={
"membership_id": user.id,
"tenant_id": user.tenant_id,
"account_id": user.account_id,
},
)
return tuple(options.values())
def _group_options(
groups: Sequence[GroupRef],
*,
principal: object,
administrative: bool,
) -> tuple[ReferenceOption, ...]:
permitted = {
str(value)
for value in getattr(principal, "group_ids", ())
if str(value)
}
return tuple(
ReferenceOption(
value=group.id,
label=group.name or group.id,
description="Inactive" if group.status != "active" else None,
kind="group",
availability=(
"inactive" if group.status != "active" else "available"
),
disabled=group.status != "active",
source_module="access",
provenance={"tenant_id": group.tenant_id, "group_id": group.id},
)
for group in groups
if administrative or group.id in permitted
)
def _filter_and_retain_options(
options: Sequence[ReferenceOption],
*,
query: str,
selected_values: Sequence[str],
limit: int,
kind: str,
) -> tuple[ReferenceOption, ...]:
by_value = {option.value: option for option in options}
needle = str(query or "").strip().casefold()
matches = [
option
for option in options
if not needle
or needle
in " ".join(
(
option.value,
option.label,
option.description or "",
)
).casefold()
][:limit]
returned = {option.value for option in matches}
for value in selected_values:
if value in returned:
continue
matches.append(by_value.get(value) or _unavailable_option(value, kind=kind))
returned.add(value)
return tuple(matches)
def _bounded_page_options(
options: Sequence[ReferenceOption],
*,
selected_values: Sequence[str],
limit: int,
kind: str,
) -> tuple[ReferenceOption, ...]:
by_value = {option.value: option for option in options}
selected = set(selected_values)
bounded = [
option for option in options if option.value not in selected
][:limit]
returned = {option.value for option in bounded}
for value in selected_values:
if value in returned:
continue
bounded.append(by_value.get(value) or _unavailable_option(value, kind=kind))
returned.add(value)
return tuple(bounded)
def _fallback_scope_options(
principal: object,
*,
scope_type: str,
reference_kind: str | None = None,
query: str,
selected_values: Sequence[str],
limit: int,
) -> tuple[ReferenceOption, ...]:
candidates: list[ReferenceOption] = []
if scope_type == "user":
kind = str(reference_kind or "user")
value = str(
(
getattr(principal, "membership_id", "")
if kind == "membership"
else getattr(principal, "account_id", "")
)
or ""
)
if value:
candidates.append(
ReferenceOption(
value=value,
label=(
str(getattr(principal, "display_name", "") or "")
or str(getattr(principal, "email", "") or "")
or value
),
description="Current user · Access directory unavailable",
kind=kind,
source_module="core",
provenance={"fallback": True},
)
)
else:
candidates.extend(
ReferenceOption(
value=str(group_id),
label=str(group_id),
description="Permitted group · Access directory unavailable",
kind="group",
source_module="core",
provenance={"fallback": True},
)
for group_id in getattr(principal, "group_ids", ())
if str(group_id)
)
return _filter_and_retain_options(
candidates,
query=query,
selected_values=selected_values,
limit=limit,
kind=scope_type,
)
def _unavailable_option(value: str, *, kind: str) -> ReferenceOption:
return ReferenceOption(
value=value,
label=f"Unavailable {kind}",
description=value,
kind=kind,
availability="unavailable",
disabled=True,
source_module=None,
provenance={"retained_reference": True},
)
__all__ = [
"CAPABILITY_ACCESS_REFERENCE_OPTIONS",
"ReferenceAvailability",
"ReferenceOption",
"ReferenceOptionProvider",
"ReferenceSearchPage",
"ReferenceSearchRequest",
"access_scope_reference_page",
"access_scope_reference_options",
"access_scope_reference_provider_available",
"reference_option_provider",
"validate_access_scope_reference",
]

View File

@@ -16,13 +16,29 @@ from govoplan_core.core.modules import (
ModuleManifest, ModuleManifest,
NavItem, NavItem,
PermissionDefinition, PermissionDefinition,
PublicFrontendRoute,
ResourceAclProvider, ResourceAclProvider,
RoleTemplate, RoleTemplate,
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION, SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
SUPPORTED_MANIFEST_CONTRACT_VERSION, SUPPORTED_MANIFEST_CONTRACT_VERSION,
TenantSummaryBatchProvider,
TenantSummaryProvider, TenantSummaryProvider,
user_workflow_scope_condition_issues,
) )
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
from govoplan_core.core.search import (
RegisteredSearchProvider,
RegisteredSearchSourceProvider,
SearchProvider,
SearchSourceProvider,
)
from govoplan_core.core.views import (
ViewSurface,
module_view_surface_id,
navigation_view_surface_id,
route_view_surface_id,
validate_view_surface_id,
)
_MODULE_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$") _MODULE_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
_NPM_PACKAGE_RE = re.compile(r"^(?:@[a-z0-9][a-z0-9_.-]*/)?[a-z0-9][a-z0-9_.-]*$") _NPM_PACKAGE_RE = re.compile(r"^(?:@[a-z0-9][a-z0-9_.-]*/)?[a-z0-9][a-z0-9_.-]*$")
@@ -47,10 +63,17 @@ class PlatformRegistry:
def __init__(self) -> None: def __init__(self) -> None:
self._manifests: dict[str, ModuleManifest] = {} self._manifests: dict[str, ModuleManifest] = {}
self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {} self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {}
self._tenant_summary_batch_providers: dict[str, TenantSummaryBatchProvider] = {}
self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = defaultdict(list) self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = defaultdict(list)
self._capability_factories: dict[str, CapabilityFactory] = {} self._capability_factories: dict[str, CapabilityFactory] = {}
self._capabilities: dict[str, object] = {} self._capabilities: dict[str, object] = {}
self._capability_context: ModuleContext | None = None self._capability_context: ModuleContext | None = None
self._search_provider_registrations: list[RegisteredSearchProvider] = []
self._search_providers: dict[str, SearchProvider] = {}
self._search_source_registrations: list[
RegisteredSearchSourceProvider
] = []
self._search_sources: dict[str, SearchSourceProvider] = {}
def register(self, manifest: ModuleManifest) -> ModuleManifest: def register(self, manifest: ModuleManifest) -> ModuleManifest:
if manifest.id in self._manifests: if manifest.id in self._manifests:
@@ -58,11 +81,27 @@ class PlatformRegistry:
self._manifests[manifest.id] = manifest self._manifests[manifest.id] = manifest
for provider in manifest.tenant_summary_providers: for provider in manifest.tenant_summary_providers:
self.register_tenant_summary_provider(manifest.id, provider) self.register_tenant_summary_provider(manifest.id, provider)
for provider in manifest.tenant_summary_batch_providers:
self.register_tenant_summary_batch_provider(manifest.id, provider)
for resource_type, providers in manifest.delete_veto_providers.items(): for resource_type, providers in manifest.delete_veto_providers.items():
for provider in providers: for provider in providers:
self.register_delete_veto(manifest.id, resource_type, provider) self.register_delete_veto(manifest.id, resource_type, provider)
for name, factory in manifest.capability_factories.items(): for name, factory in manifest.capability_factories.items():
self.register_capability_factory(manifest.id, name, factory) self.register_capability_factory(manifest.id, name, factory)
for registration in manifest.search_providers:
self._search_provider_registrations.append(
RegisteredSearchProvider(
module_id=manifest.id,
registration=registration,
)
)
for registration in manifest.search_sources:
self._search_source_registrations.append(
RegisteredSearchSourceProvider(
module_id=manifest.id,
registration=registration,
)
)
return manifest return manifest
def replace(self, manifests: Iterable[ModuleManifest]) -> RegistrySnapshot: def replace(self, manifests: Iterable[ModuleManifest]) -> RegistrySnapshot:
@@ -75,12 +114,23 @@ class PlatformRegistry:
self._manifests = dict(replacement._manifests) self._manifests = dict(replacement._manifests)
self._tenant_summary_providers = dict(replacement._tenant_summary_providers) self._tenant_summary_providers = dict(replacement._tenant_summary_providers)
self._tenant_summary_batch_providers = dict(
replacement._tenant_summary_batch_providers
)
self._delete_veto_providers = defaultdict(list, { self._delete_veto_providers = defaultdict(list, {
resource_type: list(providers) resource_type: list(providers)
for resource_type, providers in replacement._delete_veto_providers.items() for resource_type, providers in replacement._delete_veto_providers.items()
}) })
self._capability_factories = dict(replacement._capability_factories) self._capability_factories = dict(replacement._capability_factories)
self._search_provider_registrations = list(
replacement._search_provider_registrations
)
self._search_source_registrations = list(
replacement._search_source_registrations
)
self._capabilities.clear() self._capabilities.clear()
self._search_providers.clear()
self._search_sources.clear()
return snapshot return snapshot
def get(self, module_id: str) -> ModuleManifest | None: def get(self, module_id: str) -> ModuleManifest | None:
@@ -111,12 +161,21 @@ class PlatformRegistry:
def nav_items(self) -> tuple[NavItem, ...]: def nav_items(self) -> tuple[NavItem, ...]:
return tuple(sorted((item for manifest in self.manifests() for item in manifest.nav_items), key=lambda item: item.order)) return tuple(sorted((item for manifest in self.manifests() for item in manifest.nav_items), key=lambda item: item.order))
def view_surfaces(self) -> tuple[ViewSurface, ...]:
return tuple(
surface
for manifest in self.manifests()
for surface in manifest_view_surfaces(manifest)
)
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]: def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers) return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers)
def configure_capability_context(self, context: ModuleContext) -> None: def configure_capability_context(self, context: ModuleContext) -> None:
self._capability_context = context self._capability_context = context
self._capabilities.clear() self._capabilities.clear()
self._search_providers.clear()
self._search_sources.clear()
def register_capability_factory(self, module_id: str, name: str, factory: CapabilityFactory) -> None: def register_capability_factory(self, module_id: str, name: str, factory: CapabilityFactory) -> None:
if name in self._capability_factories: if name in self._capability_factories:
@@ -144,12 +203,95 @@ class PlatformRegistry:
raise RegistryError(f"Required capability is not available: {name}") raise RegistryError(f"Required capability is not available: {name}")
return capability return capability
def search_provider_registrations(
self,
) -> tuple[RegisteredSearchProvider, ...]:
return tuple(
sorted(
self._search_provider_registrations,
key=lambda item: (
item.registration.order,
item.module_id,
item.registration.id,
),
)
)
def search_providers(
self,
) -> tuple[tuple[RegisteredSearchProvider, SearchProvider], ...]:
if self._capability_context is None:
if self._search_provider_registrations:
raise RegistryError("Search provider context is not configured.")
return ()
providers: list[tuple[RegisteredSearchProvider, SearchProvider]] = []
for registered in self.search_provider_registrations():
key = f"{registered.module_id}:{registered.registration.id}"
provider = self._search_providers.get(key)
if provider is None:
provider = registered.registration.create(self._capability_context)
self._search_providers[key] = provider
providers.append((registered, provider))
return tuple(providers)
def search_source_registrations(
self,
) -> tuple[RegisteredSearchSourceProvider, ...]:
return tuple(
sorted(
self._search_source_registrations,
key=lambda item: (
item.registration.order,
item.module_id,
item.registration.id,
),
)
)
def search_sources(
self,
) -> tuple[
tuple[RegisteredSearchSourceProvider, SearchSourceProvider],
...,
]:
if self._capability_context is None:
if self._search_source_registrations:
raise RegistryError(
"Search source context is not configured."
)
return ()
providers: list[
tuple[RegisteredSearchSourceProvider, SearchSourceProvider]
] = []
for registered in self.search_source_registrations():
key = f"{registered.module_id}:{registered.registration.id}"
provider = self._search_sources.get(key)
if provider is None:
provider = registered.registration.create(
self._capability_context
)
self._search_sources[key] = provider
providers.append((registered, provider))
return tuple(providers)
def register_tenant_summary_provider(self, module_id: str, provider: TenantSummaryProvider) -> None: def register_tenant_summary_provider(self, module_id: str, provider: TenantSummaryProvider) -> None:
self._tenant_summary_providers[module_id] = provider self._tenant_summary_providers[module_id] = provider
def tenant_summary_providers(self) -> Mapping[str, TenantSummaryProvider]: def tenant_summary_providers(self) -> Mapping[str, TenantSummaryProvider]:
return dict(self._tenant_summary_providers) return dict(self._tenant_summary_providers)
def register_tenant_summary_batch_provider(
self,
module_id: str,
provider: TenantSummaryBatchProvider,
) -> None:
self._tenant_summary_batch_providers[module_id] = provider
def tenant_summary_batch_providers(
self,
) -> Mapping[str, TenantSummaryBatchProvider]:
return dict(self._tenant_summary_batch_providers)
def register_delete_veto(self, module_id: str, resource_type: str, provider: DeleteVetoProvider) -> None: def register_delete_veto(self, module_id: str, resource_type: str, provider: DeleteVetoProvider) -> None:
self._delete_veto_providers[resource_type].append(DeleteVetoProviderRegistration( self._delete_veto_providers[resource_type].append(DeleteVetoProviderRegistration(
module_id=module_id, module_id=module_id,
@@ -186,46 +328,21 @@ class PlatformRegistry:
def validate(self) -> RegistrySnapshot: def validate(self) -> RegistrySnapshot:
ordered = tuple(self._topologically_sorted()) ordered = tuple(self._topologically_sorted())
available_capabilities = set(self._capability_factories) available_capabilities = set(self._capability_factories)
seen_permissions: dict[str, PermissionDefinition] = {}
for manifest in ordered: for manifest in ordered:
_validate_manifest_shape(manifest) _validate_manifest_shape(manifest)
for dependency in manifest.dependencies: _validate_manifest_relationships(
if dependency not in self._manifests: manifest,
raise RegistryError(f"Module {manifest.id!r} depends on unknown module {dependency!r}") known_modules=self._manifests,
for capability in manifest.required_capabilities: available_capabilities=available_capabilities,
if capability not in available_capabilities: )
raise RegistryError(f"Module {manifest.id!r} requires unavailable capability {capability!r}") permissions = _collect_manifest_permissions(ordered)
for dependency in manifest.optional_dependencies: _validate_public_frontend_route_uniqueness(ordered)
if dependency == manifest.id:
raise RegistryError(f"Module {manifest.id!r} cannot list itself as an optional dependency")
for permission in manifest.permissions:
if permission.module_id != manifest.id:
raise RegistryError(f"Permission {permission.scope!r} has mismatched module id {permission.module_id!r}")
if not _SCOPE_RE.match(permission.scope):
raise RegistryError(f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}")
expected_prefix = f"{permission.module_id}:{permission.resource}:"
if not permission.scope.startswith(expected_prefix) or permission.scope.rsplit(":", 1)[-1] != permission.action:
raise RegistryError(f"Permission fields do not match scope {permission.scope!r}")
if permission.scope in seen_permissions:
raise RegistryError(f"Duplicate permission scope: {permission.scope}")
seen_permissions[permission.scope] = permission
_validate_interface_closure(ordered) _validate_interface_closure(ordered)
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
known_scopes = set(seen_permissions)
for manifest in ordered:
for template in manifest.role_templates:
for scope in template.permissions:
if scope in {"*", "tenant:*", "system:*"}:
continue
if _WILDCARD_RE.match(scope):
continue
if scope not in known_scopes:
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
return RegistrySnapshot( return RegistrySnapshot(
manifests=ordered, manifests=ordered,
permissions=tuple(seen_permissions.values()), permissions=tuple(permissions.values()),
role_templates=tuple(template for manifest in ordered for template in manifest.role_templates), role_templates=tuple(template for manifest in ordered for template in manifest.role_templates),
nav_items=self.nav_items(), nav_items=self.nav_items(),
) )
@@ -253,6 +370,53 @@ class PlatformRegistry:
return (self._manifests[module_id] for module_id in ordered) return (self._manifests[module_id] for module_id in ordered)
def manifest_view_surfaces(manifest: ModuleManifest) -> tuple[ViewSurface, ...]:
frontend = manifest.frontend
if frontend is None:
return ()
root_id = module_view_surface_id(manifest.id)
surfaces = [
ViewSurface(
id=root_id,
module_id=manifest.id,
kind="module",
label=manifest.name,
order=min((item.order for item in frontend.nav_items), default=100),
)
]
surfaces.extend(
ViewSurface(
id=item.surface_id or navigation_view_surface_id(manifest.id, item.path),
module_id=manifest.id,
kind="navigation",
label=item.label,
parent_id=root_id,
order=item.order,
)
for item in frontend.nav_items
)
surfaces.extend(
ViewSurface(
id=route.surface_id or route_view_surface_id(manifest.id, route.path),
module_id=manifest.id,
kind="route",
label=route.component,
parent_id=root_id,
description=route.path,
order=route.order,
)
for route in (*frontend.routes, *frontend.settings_routes)
)
surfaces.extend(
replace(
surface,
parent_id=surface.parent_id or root_id,
)
for surface in frontend.view_surfaces
)
return tuple(surfaces)
def _normalize_delete_veto_result( def _normalize_delete_veto_result(
result: object, result: object,
*, *,
@@ -299,7 +463,110 @@ def _attribute_delete_veto_issue(
return replace(issue, module_id=issue.module_id or registration.module_id, details=details) return replace(issue, module_id=issue.module_id or registration.module_id, details=details)
def _validate_manifest_relationships(
manifest: ModuleManifest,
*,
known_modules: Mapping[str, ModuleManifest],
available_capabilities: set[str],
) -> None:
for dependency in manifest.dependencies:
if dependency not in known_modules:
raise RegistryError(f"Module {manifest.id!r} depends on unknown module {dependency!r}")
for capability in manifest.required_capabilities:
if capability not in available_capabilities:
raise RegistryError(f"Module {manifest.id!r} requires unavailable capability {capability!r}")
for dependency in manifest.optional_dependencies:
if dependency == manifest.id:
raise RegistryError(f"Module {manifest.id!r} cannot list itself as an optional dependency")
def _collect_manifest_permissions(manifests: tuple[ModuleManifest, ...]) -> dict[str, PermissionDefinition]:
permissions: dict[str, PermissionDefinition] = {}
for manifest in manifests:
for permission in manifest.permissions:
_validate_manifest_permission(manifest, permission, permissions)
permissions[permission.scope] = permission
return permissions
def _validate_manifest_permission(
manifest: ModuleManifest,
permission: PermissionDefinition,
seen_permissions: Mapping[str, PermissionDefinition],
) -> None:
if permission.module_id != manifest.id:
raise RegistryError(f"Permission {permission.scope!r} has mismatched module id {permission.module_id!r}")
if not _SCOPE_RE.match(permission.scope):
raise RegistryError(f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}")
expected_prefix = f"{permission.module_id}:{permission.resource}:"
if not permission.scope.startswith(expected_prefix) or permission.scope.rsplit(":", 1)[-1] != permission.action:
raise RegistryError(f"Permission fields do not match scope {permission.scope!r}")
if permission.scope in seen_permissions:
raise RegistryError(f"Duplicate permission scope: {permission.scope}")
def _validate_role_template_scopes(
manifests: tuple[ModuleManifest, ...],
*,
known_scopes: set[str],
) -> None:
seen_templates: dict[tuple[str, str], str] = {}
for manifest in manifests:
for template in manifest.role_templates:
template_key = (template.level, template.slug)
previous_module = seen_templates.get(template_key)
if previous_module is not None:
raise RegistryError(
f"Duplicate {template.level} role template slug {template.slug!r} "
f"in modules {previous_module!r} and {manifest.id!r}"
)
seen_templates[template_key] = manifest.id
if template.default_authenticated and template.level != "tenant":
raise RegistryError(
f"Default authenticated role template {template.slug!r} must be tenant-level"
)
if template.default_authenticated and not template.managed:
raise RegistryError(
f"Default authenticated role template {template.slug!r} must be managed"
)
for scope in template.permissions:
if template.default_authenticated and (
scope in {"*", "tenant:*", "system:*"}
or _WILDCARD_RE.match(scope)
):
raise RegistryError(
f"Default authenticated role template {template.slug!r} "
"must use explicit permissions, not wildcard scopes"
)
if _role_template_scope_known(scope, known_scopes):
continue
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
def _role_template_scope_known(scope: str, known_scopes: set[str]) -> bool:
if scope in {"*", "tenant:*", "system:*"}:
return True
if _WILDCARD_RE.match(scope):
return True
return scope in known_scopes
def _validate_manifest_shape(manifest: ModuleManifest) -> None: def _validate_manifest_shape(manifest: ModuleManifest) -> None:
_validate_manifest_identity(manifest)
_validate_manifest_contract_lists(manifest)
_validate_manifest_overlaps(manifest)
_validate_manifest_migration_spec(manifest)
_validate_manifest_frontend(manifest)
for item in manifest.nav_items:
_validate_nav_item(manifest.id, item)
for topic in manifest.documentation:
for issue in user_workflow_scope_condition_issues(topic):
raise RegistryError(
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
)
def _validate_manifest_identity(manifest: ModuleManifest) -> None:
if not _MODULE_ID_RE.match(manifest.id): if not _MODULE_ID_RE.match(manifest.id):
raise RegistryError(f"Module manifest id must match {_MODULE_ID_RE.pattern}: {manifest.id!r}") raise RegistryError(f"Module manifest id must match {_MODULE_ID_RE.pattern}: {manifest.id!r}")
if not manifest.name.strip(): if not manifest.name.strip():
@@ -313,12 +580,43 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
f"{SUPPORTED_MANIFEST_CONTRACT_VERSION!r}" f"{SUPPORTED_MANIFEST_CONTRACT_VERSION!r}"
) )
def _validate_manifest_contract_lists(manifest: ModuleManifest) -> None:
_validate_dependency_list(manifest.id, "dependencies", manifest.dependencies) _validate_dependency_list(manifest.id, "dependencies", manifest.dependencies)
_validate_dependency_list(manifest.id, "optional_dependencies", manifest.optional_dependencies) _validate_dependency_list(manifest.id, "optional_dependencies", manifest.optional_dependencies)
_validate_capability_list(manifest.id, "required_capabilities", manifest.required_capabilities) _validate_capability_list(manifest.id, "required_capabilities", manifest.required_capabilities)
_validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities) _validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities)
_validate_interface_providers(manifest.id, manifest.provides_interfaces) _validate_interface_providers(manifest.id, manifest.provides_interfaces)
_validate_interface_requirements(manifest.id, manifest.requires_interfaces) _validate_interface_requirements(manifest.id, manifest.requires_interfaces)
provider_ids: set[str] = set()
for registration in manifest.search_providers:
if not _INTERFACE_NAME_RE.match(registration.id):
raise RegistryError(
f"Module {manifest.id!r} search provider id must be namespaced: "
f"{registration.id!r}"
)
if registration.id in provider_ids:
raise RegistryError(
f"Module {manifest.id!r} declares duplicate search provider "
f"{registration.id!r}"
)
provider_ids.add(registration.id)
source_ids: set[str] = set()
for registration in manifest.search_sources:
if not _INTERFACE_NAME_RE.match(registration.id):
raise RegistryError(
f"Module {manifest.id!r} search source id must be "
f"namespaced: {registration.id!r}"
)
if registration.id in source_ids:
raise RegistryError(
f"Module {manifest.id!r} declares duplicate search source "
f"{registration.id!r}"
)
source_ids.add(registration.id)
def _validate_manifest_overlaps(manifest: ModuleManifest) -> None:
overlap = set(manifest.dependencies) & set(manifest.optional_dependencies) overlap = set(manifest.dependencies) & set(manifest.optional_dependencies)
if overlap: if overlap:
joined = ", ".join(sorted(overlap)) joined = ", ".join(sorted(overlap))
@@ -328,34 +626,167 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
joined = ", ".join(sorted(capability_overlap)) joined = ", ".join(sorted(capability_overlap))
raise RegistryError(f"Module {manifest.id!r} lists capabilities as both required and optional: {joined}") raise RegistryError(f"Module {manifest.id!r} lists capabilities as both required and optional: {joined}")
def _validate_manifest_migration_spec(manifest: ModuleManifest) -> None:
if manifest.migration_spec is not None: if manifest.migration_spec is not None:
if manifest.migration_spec.module_id != manifest.id: if manifest.migration_spec.module_id != manifest.id:
raise RegistryError(f"Module {manifest.id!r} has migration spec for {manifest.migration_spec.module_id!r}") raise RegistryError(f"Module {manifest.id!r} has migration spec for {manifest.migration_spec.module_id!r}")
if manifest.migration_spec.metadata is None and not manifest.migration_spec.script_location: if manifest.migration_spec.metadata is None and not manifest.migration_spec.script_location:
raise RegistryError(f"Module {manifest.id!r} migration spec must declare metadata or script location") raise RegistryError(f"Module {manifest.id!r} migration spec must declare metadata or script location")
if manifest.frontend is not None:
frontend = manifest.frontend
if frontend.module_id != manifest.id:
raise RegistryError(f"Module {manifest.id!r} has frontend metadata for {frontend.module_id!r}")
if frontend.asset_manifest_contract_version != SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION:
raise RegistryError(
f"Module {manifest.id!r} uses unsupported frontend asset manifest contract version "
f"{frontend.asset_manifest_contract_version!r}; supported version is "
f"{SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION!r}"
)
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):
if not route.path.startswith("/"):
raise RegistryError(f"Frontend route for module {manifest.id!r} must start with '/': {route.path!r}")
if not route.component.strip():
raise RegistryError(f"Frontend route {route.path!r} for module {manifest.id!r} must declare a component")
for item in frontend.nav_items:
_validate_nav_item(manifest.id, item)
for item in manifest.nav_items: def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
if manifest.frontend is None:
return
frontend = manifest.frontend
if frontend.module_id != manifest.id:
raise RegistryError(f"Module {manifest.id!r} has frontend metadata for {frontend.module_id!r}")
if frontend.asset_manifest_contract_version != SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION:
raise RegistryError(
f"Module {manifest.id!r} uses unsupported frontend asset manifest contract version "
f"{frontend.asset_manifest_contract_version!r}; supported version is "
f"{SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION!r}"
)
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, *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) _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:
if not path.startswith("/"):
raise RegistryError(f"Frontend route for module {module_id!r} must start with '/': {path!r}")
if not component.strip():
raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component")
def _validate_public_frontend_route_uniqueness(
manifests: tuple[ModuleManifest, ...],
) -> None:
owners: dict[str, tuple[str, PublicFrontendRoute]] = {}
for manifest in manifests:
if manifest.frontend is None:
continue
for route in manifest.frontend.public_routes:
previous = owners.get(route.path)
if previous is not None:
previous_module, _previous_route = previous
raise RegistryError(
f"Duplicate public frontend route {route.path!r} in modules "
f"{previous_module!r} and {manifest.id!r}"
)
owners[route.path] = (manifest.id, route)
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None: def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:

View File

@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any
from govoplan_core.core.modules import ModuleContext from govoplan_core.core.modules import ModuleContext
_context: ModuleContext | None = None _context: ModuleContext | None = None
@@ -21,3 +23,41 @@ def get_runtime_context() -> ModuleContext | None:
def get_registry() -> object | None: def get_registry() -> object | None:
return _context.registry if _context is not None else None return _context.registry if _context is not None else None
class ModuleSettingsProxy:
def __init__(self, runtime: ModuleRuntimeState) -> None:
self._runtime = runtime
def __getattr__(self, name: str) -> Any:
return getattr(self._runtime.get_settings(), name)
class ModuleRuntimeState:
def __init__(self, module_name: str) -> None:
self.module_name = module_name
self.settings = ModuleSettingsProxy(self)
self._registry: object | None = None
self._settings: object | None = None
def configure_runtime(self, *, registry: object | None = None, settings: object | None = None) -> None:
if registry is not None:
self._registry = registry
if settings is not None:
self._settings = settings
def clear_runtime(self) -> None:
self._registry = None
self._settings = None
def get_registry(self) -> object | None:
return self._registry
def get_settings(self) -> object:
if self._settings is not None:
return self._settings
try:
from govoplan_core.settings import settings as legacy_settings
except ModuleNotFoundError as exc:
raise RuntimeError(f"GovOPlaN {self.module_name} runtime settings are not configured") from exc
return legacy_settings

View File

@@ -0,0 +1,383 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
SANCTIONS_SNAPSHOT_CONTRACT_VERSION = "1"
SANCTIONS_SCREENING_CONTRACT_VERSION = "1"
CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS = (
"connectors.sanctionsSnapshotProvider"
)
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING = (
"riskCompliance.sanctionsScreeningProvider"
)
@dataclass(frozen=True, slots=True)
class SanctionsSnapshotReference:
ref: str
tenant_id: str
provider_id: str
publisher: str
jurisdiction: str
list_type: str
source_id: str
source_version: str
publication_at: datetime | None
effective_at: datetime | None
acquired_at: datetime
content_type: str
byte_count: int
sha256: str
parser_version: str
raw_evidence_ref: str
connector_run_id: str
signature_evidence: Mapping[str, object] = field(
default_factory=dict
)
licence_notes: str | None = None
trust_notes: str | None = None
transport_evidence: Mapping[str, object] = field(
default_factory=dict
)
contract_version: str = SANCTIONS_SNAPSHOT_CONTRACT_VERSION
def __post_init__(self) -> None:
if self.contract_version != SANCTIONS_SNAPSHOT_CONTRACT_VERSION:
raise ValueError(
"Unsupported sanctions snapshot contract version."
)
required = (
self.ref,
self.tenant_id,
self.provider_id,
self.publisher,
self.jurisdiction,
self.list_type,
self.source_id,
self.source_version,
self.content_type,
self.sha256,
self.parser_version,
self.raw_evidence_ref,
self.connector_run_id,
)
if not all(value.strip() for value in required):
raise ValueError(
"Sanctions snapshot references require complete provenance."
)
if self.byte_count < 1:
raise ValueError(
"Sanctions snapshot evidence must not be empty."
)
if (
len(self.sha256) != 64
or any(character not in "0123456789abcdef" for character in self.sha256)
):
raise ValueError(
"Sanctions snapshot SHA-256 evidence is invalid."
)
@dataclass(frozen=True, slots=True)
class SanctionsSnapshotPayload:
snapshot: SanctionsSnapshotReference
content: bytes
def __post_init__(self) -> None:
if len(self.content) != self.snapshot.byte_count:
raise ValueError(
"Sanctions snapshot content length does not match metadata."
)
@dataclass(frozen=True, slots=True)
class SanctionsScreeningSubject:
subject_type: Literal["person", "entity"]
primary_name: str | None = None
subject_ref: str | None = None
aliases: tuple[str, ...] = ()
identifiers: tuple[Mapping[str, str], ...] = ()
dates: tuple[str, ...] = ()
addresses: tuple[Mapping[str, str], ...] = ()
def __post_init__(self) -> None:
if self.subject_type not in {"person", "entity"}:
raise ValueError(
"Sanctions screening subjects must be a person or entity."
)
if not str(self.primary_name or "").strip() and not any(
str(value.get("value") or "").strip()
for value in self.identifiers
):
raise ValueError(
"A sanctions screening subject needs a name or identifier."
)
@dataclass(frozen=True, slots=True)
class SanctionsScreeningPolicy:
fuzzy_threshold: float = 0.88
max_snapshot_age_days: int = 7
max_candidates: int = 100
failure_policy: Literal["block", "review", "degraded"] = "block"
def __post_init__(self) -> None:
if not 0.8 <= self.fuzzy_threshold <= 1:
raise ValueError(
"Sanctions screening fuzzy threshold must be between 0.8 and 1."
)
if not 1 <= self.max_snapshot_age_days <= 365:
raise ValueError(
"Sanctions snapshot age must be between 1 and 365 days."
)
if not 1 <= self.max_candidates <= 100:
raise ValueError(
"Sanctions screening candidate limit must be between 1 and 100."
)
if self.failure_policy not in {"block", "review", "degraded"}:
raise ValueError(
"Sanctions screening failure policy is not supported."
)
@dataclass(frozen=True, slots=True)
class SanctionsScreeningRequest:
list_snapshot_id: str
idempotency_key: str
subject: SanctionsScreeningSubject
policy: SanctionsScreeningPolicy = field(
default_factory=SanctionsScreeningPolicy
)
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
def __post_init__(self) -> None:
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
raise ValueError(
"Unsupported sanctions screening contract version."
)
if not self.list_snapshot_id.strip():
raise ValueError(
"A sanctions list snapshot is required for screening."
)
if not self.idempotency_key.strip():
raise ValueError(
"A sanctions screening idempotency key is required."
)
@dataclass(frozen=True, slots=True)
class SanctionsScreeningEvidence:
ref: str
run_id: str
outcome: str
candidate_count: int
list_snapshot_id: str
source_version: str
subject_fingerprint: str
matcher_version: str
normalization_version: str
policy_version: str
completed_at: datetime | None
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
def __post_init__(self) -> None:
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
raise ValueError(
"Unsupported sanctions screening evidence version."
)
if self.ref != f"risk-screening:{self.run_id}":
raise ValueError(
"Sanctions screening evidence reference is invalid."
)
if self.candidate_count < 0:
raise ValueError(
"Sanctions screening candidate count cannot be negative."
)
@dataclass(frozen=True, slots=True)
class SanctionsScreeningFreshnessRequest:
evidence_ref: str
current_subject: SanctionsScreeningSubject | None = None
expected_list_snapshot_id: str | None = None
policy: SanctionsScreeningPolicy = field(
default_factory=SanctionsScreeningPolicy
)
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
def __post_init__(self) -> None:
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
raise ValueError(
"Unsupported sanctions screening freshness contract version."
)
if not self.evidence_ref.startswith("risk-screening:"):
raise ValueError(
"Sanctions screening evidence reference is invalid."
)
@dataclass(frozen=True, slots=True)
class SanctionsScreeningFreshness:
evidence: SanctionsScreeningEvidence
fresh: bool
reasons: tuple[str, ...]
checked_at: datetime
current_list_snapshot_id: str | None
gate_decision: Literal["allow", "block", "review", "degraded"]
gate_reasons: tuple[str, ...]
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
def __post_init__(self) -> None:
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
raise ValueError(
"Unsupported sanctions screening freshness version."
)
if self.fresh != (not self.reasons):
raise ValueError(
"Sanctions screening freshness reasons are inconsistent."
)
if self.gate_decision not in {
"allow",
"block",
"review",
"degraded",
}:
raise ValueError(
"Sanctions screening gate decision is invalid."
)
@dataclass(frozen=True, slots=True)
class SanctionsScreeningResult:
evidence: SanctionsScreeningEvidence
freshness: SanctionsScreeningFreshness
created: bool
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
def __post_init__(self) -> None:
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
raise ValueError(
"Unsupported sanctions screening result version."
)
if self.evidence != self.freshness.evidence:
raise ValueError(
"Sanctions screening result evidence is inconsistent."
)
@runtime_checkable
class SanctionsSnapshotProvider(Protocol):
def list_snapshots(
self,
session: object,
principal: object,
*,
limit: int = 100,
) -> Sequence[SanctionsSnapshotReference]:
...
def get_snapshot(
self,
session: object,
principal: object,
*,
snapshot_ref: str,
) -> SanctionsSnapshotReference | None:
...
def read_snapshot(
self,
session: object,
principal: object,
*,
snapshot_ref: str,
) -> SanctionsSnapshotPayload:
...
@runtime_checkable
class SanctionsScreeningProvider(Protocol):
def request_screening(
self,
session: object,
principal: object,
request: SanctionsScreeningRequest,
) -> SanctionsScreeningResult:
...
def check_freshness(
self,
session: object,
principal: object,
request: SanctionsScreeningFreshnessRequest,
) -> SanctionsScreeningFreshness:
...
def sanctions_snapshot_provider(
registry: object | None,
) -> SanctionsSnapshotProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(
CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS
)
):
return None
provider = registry.capability(
CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS
)
return (
provider
if isinstance(provider, SanctionsSnapshotProvider)
else None
)
def sanctions_screening_provider(
registry: object | None,
) -> SanctionsScreeningProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING
)
):
return None
provider = registry.capability(
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING
)
return (
provider
if isinstance(provider, SanctionsScreeningProvider)
else None
)
__all__ = [
"CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS",
"CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING",
"SANCTIONS_SCREENING_CONTRACT_VERSION",
"SANCTIONS_SNAPSHOT_CONTRACT_VERSION",
"SanctionsScreeningEvidence",
"SanctionsScreeningFreshness",
"SanctionsScreeningFreshnessRequest",
"SanctionsScreeningPolicy",
"SanctionsScreeningProvider",
"SanctionsScreeningRequest",
"SanctionsScreeningResult",
"SanctionsScreeningSubject",
"SanctionsSnapshotPayload",
"SanctionsSnapshotProvider",
"SanctionsSnapshotReference",
"sanctions_screening_provider",
"sanctions_snapshot_provider",
]

View File

@@ -0,0 +1,473 @@
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.external_references import ExternalObjectReference
from govoplan_core.core.modules import ModuleContext
SearchContextKind = Literal["global", "module", "resource"]
SearchVisibility = Literal["tenant", "restricted"]
SearchIndexChangeKind = Literal["upsert", "delete"]
SEARCH_SOURCE_CONTRACT_VERSION = "1"
@dataclass(frozen=True, slots=True)
class SearchQuery:
text: str
tenant_id: str
module_ids: tuple[str, ...] = ()
resource_types: tuple[str, ...] = ()
context_kind: SearchContextKind = "global"
context_id: str | None = None
limit: int = 25
offset: int = 0
cursor: str | None = None
language: str = "simple"
def __post_init__(self) -> None:
normalized_text = self.text.strip()
if len(normalized_text) > 500:
raise ValueError("Search text is limited to 500 characters.")
if not self.tenant_id.strip():
raise ValueError("Search queries require a tenant.")
if not 1 <= self.limit <= 200:
raise ValueError("Search result limits must be between 1 and 200.")
if self.offset < 0:
raise ValueError("Search offsets cannot be negative.")
if self.cursor and self.offset:
raise ValueError(
"Search cursor and offset pagination cannot be combined."
)
if not self.language.replace("_", "").isalnum():
raise ValueError("Search language configuration is invalid.")
if len(self.module_ids) > 50 or len(self.resource_types) > 50:
raise ValueError(
"Search queries support at most 50 module and type filters."
)
object.__setattr__(self, "text", normalized_text)
@dataclass(frozen=True, slots=True)
class SearchResult:
provider_id: str
module_id: str
resource_type: str
resource_id: str
title: str
url: str
summary: str | None = None
score: float = 0.0
highlights: tuple[str, ...] = ()
breadcrumbs: tuple[str, ...] = ()
external_reference: ExternalObjectReference | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
source_revision: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class SearchResourceReference:
tenant_id: str
module_id: str
resource_type: str
resource_id: str
def __post_init__(self) -> None:
for field_name in (
"tenant_id",
"module_id",
"resource_type",
"resource_id",
):
if not str(getattr(self, field_name) or "").strip():
raise ValueError(
f"Search resource reference {field_name} is required."
)
@property
def key(self) -> str:
values = (
self.tenant_id,
self.module_id,
self.resource_type,
self.resource_id,
)
return "|".join(
f"{len(value)}:{value}"
for value in values
)
@dataclass(frozen=True, slots=True)
class SearchResourceType:
provider_id: str
module_id: str
resource_type: str
label: str
index_version: int = 1
language: str = "simple"
requires_authorization_recheck: bool = True
def __post_init__(self) -> None:
if not all(
value.strip()
for value in (
self.provider_id,
self.module_id,
self.resource_type,
self.label,
)
):
raise ValueError(
"Search resource type identifiers and label are required."
)
if self.index_version < 1:
raise ValueError("Search index versions start at one.")
@dataclass(frozen=True, slots=True)
class SearchDocument:
tenant_id: str
module_id: str
resource_type: str
resource_id: str
title: str
url: str
summary: str | None = None
body: str | None = None
keywords: tuple[str, ...] = ()
visibility: SearchVisibility = "restricted"
acl_tokens: tuple[str, ...] = ()
external_reference: ExternalObjectReference | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
provider_id: str | None = None
source_revision: str = "1"
change_cursor: str | None = None
source_updated_at: datetime | None = None
language: str = "simple"
index_version: int = 1
requires_authorization_recheck: bool = False
def __post_init__(self) -> None:
required = {
"tenant_id": self.tenant_id,
"module_id": self.module_id,
"resource_type": self.resource_type,
"resource_id": self.resource_id,
"title": self.title,
"url": self.url,
}
for field_name, value in required.items():
if not str(value or "").strip():
raise ValueError(f"Search document {field_name} is required.")
limits = {
"module_id": 100,
"resource_type": 100,
"resource_id": 255,
"title": 500,
"url": 1500,
"summary": 4_000,
"body": 200_000,
"source_revision": 255,
"change_cursor": 500,
"language": 32,
}
for field_name, limit in limits.items():
value = getattr(self, field_name)
if value is not None and len(str(value)) > limit:
raise ValueError(
f"Search document {field_name} is limited to "
f"{limit} characters."
)
if len(self.keywords) > 100 or any(
len(keyword) > 200
for keyword in self.keywords
):
raise ValueError(
"Search documents support at most 100 bounded keywords."
)
if len(self.acl_tokens) > 500 or any(
len(token) > 500
for token in self.acl_tokens
):
raise ValueError(
"Search documents support at most 500 bounded ACL tokens."
)
if self.visibility == "restricted" and not self.acl_tokens:
raise ValueError(
"Restricted search documents require at least one ACL token."
)
if not self.source_revision.strip():
raise ValueError(
"Search documents require a stable source revision."
)
if self.index_version < 1:
raise ValueError("Search document index versions start at one.")
if (
self.requires_authorization_recheck
and not str(self.provider_id or "").strip()
):
raise ValueError(
"Search documents requiring authorization rechecks must "
"name their source provider."
)
if self.provider_id is not None and len(self.provider_id) > 200:
raise ValueError(
"Search document provider_id is limited to 200 characters."
)
@property
def reference(self) -> SearchResourceReference:
return SearchResourceReference(
tenant_id=self.tenant_id,
module_id=self.module_id,
resource_type=self.resource_type,
resource_id=self.resource_id,
)
@dataclass(frozen=True, slots=True)
class SearchAuthorizationRequest:
reference: SearchResourceReference
source_revision: str
@dataclass(frozen=True, slots=True)
class SearchBackfillRequest:
tenant_id: str
provider_id: str
resource_type: str
rebuild_id: str
cursor: str | None = None
limit: int = 100
def __post_init__(self) -> None:
if not 1 <= self.limit <= 500:
raise ValueError(
"Search backfill page size must be between 1 and 500."
)
@dataclass(frozen=True, slots=True)
class SearchBackfillPage:
documents: tuple[SearchDocument, ...]
next_cursor: str | None
complete: bool
high_watermark: str | None = None
def __post_init__(self) -> None:
if len(self.documents) > 500:
raise ValueError(
"Search backfill providers returned more than 500 documents."
)
if not self.complete and not self.next_cursor:
raise ValueError(
"Incomplete search backfill pages require a next cursor."
)
@dataclass(frozen=True, slots=True)
class SearchIndexChange:
change_id: str
provider_id: str
kind: SearchIndexChangeKind
reference: SearchResourceReference
source_revision: str
cursor: str
document: SearchDocument | None = None
occurred_at: datetime | None = None
def __post_init__(self) -> None:
if not all(
value.strip()
for value in (
self.change_id,
self.provider_id,
self.source_revision,
self.cursor,
)
):
raise ValueError(
"Search index changes require stable identity and revision."
)
if self.kind == "upsert":
if (
self.document is None
or self.document.reference != self.reference
or self.document.provider_id != self.provider_id
or self.document.source_revision
!= self.source_revision
or self.document.change_cursor != self.cursor
):
raise ValueError(
"Search upserts require a matching document."
)
elif self.document is not None:
raise ValueError(
"Search deletes must not contain a document."
)
@runtime_checkable
class SearchProvider(Protocol):
def search(
self,
session: object,
principal: object,
*,
query: SearchQuery,
) -> Sequence[SearchResult]:
"""Return only results the principal may currently read."""
@runtime_checkable
class SearchIndexWriter(Protocol):
def upsert_document(
self,
session: object,
principal: object,
*,
document: SearchDocument,
) -> None:
...
def delete_document(
self,
session: object,
principal: object,
*,
tenant_id: str,
module_id: str,
resource_type: str,
resource_id: str,
) -> bool:
...
def enqueue_change(
self,
session: object,
*,
change: SearchIndexChange,
) -> bool:
...
@runtime_checkable
class SearchSourceProvider(Protocol):
def resource_types(self) -> Sequence[SearchResourceType]:
...
def backfill(
self,
session: object,
*,
request: SearchBackfillRequest,
) -> SearchBackfillPage:
...
def authorize(
self,
session: object,
principal: object,
*,
requests: Sequence[SearchAuthorizationRequest],
) -> Mapping[str, bool]:
"""Return an explicit decision for every requested reference key."""
SearchProviderFactory = Callable[[ModuleContext], SearchProvider]
SearchSourceProviderFactory = Callable[
[ModuleContext],
SearchSourceProvider,
]
@dataclass(frozen=True, slots=True)
class SearchProviderRegistration:
id: str
factory: SearchProviderFactory
resource_types: tuple[str, ...] = ()
order: int = 100
def create(self, context: ModuleContext) -> SearchProvider:
provider = self.factory(context)
if not isinstance(provider, SearchProvider):
raise TypeError(
f"Search provider {self.id!r} does not implement SearchProvider."
)
return provider
@dataclass(frozen=True, slots=True)
class RegisteredSearchProvider:
module_id: str
registration: SearchProviderRegistration
@dataclass(frozen=True, slots=True)
class SearchSourceProviderRegistration:
id: str
factory: SearchSourceProviderFactory
order: int = 100
def create(self, context: ModuleContext) -> SearchSourceProvider:
provider = self.factory(context)
if not isinstance(provider, SearchSourceProvider):
raise TypeError(
f"Search source {self.id!r} does not implement "
"SearchSourceProvider."
)
return provider
@dataclass(frozen=True, slots=True)
class RegisteredSearchSourceProvider:
module_id: str
registration: SearchSourceProviderRegistration
CAPABILITY_SEARCH_INDEX_WRITER = "search.index_writer"
def search_index_writer(registry: object | None) -> SearchIndexWriter | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(CAPABILITY_SEARCH_INDEX_WRITER)
):
return None
writer = registry.capability(CAPABILITY_SEARCH_INDEX_WRITER)
return writer if isinstance(writer, SearchIndexWriter) else None
__all__ = [
"CAPABILITY_SEARCH_INDEX_WRITER",
"SEARCH_SOURCE_CONTRACT_VERSION",
"RegisteredSearchProvider",
"RegisteredSearchSourceProvider",
"SearchAuthorizationRequest",
"SearchBackfillPage",
"SearchBackfillRequest",
"SearchContextKind",
"SearchDocument",
"SearchIndexChange",
"SearchIndexChangeKind",
"SearchIndexWriter",
"SearchProvider",
"SearchProviderFactory",
"SearchProviderRegistration",
"SearchQuery",
"SearchResourceReference",
"SearchResourceType",
"SearchResult",
"SearchSourceProvider",
"SearchSourceProviderFactory",
"SearchSourceProviderRegistration",
"SearchVisibility",
"search_index_writer",
]

View File

@@ -0,0 +1,68 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any, Literal
from sqlalchemy import inspect
ChangeOperation = Literal["created", "updated", "deleted"]
def object_state(obj: object) -> Any:
return inspect(obj)
def has_attr_changes(state: Any, attrs: tuple[str, ...]) -> bool:
return any(name in state.attrs and state.attrs[name].history.has_changes() for name in attrs)
def previous_value(obj: object, attr_name: str) -> str | None:
state = object_state(obj)
if attr_name not in state.attrs:
return None
history = state.attrs[attr_name].history
if not history.has_changes() or not history.deleted:
return None
value = history.deleted[0]
return str(value) if value is not None else None
def ensure_object_id(obj: object, new_id: Callable[[], str]) -> str:
resource_id = getattr(obj, "id", None)
if resource_id:
return str(resource_id)
resource_id = new_id()
setattr(obj, "id", resource_id)
return resource_id
def operation_for_object(obj: object, *, changed_attrs: tuple[str, ...]) -> ChangeOperation | None:
state = object_state(obj)
if state.pending:
return "created"
if state.deleted:
return "deleted"
if not has_attr_changes(state, changed_attrs):
return None
return "updated"
def operation_for_soft_deletable(
obj: object,
*,
changed_attrs: tuple[str, ...],
deleted_attr: str = "deleted_at",
) -> ChangeOperation | None:
state = object_state(obj)
if state.pending:
return "created"
if not has_attr_changes(state, changed_attrs):
return None
if deleted_attr in state.attrs:
history = state.attrs[deleted_attr].history
if history.has_changes():
if any(value is not None for value in history.added):
return "deleted"
if any(value is not None for value in history.deleted):
return "created"
return "updated"

View File

@@ -0,0 +1,264 @@
from __future__ import annotations
import csv
import io
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
CAPABILITY_CONNECTORS_TABULAR_SOURCES = "connectors.tabularSources"
CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER = "connectors.tabularSnapshotWriter"
class TabularSourceError(ValueError):
"""Stable base error for provider-neutral tabular source operations."""
class TabularSourceNotFoundError(TabularSourceError):
pass
class TabularSourceAccessError(TabularSourceError):
pass
class TabularSourceValidationError(TabularSourceError):
pass
def parse_tabular_csv(
csv_text: str,
*,
delimiter: str = ",",
max_rows: int = 10_000,
) -> tuple[Mapping[str, object], ...]:
"""Parse a bounded CSV document into JSON-compatible tabular rows."""
if len(delimiter) != 1:
raise TabularSourceValidationError("CSV delimiter must be one character.")
try:
reader = csv.DictReader(io.StringIO(csv_text), delimiter=delimiter)
original_headers, normalized_headers = _csv_headers(reader.fieldnames)
rows: list[dict[str, object]] = []
for row in reader:
_validate_csv_row_shape(row)
if _csv_row_is_empty(row, original_headers):
continue
if len(rows) >= max_rows:
raise TabularSourceValidationError(
f"CSV snapshots are limited to {max_rows:,} rows."
)
rows.append(_csv_row(row, original_headers, normalized_headers))
return tuple(rows)
except csv.Error as exc:
raise TabularSourceValidationError(f"CSV input could not be parsed: {exc}") from exc
def _csv_headers(
fieldnames: Sequence[str | None] | None,
) -> tuple[tuple[str, ...], tuple[str, ...]]:
if not fieldnames:
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
original = tuple(str(name or "") for name in fieldnames)
normalized = tuple(
name.strip().lstrip("\ufeff") if position == 0 else name.strip()
for position, name in enumerate(original)
)
if any(not name for name in normalized):
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
if len(set(normalized)) != len(normalized):
raise TabularSourceValidationError("CSV column names must be unique.")
return original, normalized
def _validate_csv_row_shape(row: Mapping[str | None, object]) -> None:
extras = row.get(None)
if isinstance(extras, list) and any(str(value or "").strip() for value in extras):
raise TabularSourceValidationError(
"A CSV row contains more values than the header defines."
)
def _csv_row_is_empty(
row: Mapping[str | None, str | list[str] | None],
headers: Sequence[str],
) -> bool:
return all(
value is None or isinstance(value, list) or not value.strip()
for value in (row.get(header) for header in headers)
)
def _csv_row(
row: Mapping[str | None, str | list[str] | None],
original_headers: Sequence[str],
normalized_headers: Sequence[str],
) -> dict[str, object]:
return {
normalized: _csv_scalar(value if isinstance(value, str) else None)
for original, normalized in zip(
original_headers,
normalized_headers,
strict=True,
)
for value in (row.get(original),)
}
def _csv_scalar(value: str | None) -> object:
if value is None:
return None
text = value.strip()
if not text:
return None
lowered = text.casefold()
if lowered in {"true", "false"}:
return lowered == "true"
if re.fullmatch(r"-?(?:0|[1-9][0-9]*)", text):
return int(text)
if re.fullmatch(r"-?(?:0|[1-9][0-9]*)\.[0-9]+", text):
return float(text)
return text
@dataclass(frozen=True, slots=True)
class TabularColumn:
name: str
data_type: str
nullable: bool = True
@dataclass(frozen=True, slots=True)
class TabularSource:
"""Opaque, policy-filtered source reference exposed to consuming modules."""
ref: str
provider: str
source_name: str
name: str
description: str | None = None
schema: tuple[TabularColumn, ...] = ()
schema_version: str = "1"
fingerprint: str = ""
row_count: int | None = None
byte_count: int | None = None
updated_at: datetime | None = None
capabilities: tuple[str, ...] = ("read",)
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class TabularReadRequest:
source_ref: str
limit: int = 250
offset: int = 0
columns: tuple[str, ...] = ()
expected_fingerprint: str | None = None
@dataclass(frozen=True, slots=True)
class TabularReadResult:
source: TabularSource
rows: tuple[Mapping[str, object], ...]
total_rows: int
truncated: bool
@dataclass(frozen=True, slots=True)
class TabularSnapshotInput:
name: str
source_name: str
rows: tuple[Mapping[str, object], ...]
description: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class TabularSourceProvider(Protocol):
"""Principal-aware catalogue and bounded reader for tabular sources."""
def list_sources(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
) -> Sequence[TabularSource]:
...
def get_source(
self,
session: object,
principal: object,
*,
source_ref: str,
) -> TabularSource | None:
...
def read_source(
self,
session: object,
principal: object,
*,
request: TabularReadRequest,
) -> TabularReadResult:
...
@runtime_checkable
class TabularSnapshotWriter(Protocol):
"""Optional capability for importing bounded static tabular snapshots."""
def create_snapshot(
self,
session: object,
principal: object,
*,
snapshot: TabularSnapshotInput,
) -> TabularSource:
...
def tabular_source_provider(registry: object | None) -> TabularSourceProvider | None:
capability = _capability(registry, CAPABILITY_CONNECTORS_TABULAR_SOURCES)
return capability if isinstance(capability, TabularSourceProvider) else None
def tabular_snapshot_writer(registry: object | None) -> TabularSnapshotWriter | None:
capability = _capability(registry, CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER)
return capability if isinstance(capability, TabularSnapshotWriter) else None
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
__all__ = [
"CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER",
"CAPABILITY_CONNECTORS_TABULAR_SOURCES",
"TabularColumn",
"TabularReadRequest",
"TabularReadResult",
"TabularSnapshotInput",
"TabularSnapshotWriter",
"TabularSource",
"TabularSourceAccessError",
"TabularSourceError",
"TabularSourceNotFoundError",
"TabularSourceProvider",
"TabularSourceValidationError",
"parse_tabular_csv",
"tabular_snapshot_writer",
"tabular_source_provider",
]

View File

@@ -0,0 +1,349 @@
from __future__ import annotations
import hashlib
import logging
import re
import threading
import time
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from typing import Protocol
from redis import Redis
from redis.exceptions import RedisError
logger = logging.getLogger(__name__)
_NAMESPACE_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]{0,99}$")
_REDIS_INCREMENT_SCRIPT = """
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
local ttl = redis.call('TTL', KEYS[1])
return {count, ttl}
"""
@dataclass(frozen=True, slots=True)
class FixedWindowBucket:
count: int = 0
retry_after_seconds: int = 0
@dataclass(frozen=True, slots=True)
class ThrottleDimension:
"""One independently limited dimension without putting its value in keys."""
namespace: str
subject: str
limit: int
@dataclass(frozen=True, slots=True)
class ThrottleDecision:
allowed: bool
retry_after_seconds: int = 0
class FixedWindowStore(Protocol):
def read(self, key: str) -> FixedWindowBucket: ...
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket: ...
def delete(self, key: str) -> None: ...
class InMemoryFixedWindowStore:
"""Bounded process-local store for development and distributed-store loss."""
def __init__(
self,
*,
max_entries: int = 10_000,
clock: Callable[[], float] = time.monotonic,
) -> None:
self._entries: dict[str, tuple[int, float]] = {}
self._lock = threading.Lock()
self._max_entries = max(2, max_entries)
self._clock = clock
def read(self, key: str) -> FixedWindowBucket:
now = self._clock()
with self._lock:
entry = self._active_entry(key, now=now)
if entry is None:
return FixedWindowBucket()
count, expires_at = entry
return FixedWindowBucket(
count=count,
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
)
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
now = self._clock()
with self._lock:
entry = self._active_entry(key, now=now)
if entry is None:
self._make_room(now=now, incoming_key=key)
count = 1
expires_at = now + window_seconds
else:
count = entry[0] + 1
expires_at = entry[1]
self._entries[key] = (count, expires_at)
return FixedWindowBucket(
count=count,
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
)
def delete(self, key: str) -> None:
with self._lock:
self._entries.pop(key, None)
def _active_entry(self, key: str, *, now: float) -> tuple[int, float] | None:
entry = self._entries.get(key)
if entry is None:
return None
if entry[1] <= now:
self._entries.pop(key, None)
return None
return entry
def _make_room(self, *, now: float, incoming_key: str) -> None:
if incoming_key in self._entries or len(self._entries) < self._max_entries:
return
for key, (_count, expires_at) in tuple(self._entries.items()):
if expires_at <= now:
self._entries.pop(key, None)
while len(self._entries) >= self._max_entries:
self._entries.pop(next(iter(self._entries)))
class RedisFixedWindowStore:
"""Atomic Redis fixed-window counters shared across API workers."""
def __init__(self, redis_url: str) -> None:
self._client = Redis.from_url(
redis_url,
decode_responses=True,
socket_connect_timeout=0.25,
socket_timeout=0.25,
health_check_interval=30,
)
def read(self, key: str) -> FixedWindowBucket:
pipeline = self._client.pipeline(transaction=False)
pipeline.get(key)
pipeline.ttl(key)
raw_count, raw_ttl = pipeline.execute()
return FixedWindowBucket(
count=int(raw_count or 0),
retry_after_seconds=max(0, int(raw_ttl or 0)),
)
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
result = self._client.eval(
_REDIS_INCREMENT_SCRIPT,
1,
key,
window_seconds,
)
if not isinstance(result, (list, tuple)) or len(result) != 2:
raise RedisError("Unexpected fixed-window throttle response from Redis")
return FixedWindowBucket(
count=int(result[0]),
retry_after_seconds=max(1, int(result[1])),
)
def delete(self, key: str) -> None:
self._client.delete(key)
class ResilientFixedWindowStore:
"""Mirror locally, prefer Redis, and fall back safely during an outage."""
def __init__(
self,
primary: FixedWindowStore | None,
fallback: FixedWindowStore,
*,
retry_seconds: int = 30,
clock: Callable[[], float] = time.monotonic,
) -> None:
self._primary = primary
self._fallback = fallback
self._retry_seconds = max(1, retry_seconds)
self._clock = clock
self._primary_unavailable_until = 0.0
self._state_lock = threading.Lock()
def read(self, key: str) -> FixedWindowBucket:
fallback_result = self._fallback.read(key)
primary = self._available_primary()
if primary is None:
return fallback_result
try:
return _stricter_bucket(primary.read(key), fallback_result)
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
self._mark_primary_unavailable(exc)
return fallback_result
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
fallback_result = self._fallback.increment(
key,
window_seconds=window_seconds,
)
primary = self._available_primary()
if primary is None:
return fallback_result
try:
primary_result = primary.increment(
key,
window_seconds=window_seconds,
)
return _stricter_bucket(primary_result, fallback_result)
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
self._mark_primary_unavailable(exc)
return fallback_result
def delete(self, key: str) -> None:
self._fallback.delete(key)
primary = self._available_primary()
if primary is None:
return
try:
primary.delete(key)
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
self._mark_primary_unavailable(exc)
def _available_primary(self) -> FixedWindowStore | None:
if self._primary is None:
return None
with self._state_lock:
if self._clock() < self._primary_unavailable_until:
return None
return self._primary
def _mark_primary_unavailable(self, exc: Exception) -> None:
should_log = False
with self._state_lock:
now = self._clock()
if now >= self._primary_unavailable_until:
should_log = True
self._primary_unavailable_until = now + self._retry_seconds
if should_log:
logger.warning(
"Distributed throttling is unavailable; using the process-local fallback (%s)",
type(exc).__name__,
)
def _stricter_bucket(
first: FixedWindowBucket,
second: FixedWindowBucket,
) -> FixedWindowBucket:
return FixedWindowBucket(
count=max(first.count, second.count),
retry_after_seconds=max(
first.retry_after_seconds,
second.retry_after_seconds,
),
)
class FixedWindowThrottle:
def __init__(
self,
store: FixedWindowStore,
*,
window_seconds: int,
key_prefix: str = "govoplan:throttle:v1",
) -> None:
self._store = store
self._window_seconds = max(1, window_seconds)
self._key_prefix = key_prefix.rstrip(":")
def check(self, dimensions: Iterable[ThrottleDimension]) -> ThrottleDecision:
return self._decision(tuple(dimensions), increment=False)
def record(self, dimensions: Iterable[ThrottleDimension]) -> ThrottleDecision:
return self._decision(tuple(dimensions), increment=True)
def reset(self, dimensions: Iterable[ThrottleDimension]) -> None:
for dimension in tuple(dimensions):
self._store.delete(self._key(dimension))
def _decision(
self,
dimensions: tuple[ThrottleDimension, ...],
*,
increment: bool,
) -> ThrottleDecision:
if not dimensions:
raise ValueError("At least one throttle dimension is required")
blocked_retry_after = 0
for dimension in dimensions:
if dimension.limit < 1:
raise ValueError("Throttle limits must be positive")
key = self._key(dimension)
state = (
self._store.increment(key, window_seconds=self._window_seconds)
if increment
else self._store.read(key)
)
if state.count >= dimension.limit:
blocked_retry_after = max(
blocked_retry_after,
max(1, state.retry_after_seconds),
)
return ThrottleDecision(
allowed=blocked_retry_after == 0,
retry_after_seconds=blocked_retry_after,
)
def _key(self, dimension: ThrottleDimension) -> str:
namespace = dimension.namespace.strip().casefold()
if not _NAMESPACE_PATTERN.fullmatch(namespace):
raise ValueError("Throttle namespaces must use lowercase letters, digits, '.', '_' or '-'")
subject_digest = hashlib.sha256(dimension.subject.encode("utf-8")).hexdigest()
return f"{self._key_prefix}:{namespace}:{subject_digest}"
def build_fixed_window_throttle(
*,
redis_url: str | None,
window_seconds: int,
redis_retry_seconds: int = 30,
max_local_entries: int = 10_000,
key_prefix: str = "govoplan:throttle:v1",
) -> FixedWindowThrottle:
primary = (
RedisFixedWindowStore(redis_url)
if redis_url is not None and redis_url.strip()
else None
)
store = ResilientFixedWindowStore(
primary,
InMemoryFixedWindowStore(max_entries=max_local_entries),
retry_seconds=redis_retry_seconds,
)
return FixedWindowThrottle(
store,
window_seconds=window_seconds,
key_prefix=key_prefix,
)
__all__ = [
"FixedWindowBucket",
"FixedWindowStore",
"FixedWindowThrottle",
"InMemoryFixedWindowStore",
"RedisFixedWindowStore",
"ResilientFixedWindowStore",
"ThrottleDecision",
"ThrottleDimension",
"build_fixed_window_throttle",
]

View 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",
]

View 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",
]

View File

@@ -32,6 +32,7 @@ def create_all_tables() -> None:
# model metadata with the shared SQLAlchemy base before create_all runs. # model metadata with the shared SQLAlchemy base before create_all runs.
from govoplan_core.admin import models as core_admin_models # noqa: F401 from govoplan_core.admin import models as core_admin_models # noqa: F401
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules) raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules) candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules)
@@ -65,7 +66,7 @@ def bootstrap_dev_data(
api_key_secret: str | None = None, api_key_secret: str | None = None,
tenant_slug: str = "default", tenant_slug: str = "default",
user_email: str = "admin@example.local", user_email: str = "admin@example.local",
user_password: str = "dev-admin", user_password: str = "dev-admin", # noqa: S107 - development bootstrap only.
) -> BootstrapResult: ) -> BootstrapResult:
tenant = session.query(Tenant).filter(Tenant.slug == tenant_slug).one_or_none() tenant = session.query(Tenant).filter(Tenant.slug == tenant_slug).one_or_none()
if tenant is None: if tenant is None:

View File

@@ -1,10 +1,13 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Iterable, Mapping
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
import json import json
import logging
import os import os
from pathlib import Path from pathlib import Path
import re
import sysconfig
from typing import Any from typing import Any
from alembic import command from alembic import command
@@ -15,6 +18,7 @@ from sqlalchemy import create_engine, inspect, text
from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.modules import ModuleMigrationTaskContext, ModuleMigrationTaskResult from govoplan_core.core.modules import ModuleMigrationTaskContext, ModuleMigrationTaskResult
@@ -24,6 +28,8 @@ from govoplan_core.server.config import ManifestFactory
from govoplan_core.server.registry import available_module_manifests, build_platform_registry from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.settings import settings from govoplan_core.settings import settings
logger = logging.getLogger(__name__)
# Historic development databases could be created partly through Alembic and # Historic development databases could be created partly through Alembic and
# partly through Base.metadata.create_all(). In that state Alembic still says # partly through Base.metadata.create_all(). In that state Alembic still says
# "2c..." while the 3d/4e file-storage tables already exist, so a normal # "2c..." while the 3d/4e file-storage tables already exist, so a normal
@@ -44,6 +50,7 @@ MIGRATION_TASK_PHASES = (*PRE_MIGRATION_TASK_PHASES, *POST_MIGRATION_TASK_PHASES
MIGRATION_TRACK_RELEASE = "release" MIGRATION_TRACK_RELEASE = "release"
MIGRATION_TRACK_DEV = "dev" MIGRATION_TRACK_DEV = "dev"
MIGRATION_TRACKS = (MIGRATION_TRACK_RELEASE, MIGRATION_TRACK_DEV) MIGRATION_TRACKS = (MIGRATION_TRACK_RELEASE, MIGRATION_TRACK_DEV)
_SQL_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_NAMESPACE_TABLE_RENAMES = ( _NAMESPACE_TABLE_RENAMES = (
("tenants", "tenancy_tenants"), ("tenants", "tenancy_tenants"),
@@ -164,6 +171,15 @@ _CREATE_ALL_THROUGH_HIERARCHICAL_INDEXES = {
} }
@dataclass(frozen=True, slots=True)
class _LegacyCreateAllSchemaState:
current: str | None
has_no_revision: bool
has_file_storage: bool
has_file_folders: bool
has_create_all_hierarchical_schema: bool
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class MigrationResult: class MigrationResult:
previous_revision: str | None previous_revision: str | None
@@ -201,7 +217,10 @@ def _registered_module_registry(
server_config = get_server_config() server_config = get_server_config()
active_database_url = database_url or settings.database_url active_database_url = database_url or settings.database_url
if active_database_url: if active_database_url:
configure_database(active_database_url) # Registry planning may target a different database than the currently
# configured handle. The global handle is replaced either way, so
# dispose its superseded pool instead of leaking open DBAPI connections.
configure_database(active_database_url, dispose_previous=True)
active_manifest_factories = manifest_factories or tuple(server_config.manifest_factories) active_manifest_factories = manifest_factories or tuple(server_config.manifest_factories)
raw_enabled_modules = tuple(enabled_modules) if enabled_modules is not None else load_startup_enabled_modules(server_config.enabled_modules) raw_enabled_modules = tuple(enabled_modules) if enabled_modules is not None else load_startup_enabled_modules(server_config.enabled_modules)
candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules) candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
@@ -231,10 +250,7 @@ def run_registered_module_migration_tasks(
dry_run: bool = False, dry_run: bool = False,
manifest_factories: tuple[ManifestFactory, ...] = (), manifest_factories: tuple[ManifestFactory, ...] = (),
) -> tuple[dict[str, object], ...]: ) -> tuple[dict[str, object], ...]:
active_phases = tuple(dict.fromkeys(str(item).strip() for item in phases if str(item).strip())) active_phases = _normalized_migration_task_phases(phases)
invalid_phases = tuple(phase for phase in active_phases if phase not in MIGRATION_TASK_PHASES)
if invalid_phases:
raise ValueError("Unsupported module migration task phase(s): " + ", ".join(invalid_phases))
url = database_url or settings.database_url url = database_url or settings.database_url
registry = _registered_module_registry( registry = _registered_module_registry(
database_url=url, database_url=url,
@@ -242,91 +258,192 @@ def run_registered_module_migration_tasks(
manifest_factories=manifest_factories, manifest_factories=manifest_factories,
) )
manifests = {manifest.id: manifest for manifest in registry.manifests()} manifests = {manifest.id: manifest for manifest in registry.manifests()}
ordered_ids = tuple(dict.fromkeys([ ordered_ids = _ordered_migration_task_module_ids(migration_module_order, manifests)
*(str(item).strip() for item in (migration_module_order or ()) if str(item).strip()),
*manifests.keys(),
]))
records: list[dict[str, object]] = [] records: list[dict[str, object]] = []
database = get_database() database = get_database()
with database.SessionLocal() as session: with database.SessionLocal() as session:
for phase in active_phases: for phase in active_phases:
for module_id in ordered_ids: for manifest, task in _iter_phase_migration_tasks(phase, ordered_ids=ordered_ids, manifests=manifests):
manifest = manifests.get(module_id) _run_registered_module_migration_task(
if manifest is None or manifest.migration_spec is None: session,
continue manifest=manifest,
for task in manifest.migration_spec.migration_tasks: task=task,
if task.phase != phase: database_url=url,
continue dry_run=dry_run,
record: dict[str, object] = { records=records,
"module_id": manifest.id, )
"task_id": task.task_id,
"phase": task.phase,
"summary": task.summary,
"task_version": task.task_version,
"safety": task.safety,
"idempotent": task.idempotent,
"dry_run": dry_run,
}
if task.timeout_seconds is not None:
record["timeout_seconds"] = task.timeout_seconds
if not task.idempotent:
record.update({"status": "blocked", "message": "Task is not idempotent."})
records.append(record)
raise ModuleMigrationTaskExecutionError(
f"Module migration task {manifest.id}/{task.task_id} is not idempotent.",
records=tuple(records),
)
if dry_run:
record.update({"status": "skipped", "message": "Dry run; executor was not called."})
records.append(record)
continue
if task.executor is None:
record.update({"status": "blocked", "message": "Task has no executor."})
records.append(record)
raise ModuleMigrationTaskExecutionError(
f"Module migration task {manifest.id}/{task.task_id} has no executor.",
records=tuple(records),
)
context = ModuleMigrationTaskContext(
module_id=manifest.id,
task_id=task.task_id,
phase=task.phase,
database_url=url,
target_version=manifest.version,
session=session,
dry_run=dry_run,
metadata=task.metadata,
)
try:
result = task.executor(context)
normalized = _normalize_migration_task_result(result)
except Exception as exc:
session.rollback()
record.update({
"status": "blocked",
"message": f"{type(exc).__name__}: {exc}",
})
records.append(record)
raise ModuleMigrationTaskExecutionError(
f"Module migration task {manifest.id}/{task.task_id} failed.",
records=tuple(records),
) from exc
record.update({
"status": normalized.status,
"message": normalized.message,
"details": _jsonable_migration_task_details(normalized.details),
})
records.append(record)
if normalized.status == "blocked":
session.rollback()
raise ModuleMigrationTaskExecutionError(
normalized.message or f"Module migration task {manifest.id}/{task.task_id} blocked migration.",
records=tuple(records),
)
session.commit()
return tuple(records) return tuple(records)
def _normalized_migration_task_phases(phases: tuple[str, ...] | list[str]) -> tuple[str, ...]:
active_phases = tuple(dict.fromkeys(str(item).strip() for item in phases if str(item).strip()))
invalid_phases = tuple(phase for phase in active_phases if phase not in MIGRATION_TASK_PHASES)
if invalid_phases:
raise ValueError("Unsupported module migration task phase(s): " + ", ".join(invalid_phases))
return active_phases
def _ordered_migration_task_module_ids(
migration_module_order: tuple[str, ...] | list[str] | None,
manifests: Mapping[str, object],
) -> tuple[str, ...]:
return tuple(dict.fromkeys([
*(str(item).strip() for item in (migration_module_order or ()) if str(item).strip()),
*manifests.keys(),
]))
def _iter_phase_migration_tasks(
phase: str,
*,
ordered_ids: tuple[str, ...],
manifests: Mapping[str, object],
) -> Iterable[tuple[object, object]]:
for module_id in ordered_ids:
manifest = manifests.get(module_id)
migration_spec = getattr(manifest, "migration_spec", None)
if manifest is None or migration_spec is None:
continue
for task in migration_spec.migration_tasks:
if task.phase == phase:
yield manifest, task
def _run_registered_module_migration_task(
session: object,
*,
manifest: object,
task: object,
database_url: str,
dry_run: bool,
records: list[dict[str, object]],
) -> None:
record = _migration_task_record(manifest, task, dry_run=dry_run)
_validate_migration_task_can_run(manifest, task, record, records, dry_run=dry_run)
if dry_run:
record.update({"status": "skipped", "message": "Dry run; executor was not called."})
records.append(record)
return
normalized = _execute_module_migration_task(
session,
manifest=manifest,
task=task,
record=record,
records=records,
database_url=database_url,
dry_run=dry_run,
)
record.update({
"status": normalized.status,
"message": normalized.message,
"details": _jsonable_migration_task_details(normalized.details),
})
records.append(record)
if normalized.status == "blocked":
session.rollback()
raise ModuleMigrationTaskExecutionError(
normalized.message or f"Module migration task {manifest.id}/{task.task_id} blocked migration.",
records=tuple(records),
)
session.commit()
def _migration_task_record(manifest: object, task: object, *, dry_run: bool) -> dict[str, object]:
record: dict[str, object] = {
"module_id": manifest.id,
"task_id": task.task_id,
"phase": task.phase,
"summary": task.summary,
"task_version": task.task_version,
"safety": task.safety,
"idempotent": task.idempotent,
"dry_run": dry_run,
}
if task.timeout_seconds is not None:
record["timeout_seconds"] = task.timeout_seconds
return record
def _validate_migration_task_can_run(
manifest: object,
task: object,
record: dict[str, object],
records: list[dict[str, object]],
*,
dry_run: bool,
) -> None:
if not task.idempotent:
_block_migration_task(
manifest,
task,
record,
records,
message="Task is not idempotent.",
error=f"Module migration task {manifest.id}/{task.task_id} is not idempotent.",
)
if not dry_run and task.executor is None:
_block_migration_task(
manifest,
task,
record,
records,
message="Task has no executor.",
error=f"Module migration task {manifest.id}/{task.task_id} has no executor.",
)
def _block_migration_task(
manifest: object,
task: object,
record: dict[str, object],
records: list[dict[str, object]],
*,
message: str,
error: str,
) -> None:
record.update({"status": "blocked", "message": message})
records.append(record)
raise ModuleMigrationTaskExecutionError(
error,
records=tuple(records),
)
def _execute_module_migration_task(
session: object,
*,
manifest: object,
task: object,
record: dict[str, object],
records: list[dict[str, object]],
database_url: str,
dry_run: bool,
) -> ModuleMigrationTaskResult:
context = ModuleMigrationTaskContext(
module_id=manifest.id,
task_id=task.task_id,
phase=task.phase,
database_url=database_url,
target_version=manifest.version,
session=session,
dry_run=dry_run,
metadata=task.metadata,
)
try:
return _normalize_migration_task_result(task.executor(context))
except Exception as exc:
session.rollback()
record.update({
"status": "blocked",
"message": f"{type(exc).__name__}: {exc}",
})
records.append(record)
raise ModuleMigrationTaskExecutionError(
f"Module migration task {manifest.id}/{task.task_id} failed.",
records=tuple(records),
) from exc
def _normalize_migration_task_result(result: ModuleMigrationTaskResult | None) -> ModuleMigrationTaskResult: def _normalize_migration_task_result(result: ModuleMigrationTaskResult | None) -> ModuleMigrationTaskResult:
if result is None: if result is None:
return ModuleMigrationTaskResult() return ModuleMigrationTaskResult()
@@ -347,11 +464,13 @@ def _jsonable_migration_task_details(value: Mapping[str, Any]) -> Mapping[str, A
def _repo_root() -> Path: def _repo_root() -> Path:
packaged_root = Path(__file__).resolve().parents[3] packaged_root = Path(__file__).resolve().parents[3]
installed_runtime_root = Path(sysconfig.get_path("data")) / "govoplan_core_runtime"
configured = os.environ.get("GOVOPLAN_CORE_SOURCE_ROOT") configured = os.environ.get("GOVOPLAN_CORE_SOURCE_ROOT")
candidates = [ candidates = [
Path(configured).expanduser() if configured else None, Path(configured).expanduser() if configured else None,
Path.cwd(), Path.cwd(),
packaged_root, packaged_root,
installed_runtime_root,
] ]
for candidate in candidates: for candidate in candidates:
if candidate is None: if candidate is None:
@@ -521,19 +640,28 @@ def _backfill_user_lock_state_for_create_all_schema(database_url: str) -> None:
def _row_count(connection, table_name: str) -> int: def _row_count(connection, table_name: str) -> int:
quoted = connection.dialect.identifier_preparer.quote(table_name) quoted = _quoted_table_name(connection, table_name)
return int(connection.execute(text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one()) statement = text(f"SELECT COUNT(*) FROM {quoted}") # noqa: S608 # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
return int(connection.execute(statement).scalar_one())
def _drop_table(connection, table_name: str) -> None: def _drop_table(connection, table_name: str) -> None:
quoted = connection.dialect.identifier_preparer.quote(table_name) quoted = _quoted_table_name(connection, table_name)
connection.execute(text(f"DROP TABLE {quoted}")) statement = text(f"DROP TABLE {quoted}") # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
connection.execute(statement)
def _rename_table(connection, old_name: str, new_name: str) -> None: def _rename_table(connection, old_name: str, new_name: str) -> None:
quoted_old = connection.dialect.identifier_preparer.quote(old_name) quoted_old = _quoted_table_name(connection, old_name)
quoted_new = connection.dialect.identifier_preparer.quote(new_name) quoted_new = _quoted_table_name(connection, new_name)
connection.execute(text(f"ALTER TABLE {quoted_old} RENAME TO {quoted_new}")) statement = text(f"ALTER TABLE {quoted_old} RENAME TO {quoted_new}") # nosec B608 # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
connection.execute(statement)
def _quoted_table_name(connection, table_name: str) -> str:
if not _SQL_IDENTIFIER_RE.fullmatch(table_name):
raise ValueError(f"Unsafe table identifier: {table_name!r}")
return connection.dialect.identifier_preparer.quote(table_name)
def _reconcile_scope_table_names(connection, tables: set[str]) -> bool: def _reconcile_scope_table_names(connection, tables: set[str]) -> bool:
@@ -707,7 +835,8 @@ def reconcile_covered_alembic_dependency_heads(
for revision_id in current: for revision_id in current:
try: try:
revision = script.get_revision(revision_id) revision = script.get_revision(revision_id)
except Exception: except Exception as exc:
logger.debug("Skipping Alembic revision %s while pruning dependency heads: %s", revision_id, exc, exc_info=True)
continue continue
ancestors = { ancestors = {
item.revision item.revision
@@ -741,58 +870,68 @@ def reconcile_legacy_create_all_schema(
""" """
url = database_url or settings.database_url url = database_url or settings.database_url
engine = create_engine(url) state = _legacy_create_all_schema_state(url)
try: target = _legacy_create_all_reconciliation_target(state)
with engine.connect() as connection:
heads = MigrationContext.configure(connection).get_current_heads()
current = heads[0] if len(heads) == 1 else None
has_no_revision = len(heads) == 0
schema = inspect(connection)
tables = set(schema.get_table_names())
has_file_storage = _FILE_STORAGE_TABLES.issubset(tables) and all(
_has_columns(schema, table, _FILE_STORAGE_COLUMNS[table])
for table in _FILE_STORAGE_TABLES
)
has_file_folders = _FILE_FOLDER_TABLES.issubset(tables) and _has_columns(
schema,
"file_folders",
_FILE_STORAGE_COLUMNS["file_folders"],
)
has_create_all_hierarchical_schema = _has_create_all_schema_through_hierarchical_settings(schema, tables)
finally:
engine.dispose()
target: str | None = None
if current == REVISION_AUTH_RBAC and has_file_storage and has_file_folders:
target = REVISION_FILE_FOLDERS
elif current == REVISION_AUTH_RBAC and has_file_storage:
target = REVISION_FILE_STORAGE
elif current == REVISION_FILE_STORAGE and has_file_folders:
target = REVISION_FILE_FOLDERS
elif current == REVISION_FILE_FOLDERS and has_create_all_hierarchical_schema:
# Development DBs may be stamped at 4e after earlier create_all
# reconciliation even though later tables/columns were already created
# by a newer model set. Skip only when that complete known schema is
# present, then let newer migrations such as mail credential usernames
# run normally.
_backfill_user_lock_state_for_create_all_schema(url)
target = REVISION_HIERARCHICAL_SETTINGS
elif has_no_revision and has_create_all_hierarchical_schema:
_backfill_user_lock_state_for_create_all_schema(url)
target = REVISION_HIERARCHICAL_SETTINGS
elif has_no_revision and has_file_storage and has_file_folders:
# This is the other create_all-only development shape. The strict
# column checks above ensure that we only stamp a complete known schema.
target = REVISION_FILE_FOLDERS
if target is None: if target is None:
return None return None
if target == REVISION_HIERARCHICAL_SETTINGS:
_backfill_user_lock_state_for_create_all_schema(url)
command.stamp(alembic_config(database_url=url, migration_track=migration_track), target) command.stamp(alembic_config(database_url=url, migration_track=migration_track), target)
return target return target
def _legacy_create_all_schema_state(database_url: str) -> _LegacyCreateAllSchemaState:
engine = create_engine(database_url)
try:
with engine.connect() as connection:
heads = MigrationContext.configure(connection).get_current_heads()
schema = inspect(connection)
tables = set(schema.get_table_names())
return _legacy_create_all_schema_state_from_inspection(heads, schema, tables)
finally:
engine.dispose()
def _legacy_create_all_schema_state_from_inspection(
heads: tuple[str, ...],
schema: object,
tables: set[str],
) -> _LegacyCreateAllSchemaState:
has_file_storage = _FILE_STORAGE_TABLES.issubset(tables) and all(
_has_columns(schema, table, _FILE_STORAGE_COLUMNS[table])
for table in _FILE_STORAGE_TABLES
)
has_file_folders = _FILE_FOLDER_TABLES.issubset(tables) and _has_columns(
schema,
"file_folders",
_FILE_STORAGE_COLUMNS["file_folders"],
)
return _LegacyCreateAllSchemaState(
current=heads[0] if len(heads) == 1 else None,
has_no_revision=len(heads) == 0,
has_file_storage=has_file_storage,
has_file_folders=has_file_folders,
has_create_all_hierarchical_schema=_has_create_all_schema_through_hierarchical_settings(schema, tables),
)
def _legacy_create_all_reconciliation_target(state: _LegacyCreateAllSchemaState) -> str | None:
if state.current == REVISION_AUTH_RBAC and state.has_file_storage and state.has_file_folders:
return REVISION_FILE_FOLDERS
if state.current == REVISION_AUTH_RBAC and state.has_file_storage:
return REVISION_FILE_STORAGE
if state.current == REVISION_FILE_STORAGE and state.has_file_folders:
return REVISION_FILE_FOLDERS
if state.current == REVISION_FILE_FOLDERS and state.has_create_all_hierarchical_schema:
return REVISION_HIERARCHICAL_SETTINGS
if state.has_no_revision and state.has_create_all_hierarchical_schema:
return REVISION_HIERARCHICAL_SETTINGS
if state.has_no_revision and state.has_file_storage and state.has_file_folders:
return REVISION_FILE_FOLDERS
return None
def migrate_database( def migrate_database(
*, *,
database_url: str | None = None, database_url: str | None = None,

View File

@@ -0,0 +1,100 @@
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
import time
import weakref
from collections.abc import Iterator
from sqlalchemy import event
from sqlalchemy.engine import Connection, Engine
from sqlalchemy.engine.interfaces import ExecutionContext
@dataclass(slots=True)
class QueryMetrics:
query_count: int = 0
total_ms: float = 0.0
slowest_ms: float = 0.0
error_count: int = 0
def record(self, elapsed_ms: float, *, failed: bool = False) -> None:
self.query_count += 1
self.total_ms += elapsed_ms
self.slowest_ms = max(self.slowest_ms, elapsed_ms)
if failed:
self.error_count += 1
_current_metrics: ContextVar[QueryMetrics | None] = ContextVar("govoplan_query_metrics", default=None)
_instrumented_engines: weakref.WeakSet[Engine] = weakref.WeakSet()
_START_STACK_KEY = "govoplan_query_metric_starts"
@contextmanager
def collect_query_metrics() -> Iterator[QueryMetrics]:
metrics = QueryMetrics()
token = _current_metrics.set(metrics)
try:
yield metrics
finally:
_current_metrics.reset(token)
def current_query_metrics() -> QueryMetrics | None:
return _current_metrics.get()
def instrument_engine(engine: Engine) -> Engine:
if engine in _instrumented_engines:
return engine
event.listen(engine, "before_cursor_execute", _before_cursor_execute)
event.listen(engine, "after_cursor_execute", _after_cursor_execute)
event.listen(engine, "handle_error", _handle_error)
_instrumented_engines.add(engine)
return engine
def _before_cursor_execute(
conn: Connection,
_cursor: object,
_statement: str,
_parameters: object,
_context: ExecutionContext,
_executemany: bool,
) -> None:
if current_query_metrics() is None:
return
starts = conn.info.setdefault(_START_STACK_KEY, [])
starts.append(time.perf_counter())
def _after_cursor_execute(
conn: Connection,
_cursor: object,
_statement: str,
_parameters: object,
_context: ExecutionContext,
_executemany: bool,
) -> None:
_record_elapsed(conn, failed=False)
def _handle_error(exception_context: object) -> None:
conn = getattr(exception_context, "connection", None)
if isinstance(conn, Connection):
_record_elapsed(conn, failed=True)
def _record_elapsed(conn: Connection, *, failed: bool) -> None:
metrics = current_query_metrics()
if metrics is None:
return
starts = conn.info.get(_START_STACK_KEY)
if not starts:
return
started_at = starts.pop()
elapsed_ms = (time.perf_counter() - started_at) * 1000
metrics.record(elapsed_ms, failed=failed)

View File

@@ -1,12 +1,23 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Generator, Mapping from collections.abc import Generator, Mapping
import os
from typing import Any from typing import Any
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.db.query_metrics import instrument_engine
_POOL_SETTING_BOUNDS: dict[str, tuple[int, int]] = {
"GOVOPLAN_DB_POOL_SIZE": (1, 100),
"GOVOPLAN_DB_MAX_OVERFLOW": (0, 200),
"GOVOPLAN_DB_POOL_TIMEOUT_SECONDS": (1, 300),
"GOVOPLAN_DB_POOL_RECYCLE_SECONDS": (0, 86_400),
}
def default_connect_args(database_url: str) -> dict[str, Any]: def default_connect_args(database_url: str) -> dict[str, Any]:
return {"check_same_thread": False} if database_url.startswith("sqlite") else {} return {"check_same_thread": False} if database_url.startswith("sqlite") else {}
@@ -22,13 +33,46 @@ def create_database_engine(
merged_connect_args = dict(default_connect_args(database_url)) merged_connect_args = dict(default_connect_args(database_url))
if connect_args: if connect_args:
merged_connect_args.update(connect_args) merged_connect_args.update(connect_args)
return create_engine(database_url, pool_pre_ping=pool_pre_ping, connect_args=merged_connect_args, **kwargs) if not database_url.startswith("sqlite"):
kwargs.setdefault(
"pool_size",
_pool_setting("GOVOPLAN_DB_POOL_SIZE", 5),
)
kwargs.setdefault(
"max_overflow",
_pool_setting("GOVOPLAN_DB_MAX_OVERFLOW", 10),
)
kwargs.setdefault(
"pool_timeout",
_pool_setting("GOVOPLAN_DB_POOL_TIMEOUT_SECONDS", 30),
)
kwargs.setdefault(
"pool_recycle",
_pool_setting("GOVOPLAN_DB_POOL_RECYCLE_SECONDS", 1800),
)
return instrument_engine(create_engine(database_url, pool_pre_ping=pool_pre_ping, connect_args=merged_connect_args, **kwargs))
def _pool_setting(name: str, default: int) -> int:
raw = os.environ.get(name)
if raw is None:
return default
try:
value = int(raw)
except ValueError as exc:
raise ValueError(f"{name} must be an integer") from exc
minimum, maximum = _POOL_SETTING_BOUNDS[name]
if not minimum <= value <= maximum:
raise ValueError(
f"{name} must be between {minimum} and {maximum}",
)
return value
class DatabaseHandle: class DatabaseHandle:
def __init__(self, database_url: str, *, engine: Engine | None = None) -> None: def __init__(self, database_url: str, *, engine: Engine | None = None) -> None:
self.database_url = database_url self.database_url = database_url
self.engine = engine or create_database_engine(database_url) self.engine = instrument_engine(engine) if engine is not None else create_database_engine(database_url)
self.SessionLocal = sessionmaker(bind=self.engine, autoflush=False, autocommit=False, expire_on_commit=False) self.SessionLocal = sessionmaker(bind=self.engine, autoflush=False, autocommit=False, expire_on_commit=False)
def session(self) -> Session: def session(self) -> Session:

View File

@@ -10,6 +10,9 @@ from collections.abc import Iterable, Sequence
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from sqlalchemy.engine import make_url
from sqlalchemy.exc import ArgumentError
from govoplan_core.core.discovery import iter_module_entry_points from govoplan_core.core.discovery import iter_module_entry_points
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.modules import ModuleManifest from govoplan_core.core.modules import ModuleManifest
@@ -34,6 +37,7 @@ class DevserverState:
config: GovoplanServerConfig config: GovoplanServerConfig
registry: PlatformRegistry registry: PlatformRegistry
reload_dirs: list[str] reload_dirs: list[str]
reload_module_ids: tuple[str, ...] | None
def _config_module_runtime_root(config_path: str | None) -> Path | None: def _config_module_runtime_root(config_path: str | None) -> Path | None:
@@ -112,6 +116,14 @@ def validate_sqlite_database_url(database_url: str) -> None:
) )
def redacted_database_url(database_url: str) -> str:
try:
return make_url(database_url).render_as_string(hide_password=True)
except (ArgumentError, TypeError, ValueError):
scheme = database_url.partition(":")[0].strip()
return f"{scheme}:<redacted>" if scheme else "<redacted>"
def _env_truthy(value: str | None) -> bool: def _env_truthy(value: str | None) -> bool:
return value is not None and value.strip().lower() in {"1", "true", "yes", "on"} return value is not None and value.strip().lower() in {"1", "true", "yes", "on"}
@@ -179,6 +191,9 @@ def _manifest_source_roots(manifest: ModuleManifest) -> tuple[Path, ...]:
for provider in manifest.tenant_summary_providers: for provider in manifest.tenant_summary_providers:
roots.extend(_source_roots_for_object(provider)) roots.extend(_source_roots_for_object(provider))
for provider in manifest.tenant_summary_batch_providers:
roots.extend(_source_roots_for_object(provider))
for providers in manifest.delete_veto_providers.values(): for providers in manifest.delete_veto_providers.values():
for provider in providers: for provider in providers:
roots.extend(_source_roots_for_object(provider)) roots.extend(_source_roots_for_object(provider))
@@ -226,20 +241,44 @@ def build_reload_dirs(
config_path: str | None = None, config_path: str | None = None,
registry: PlatformRegistry | None = None, registry: PlatformRegistry | None = None,
extra_dirs: Sequence[str] = (), extra_dirs: Sequence[str] = (),
module_ids: Sequence[str] | None = None,
) -> list[str]: ) -> list[str]:
active_registry = registry or build_platform_registry(config.enabled_modules, manifest_factories=config.manifest_factories) active_registry = registry or build_platform_registry(config.enabled_modules, manifest_factories=config.manifest_factories)
manifests = active_registry.manifests() manifests = active_registry.manifests()
enabled_module_ids = {manifest.id for manifest in manifests} enabled_module_ids = {manifest.id for manifest in manifests}
selected_module_ids = (
enabled_module_ids
if module_ids is None
else {module_id.strip() for module_id in module_ids if module_id.strip()}
)
unknown_module_ids = selected_module_ids - enabled_module_ids
if unknown_module_ids:
raise SystemExit(
"Reload modules are not enabled: "
+ ", ".join(sorted(unknown_module_ids))
)
roots: list[Path | str] = [] roots: list[Path | str] = []
roots.extend(_config_source_roots(config_path)) roots.extend(_config_source_roots(config_path))
for factory in config.manifest_factories: for factory in config.manifest_factories:
try:
manifest = factory()
except TypeError:
manifest = None
if (
module_ids is not None
and isinstance(manifest, ModuleManifest)
and manifest.id not in selected_module_ids
):
continue
roots.extend(_source_roots_for_object(factory)) roots.extend(_source_roots_for_object(factory))
roots.extend(_entry_point_source_roots(enabled_module_ids)) roots.extend(_entry_point_source_roots(selected_module_ids))
for manifest in manifests: for manifest in manifests:
if manifest.id not in selected_module_ids:
continue
roots.extend(_manifest_source_roots(manifest)) roots.extend(_manifest_source_roots(manifest))
roots.extend(extra_dirs) roots.extend(extra_dirs)
@@ -254,11 +293,32 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser.add_argument("--port", type=int, default=8000, help="Port to bind. Default: 8000.") parser.add_argument("--port", type=int, default=8000, help="Port to bind. Default: 8000.")
parser.add_argument("--no-reload", action="store_true", help="Disable uvicorn reload.") parser.add_argument("--no-reload", action="store_true", help="Disable uvicorn reload.")
parser.add_argument("--reload-dir", action="append", default=[], help="Additional directory to watch. May be passed multiple times.") parser.add_argument("--reload-dir", action="append", default=[], help="Additional directory to watch. May be passed multiple times.")
reload_scope = parser.add_mutually_exclusive_group()
reload_scope.add_argument(
"--reload-module",
action="append",
default=None,
metavar="MODULE_ID",
help=(
"Watch only this enabled module in addition to core/config sources. "
"May be passed multiple times."
),
)
reload_scope.add_argument(
"--reload-core-only",
action="store_true",
help="Watch core/config sources but no optional module source trees.",
)
parser.add_argument("--smoke", action="store_true", help="Prepare runtime paths, run app startup, print effective paths, and exit without uvicorn.") parser.add_argument("--smoke", action="store_true", help="Prepare runtime paths, run app startup, print effective paths, and exit without uvicorn.")
return parser.parse_args(argv) return parser.parse_args(argv)
def prepare_devserver(config_path: str | None, *, extra_reload_dirs: Sequence[str] = ()) -> DevserverState: def prepare_devserver(
config_path: str | None,
*,
extra_reload_dirs: Sequence[str] = (),
reload_module_ids: Sequence[str] | None = None,
) -> DevserverState:
runtime_root = apply_runtime_defaults(config_path) runtime_root = apply_runtime_defaults(config_path)
database_url = os.getenv("DATABASE_URL", "") database_url = os.getenv("DATABASE_URL", "")
validate_sqlite_database_url(database_url) validate_sqlite_database_url(database_url)
@@ -280,7 +340,13 @@ def prepare_devserver(config_path: str | None, *, extra_reload_dirs: Sequence[st
) )
enabled_modules = load_startup_enabled_modules(config.enabled_modules, available=available_modules) enabled_modules = load_startup_enabled_modules(config.enabled_modules, available=available_modules)
registry = build_platform_registry(enabled_modules, manifest_factories=config.manifest_factories) registry = build_platform_registry(enabled_modules, manifest_factories=config.manifest_factories)
reload_dirs = build_reload_dirs(config, config_path=config_path, registry=registry, extra_dirs=extra_reload_dirs) reload_dirs = build_reload_dirs(
config,
config_path=config_path,
registry=registry,
extra_dirs=extra_reload_dirs,
module_ids=reload_module_ids,
)
return DevserverState( return DevserverState(
config_path=config_path, config_path=config_path,
runtime_root=runtime_root, runtime_root=runtime_root,
@@ -289,6 +355,11 @@ def prepare_devserver(config_path: str | None, *, extra_reload_dirs: Sequence[st
config=config, config=config,
registry=registry, registry=registry,
reload_dirs=reload_dirs, reload_dirs=reload_dirs,
reload_module_ids=(
None
if reload_module_ids is None
else tuple(sorted(set(reload_module_ids)))
),
) )
@@ -297,11 +368,11 @@ def print_devserver_summary(state: DevserverState, *, app: str, no_reload: bool)
print(f"Config: {state.config_path or DEFAULT_CONFIG}") print(f"Config: {state.config_path or DEFAULT_CONFIG}")
print(f"Runtime root: {state.runtime_root}") print(f"Runtime root: {state.runtime_root}")
if state.database_url: if state.database_url:
print(f"Database: {state.database_url}") print(f"Database: {redacted_database_url(state.database_url)}")
if state.database_url.startswith("postgresql"): if state.database_url.startswith("postgresql"):
pgtools_url = os.getenv("GOVOPLAN_DATABASE_URL_PGTOOLS") pgtools_url = os.getenv("GOVOPLAN_DATABASE_URL_PGTOOLS")
if pgtools_url: if pgtools_url:
print(f"PostgreSQL tools URL: {pgtools_url}") print(f"PostgreSQL tools URL: {redacted_database_url(pgtools_url)}")
if state.bootstrap_db_path is not None: if state.bootstrap_db_path is not None:
bootstrap_state = "enabled" if getattr(state.config.settings, "dev_bootstrap_enabled", False) else "disabled by DEV_BOOTSTRAP_ENABLED" bootstrap_state = "enabled" if getattr(state.config.settings, "dev_bootstrap_enabled", False) else "disabled by DEV_BOOTSTRAP_ENABLED"
print(f"Dev bootstrap for missing SQLite DB: {bootstrap_state} ({state.bootstrap_db_path})") print(f"Dev bootstrap for missing SQLite DB: {bootstrap_state} ({state.bootstrap_db_path})")
@@ -309,6 +380,15 @@ def print_devserver_summary(state: DevserverState, *, app: str, no_reload: bool)
if no_reload: if no_reload:
print("Reload: disabled") print("Reload: disabled")
else: else:
if state.reload_module_ids is None:
print("Reload scope: core/config plus all enabled modules")
elif state.reload_module_ids:
print(
"Reload scope: core/config plus "
+ ", ".join(state.reload_module_ids)
)
else:
print("Reload scope: core/config only")
print("Reload dirs:") print("Reload dirs:")
for directory in state.reload_dirs: for directory in state.reload_dirs:
print(f" - {directory}") print(f" - {directory}")
@@ -342,7 +422,14 @@ def main(argv: Sequence[str] | None = None) -> int:
os.environ["GOVOPLAN_SERVER_CONFIG"] = args.config os.environ["GOVOPLAN_SERVER_CONFIG"] = args.config
config_path = args.config or os.getenv("GOVOPLAN_SERVER_CONFIG") config_path = args.config or os.getenv("GOVOPLAN_SERVER_CONFIG")
state = prepare_devserver(config_path, extra_reload_dirs=args.reload_dir) reload_module_ids: Sequence[str] | None = args.reload_module
if args.reload_core_only:
reload_module_ids = ()
state = prepare_devserver(
config_path,
extra_reload_dirs=args.reload_dir,
reload_module_ids=reload_module_ids,
)
print_devserver_summary(state, app=args.app, no_reload=args.no_reload) print_devserver_summary(state, app=args.app, no_reload=args.no_reload)
if args.smoke: if args.smoke:

View File

@@ -67,6 +67,32 @@ class ImapConfig(ImapServerConfig):
password: str | None = None password: str | None = None
def normalize_split_transport_credentials(value: object) -> object:
"""Move legacy transport username/password fields into credentials."""
if not isinstance(value, dict):
return value
data = dict(value)
credentials = data.get("credentials") if isinstance(data.get("credentials"), dict) else {}
credentials = {key: dict(item) for key, item in credentials.items() if isinstance(item, dict)}
for protocol in ("smtp", "imap"):
transport = data.get(protocol)
if not isinstance(transport, dict):
continue
next_transport = dict(transport)
next_credentials = dict(credentials.get(protocol) or {})
for field in ("username", "password"):
if field in next_transport and field not in next_credentials:
next_credentials[field] = next_transport[field]
next_transport.pop(field, None)
next_transport.pop("enabled", None)
data[protocol] = next_transport
if next_credentials:
credentials[protocol] = next_credentials
if credentials:
data["credentials"] = credentials
return data
def default_smtp_port(security: TransportSecurity | str | None) -> int: def default_smtp_port(security: TransportSecurity | str | None) -> int:
if security == TransportSecurity.TLS or security == "tls": if security == TransportSecurity.TLS or security == "tls":
return 465 return 465

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
"""Compatibility facade for privacy retention policy. """Compatibility facade for privacy retention policy.
Policy-owned behavior is provided by ``govoplan-policy`` through the Policy-owned behavior is provided by ``govoplan-policy`` through the
@@ -7,27 +5,22 @@ Policy-owned behavior is provided by ``govoplan-policy`` through the
without importing policy implementation code from core. without importing policy implementation code from core.
""" """
from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Mapping from typing import Any, Mapping
from govoplan_core.core.policy import CAPABILITY_POLICY_PRIVACY_RETENTION, PrivacyRetentionService from govoplan_core.core.policy import CAPABILITY_POLICY_PRIVACY_RETENTION, PrivacyRetentionService
from govoplan_core.core.runtime import get_registry from govoplan_core.core.runtime import get_registry
from govoplan_core.privacy.schemas import (
RETENTION_DAY_KEYS as RETENTION_DAY_KEYS,
RETENTION_POLICY_FIELD_KEYS,
default_allow_lower_level_limits as default_allow_lower_level_limits,
normalize_allow_lower_level_limits,
)
PRIVACY_POLICY_SETTINGS_KEY = "privacy_retention_policy" PRIVACY_POLICY_SETTINGS_KEY = "privacy_retention_policy"
RETENTION_DAY_KEYS = (
"raw_campaign_json_retention_days",
"generated_eml_retention_days",
"stored_report_detail_retention_days",
"mock_mailbox_retention_days",
"audit_detail_retention_days",
)
RETENTION_POLICY_FIELD_KEYS = (
"store_raw_campaign_json",
*RETENTION_DAY_KEYS,
"audit_detail_level",
)
_DEFAULT_POLICY = { _DEFAULT_POLICY = {
"store_raw_campaign_json": True, "store_raw_campaign_json": True,
"raw_campaign_json_retention_days": None, "raw_campaign_json_retention_days": None,
@@ -122,28 +115,10 @@ def _policy_data(settings_payload: Mapping[str, Any] | None) -> dict[str, Any]:
for key in RETENTION_POLICY_FIELD_KEYS: for key in RETENTION_POLICY_FIELD_KEYS:
if key in raw: if key in raw:
payload[key] = raw[key] payload[key] = raw[key]
payload["allow_lower_level_limits"] = _normalize_allow_lower_level_limits(raw.get("allow_lower_level_limits"), fill_defaults=True) payload["allow_lower_level_limits"] = normalize_allow_lower_level_limits(raw.get("allow_lower_level_limits"), fill_defaults=True)
return payload return payload
def _normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
if value in (None, ""):
return default_allow_lower_level_limits() if fill_defaults else None
if not isinstance(value, Mapping):
raise ValueError("allow_lower_level_limits must be an object")
normalized = default_allow_lower_level_limits() if fill_defaults else {}
for key, allowed in value.items():
clean_key = str(key)
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
raise ValueError(f"Unknown retention policy field: {clean_key}")
normalized[clean_key] = bool(allowed)
return normalized
def default_allow_lower_level_limits() -> dict[str, bool]:
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
def privacy_policy_from_settings(item: object) -> Any: def privacy_policy_from_settings(item: object) -> Any:
service = _runtime_service() service = _runtime_service()
if service is not None: if service is not None:

View File

@@ -0,0 +1,83 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
RETENTION_DAY_KEYS = (
"raw_campaign_json_retention_days",
"generated_eml_retention_days",
"stored_report_detail_retention_days",
"mock_mailbox_retention_days",
"audit_detail_retention_days",
)
RETENTION_POLICY_FIELD_KEYS = (
"store_raw_campaign_json",
*RETENTION_DAY_KEYS,
"audit_detail_level",
)
def default_allow_lower_level_limits() -> dict[str, bool]:
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
def normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
if value in (None, ""):
return default_allow_lower_level_limits() if fill_defaults else None
if not isinstance(value, dict):
raise ValueError("allow_lower_level_limits must be an object")
normalized = default_allow_lower_level_limits() if fill_defaults else {}
for key, allowed in value.items():
clean_key = str(key)
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
raise ValueError(f"Unknown retention policy field: {clean_key}")
normalized[clean_key] = bool(allowed)
return normalized
class PrivacyRetentionPolicyItem(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool = True
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] = "full"
allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits)
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return normalize_allow_lower_level_limits(value, fill_defaults=True)
class PrivacyRetentionPolicyPatchItem(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool | None = None
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] | None = None
allow_lower_level_limits: dict[str, bool] | None = None
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return normalize_allow_lower_level_limits(value, fill_defaults=False)
__all__ = [
"RETENTION_DAY_KEYS",
"RETENTION_POLICY_FIELD_KEYS",
"PrivacyRetentionPolicyItem",
"PrivacyRetentionPolicyPatchItem",
"default_allow_lower_level_limits",
"normalize_allow_lower_level_limits",
]

View 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",
]

View File

@@ -0,0 +1,105 @@
from __future__ import annotations
import urllib.parse
import urllib.request
from dataclasses import dataclass
from typing import Mapping
from govoplan_core.security.outbound_http import (
bounded_response_bytes,
build_outbound_http_opener,
validate_outbound_http_url,
)
@dataclass(frozen=True, slots=True)
class HttpFetchResponse:
status: int
headers: dict[str, str]
body: bytes
def text(self, encoding: str = "utf-8") -> str:
return self.body.decode(encoding)
def is_http_url(value: str) -> bool:
try:
validate_http_url(value)
except ValueError:
return False
return True
def validate_http_url(value: str, *, label: str = "URL") -> str:
parsed = urllib.parse.urlparse(str(value).strip())
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError(f"{label} must be an absolute HTTP(S) URL.")
if parsed.username or parsed.password:
raise ValueError(f"{label} must not include embedded credentials.")
return urllib.parse.urlunparse(parsed)
def fetch_http(
url: str,
*,
timeout: float = 15,
label: str = "URL",
method: str = "GET",
headers: Mapping[str, str] | None = None,
max_bytes: int | None = None,
) -> HttpFetchResponse:
validated_url = validate_outbound_http_url(url, label=label)
request = urllib.request.Request( # noqa: S310 - URL is restricted to validated HTTP(S).
validated_url,
headers=dict(headers or {}),
method=method,
)
opener = build_outbound_http_opener(_PolicyRedirectHandler(label=label))
with opener.open(request, timeout=timeout) as response: # noqa: S310 - URL and every redirect are policy-validated. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
response_headers = dict(response.headers.items())
return HttpFetchResponse(
status=int(getattr(response, "status", 0)),
headers=response_headers,
body=bounded_response_bytes(
response,
headers=response_headers,
max_bytes=max_bytes,
label=f"{label} response",
),
)
def fetch_http_text(
url: str,
*,
timeout: float = 15,
label: str = "URL",
method: str = "GET",
headers: Mapping[str, str] | None = None,
encoding: str = "utf-8",
max_bytes: int | None = None,
) -> str:
return fetch_http(url, timeout=timeout, label=label, method=method, headers=headers, max_bytes=max_bytes).text(encoding)
class _PolicyRedirectHandler(urllib.request.HTTPRedirectHandler):
def __init__(self, *, label: str) -> None:
super().__init__()
self._label = label
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
candidate = validate_outbound_http_url(newurl, label=f"{self._label} redirect")
previous = urllib.parse.urlparse(req.full_url)
redirected = urllib.parse.urlparse(candidate)
if previous.scheme.lower() == "https" and redirected.scheme.lower() != "https":
return None
new_request = super().redirect_request(req, fp, code, msg, headers, candidate)
if new_request is not None and _http_origin(previous) != _http_origin(redirected):
for header in ("Authorization", "Proxy-Authorization", "Cookie", "Cookie2"):
new_request.remove_header(header)
return new_request
def _http_origin(parsed: urllib.parse.ParseResult) -> tuple[str, str, int]:
scheme = parsed.scheme.lower()
return scheme, (parsed.hostname or "").lower(), parsed.port or (443 if scheme == "https" else 80)

View File

@@ -95,6 +95,11 @@ MODULE_SYSTEM_SCOPES = frozenset(
for legacy, module in LEGACY_TO_MODULE_SCOPES.items() for legacy, module in LEGACY_TO_MODULE_SCOPES.items()
if legacy.startswith("system:") if legacy.startswith("system:")
) )
LEGACY_SYSTEM_SCOPES = frozenset(
legacy
for legacy in LEGACY_TO_MODULE_SCOPES
if legacy.startswith("system:")
)
MODULE_TENANT_SCOPES = frozenset(LEGACY_TO_MODULE_SCOPES.values()) - MODULE_SYSTEM_SCOPES MODULE_TENANT_SCOPES = frozenset(LEGACY_TO_MODULE_SCOPES.values()) - MODULE_SYSTEM_SCOPES
@@ -110,7 +115,7 @@ def compatible_required_scopes(required: str) -> tuple[str, ...]:
def scopes_grant_compatible(scopes: Iterable[str], required: str) -> bool: def scopes_grant_compatible(scopes: Iterable[str], required: str) -> bool:
granted = list(scopes) granted = list(scopes)
if required in MODULE_SYSTEM_SCOPES: if required in MODULE_SYSTEM_SCOPES or required in LEGACY_SYSTEM_SCOPES:
return "*" in granted or "system:*" in granted or any( return "*" in granted or "system:*" in granted or any(
scope != "tenant:*" and scopes_grant([scope], alias) scope != "tenant:*" and scopes_grant([scope], alias)
for scope in granted for scope in granted

View 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)

View File

@@ -3,6 +3,8 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Iterable from typing import Iterable
from govoplan_core.security.scope_aliases import LEGACY_SCOPE_ALIASES
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class PermissionDefinition: class PermissionDefinition:
@@ -96,28 +98,6 @@ KNOWN_SCOPES = frozenset(item.scope for item in ALL_PERMISSIONS)
TENANT_SCOPES = frozenset(item.scope for item in TENANT_PERMISSIONS) TENANT_SCOPES = frozenset(item.scope for item in TENANT_PERMISSIONS)
SYSTEM_SCOPES = frozenset(item.scope for item in SYSTEM_PERMISSIONS) SYSTEM_SCOPES = frozenset(item.scope for item in SYSTEM_PERMISSIONS)
LEGACY_SCOPE_ALIASES: dict[str, frozenset[str]] = {
# Only names that are no longer canonical remain runtime aliases. Canonical
# permissions keep their narrow meaning; the Alembic migration expands old
# role records once so upgraded installations do not lose prior access.
"campaign:write": frozenset({
"campaign:create", "campaign:update", "campaign:copy", "campaign:archive", "campaign:delete", "campaign:share",
"recipients:read", "recipients:write", "recipients:import",
}),
"attachments:read": frozenset({"files:read", "files:download"}),
"attachments:write": frozenset({"files:upload", "files:organize", "files:share", "files:delete"}),
"admin:users": frozenset({
"admin:users:read", "admin:users:create", "admin:users:update", "admin:users:suspend",
"admin:groups:read", "admin:groups:write", "admin:groups:manage_members",
"admin:roles:read", "admin:roles:write", "admin:roles:assign",
}),
"admin:users:write": frozenset({"admin:users:create", "admin:users:update", "admin:users:suspend", "admin:roles:assign", "admin:groups:manage_members"}),
"admin:api_keys:write": frozenset({"admin:api_keys:create", "admin:api_keys:revoke"}),
"admin:settings": frozenset({"admin:settings:read", "admin:settings:write", "admin:api_keys:read", "admin:api_keys:create", "admin:api_keys:revoke"}),
"system:tenants:write": frozenset({"system:tenants:create", "system:tenants:update", "system:tenants:suspend"}),
"system:access:write": frozenset({"system:access:assign", "system:roles:assign", "system:accounts:create", "system:accounts:update", "system:accounts:suspend"}),
}
DEFAULT_TENANT_ROLES: dict[str, dict[str, object]] = { DEFAULT_TENANT_ROLES: dict[str, dict[str, object]] = {
"owner": {"name": "Owner", "description": "Full tenant access, including administration and delivery.", "permissions": ["tenant:*"], "is_builtin": True, "is_assignable": True}, "owner": {"name": "Owner", "description": "Full tenant access, including administration and delivery.", "permissions": ["tenant:*"], "is_builtin": True, "is_assignable": True},
"tenant_admin": {"name": "Tenant administrator", "description": "Manage tenant settings, users, groups and roles. Real delivery remains separately delegable.", "permissions": [ "tenant_admin": {"name": "Tenant administrator", "description": "Manage tenant settings, users, groups and roles. Real delivery remains separately delegable.", "permissions": [

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import re import re
from collections.abc import Mapping from collections.abc import Mapping
from typing import Any
def sensitive_key_tokens(key: object) -> set[str]: def sensitive_key_tokens(key: object) -> set[str]:
value = str(key).strip() value = str(key).strip()
@@ -40,7 +40,7 @@ def contains_plain_secret(value: object) -> bool:
normalized_key = str(key).strip().casefold().replace("-", "_") normalized_key = str(key).strip().casefold().replace("-", "_")
if normalized_key in {"credential_ref", "secret_ref", "secret_reference"}: if normalized_key in {"credential_ref", "secret_ref", "secret_reference"}:
continue continue
if is_sensitive_key(key) and item not in (None, "", {"secret_ref": ""}): if is_sensitive_key(key) and item not in (None, "", {"secret_ref": ""}): # nosec B105 - empty redaction sentinels.
return True return True
if isinstance(item, Mapping) and contains_plain_secret(item): if isinstance(item, Mapping) and contains_plain_secret(item):
return True return True

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
LEGACY_SCOPE_ALIASES: dict[str, frozenset[str]] = {
# Only names that are no longer canonical remain runtime aliases. Canonical
# permissions keep their narrow meaning; migrations expand old role records
# once so upgraded installations do not lose prior access.
"campaign:write": frozenset({
"campaign:create",
"campaign:update",
"campaign:copy",
"campaign:archive",
"campaign:delete",
"campaign:share",
"recipients:read",
"recipients:write",
"recipients:import",
}),
"attachments:read": frozenset({"files:read", "files:download"}),
"attachments:write": frozenset({"files:upload", "files:organize", "files:share", "files:delete"}),
"admin:users": frozenset({
"admin:users:read",
"admin:users:create",
"admin:users:update",
"admin:users:suspend",
"admin:groups:read",
"admin:groups:write",
"admin:groups:manage_members",
"admin:roles:read",
"admin:roles:write",
"admin:roles:assign",
}),
"admin:users:write": frozenset({
"admin:users:create",
"admin:users:update",
"admin:users:suspend",
"admin:roles:assign",
"admin:groups:manage_members",
}),
"admin:api_keys:write": frozenset({"admin:api_keys:create", "admin:api_keys:revoke"}),
"admin:settings": frozenset({
"admin:settings:read",
"admin:settings:write",
"admin:api_keys:read",
"admin:api_keys:create",
"admin:api_keys:revoke",
}),
"system:tenants:write": frozenset({"system:tenants:create", "system:tenants:update", "system:tenants:suspend"}),
"system:access:write": frozenset({
"system:access:assign",
"system:roles:assign",
"system:accounts:create",
"system:accounts:update",
"system:accounts:suspend",
}),
}

View File

@@ -18,7 +18,7 @@ class SecretDecryptionError(RuntimeError):
pass pass
CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider" CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider" # noqa: S105 # nosec B105 - capability identifier.
@runtime_checkable @runtime_checkable
@@ -38,8 +38,8 @@ def _normalize_fernet_key(value: str) -> bytes:
try: try:
Fernet(candidate) Fernet(candidate)
return candidate return candidate
except Exception: except (TypeError, ValueError):
pass raw = None
try: try:
raw = base64.b64decode(candidate) raw = base64.b64decode(candidate)
except Exception as exc: except Exception as exc:

View File

@@ -8,57 +8,19 @@ from govoplan_core.db.session import configure_database
from govoplan_core.server.config import GovoplanServerConfig, load_server_config from govoplan_core.server.config import GovoplanServerConfig, load_server_config
from govoplan_core.server.fastapi import create_govoplan_app from govoplan_core.server.fastapi import create_govoplan_app
from govoplan_core.server.platform import create_platform_router from govoplan_core.server.platform import create_platform_router
from govoplan_core.server.credentials import router as credential_router
from govoplan_core.server.registry import available_module_manifests, build_platform_registry from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.server.route_validation import validate_no_route_collisions from govoplan_core.server.route_validation import validate_no_route_collisions
def create_app(config: GovoplanServerConfig | str | None = None): def create_app(config: GovoplanServerConfig | str | None = None):
if isinstance(config, str) or config is None: server_config = _server_config(config)
server_config = load_server_config(config) _configure_app_database(server_config)
else: registry, available_modules = _server_module_registry(server_config)
server_config = config api_router = _server_api_router(server_config, registry)
lifecycle = _server_lifecycle(server_config, registry=registry, available_modules=available_modules)
database_url = getattr(server_config.settings, "database_url", None) if server_config.settings is not None else None
if database_url:
dispose_previous = getattr(server_config.settings, "app_env", None) == "test"
configure_database(str(database_url), dispose_previous=dispose_previous)
raw_enabled_modules = load_startup_enabled_modules(server_config.enabled_modules)
candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
startup_available_modules = available_module_manifests(
server_config.manifest_factories,
enabled_modules=candidate_modules,
ignore_load_errors=True,
)
available_modules = available_module_manifests(server_config.manifest_factories, ignore_load_errors=True)
enabled_modules = load_startup_enabled_modules(server_config.enabled_modules, available=startup_available_modules)
registry = build_platform_registry(enabled_modules, manifest_factories=server_config.manifest_factories)
api_router = APIRouter(prefix=server_config.api_prefix)
for router in server_config.base_routers:
api_router.include_router(router)
lifecycle = ModuleLifecycleManager(
registry=registry,
available_modules=available_modules,
settings=server_config.settings,
api_prefix=server_config.api_prefix,
manifest_factories=tuple(server_config.manifest_factories),
module_context_data=server_config.module_context_data,
)
lifecycle.configure_runtime() lifecycle.configure_runtime()
api_router.include_router(create_platform_router(settings=server_config.settings))
for router in server_config.post_module_routers:
api_router.include_router(router)
for contribution in server_config.extra_routers:
if contribution.should_include(server_config.settings, registry):
api_router.include_router(contribution.router)
validate_no_route_collisions(api_router, owner="server startup routes")
app = create_govoplan_app( app = create_govoplan_app(
title=server_config.title, title=server_config.title,
version=server_config.version, version=server_config.version,
@@ -74,4 +36,62 @@ def create_app(config: GovoplanServerConfig | str | None = None):
return app return app
def _server_config(config: GovoplanServerConfig | str | None) -> GovoplanServerConfig:
if isinstance(config, str) or config is None:
return load_server_config(config)
return config
def _configure_app_database(server_config: GovoplanServerConfig) -> None:
database_url = getattr(server_config.settings, "database_url", None) if server_config.settings is not None else None
if database_url:
dispose_previous = getattr(server_config.settings, "app_env", None) == "test"
configure_database(str(database_url), dispose_previous=dispose_previous)
def _server_module_registry(server_config: GovoplanServerConfig):
raw_enabled_modules = load_startup_enabled_modules(server_config.enabled_modules)
candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
startup_available_modules = available_module_manifests(
server_config.manifest_factories,
enabled_modules=candidate_modules,
ignore_load_errors=True,
)
available_modules = available_module_manifests(server_config.manifest_factories, ignore_load_errors=True)
enabled_modules = load_startup_enabled_modules(server_config.enabled_modules, available=startup_available_modules)
registry = build_platform_registry(enabled_modules, manifest_factories=server_config.manifest_factories)
return registry, available_modules
def _server_api_router(server_config: GovoplanServerConfig, registry) -> APIRouter:
api_router = APIRouter(prefix=server_config.api_prefix)
for router in server_config.base_routers:
api_router.include_router(router)
api_router.include_router(create_platform_router(settings=server_config.settings))
api_router.include_router(credential_router)
for router in server_config.post_module_routers:
api_router.include_router(router)
for contribution in server_config.extra_routers:
if contribution.should_include(server_config.settings, registry):
api_router.include_router(contribution.router)
validate_no_route_collisions(api_router, owner="server startup routes")
return api_router
def _server_lifecycle(
server_config: GovoplanServerConfig,
*,
registry,
available_modules,
) -> ModuleLifecycleManager:
return ModuleLifecycleManager(
registry=registry,
available_modules=available_modules,
settings=server_config.settings,
api_prefix=server_config.api_prefix,
manifest_factories=tuple(server_config.manifest_factories),
module_context_data=server_config.module_context_data,
)
app = create_app() app = create_app()

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import re
from collections.abc import Callable, Iterable, Mapping, Sequence from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from importlib import import_module from importlib import import_module
@@ -15,6 +16,10 @@ from govoplan_core.server.fastapi import LifespanFactory
ManifestFactory = Callable[[], ModuleManifest] ManifestFactory = Callable[[], ModuleManifest]
RouterEnabled = Callable[[object | None, PlatformRegistry], bool] RouterEnabled = Callable[[object | None, PlatformRegistry], bool]
AppConfigurator = Callable[[FastAPI, PlatformRegistry, object | None], None] AppConfigurator = Callable[[FastAPI, PlatformRegistry, object | None], None]
DEFAULT_TRUSTED_IMPORT_PREFIXES = ("govoplan_core", "govoplan_", "app")
TRUSTED_IMPORT_PREFIXES_ENV = "GOVOPLAN_TRUSTED_IMPORT_PREFIXES"
_MODULE_PATH_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$")
_ATTRIBUTE_PATH_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)*$")
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -49,16 +54,52 @@ class GovoplanServerConfig:
def import_object(path: str) -> Any: def import_object(path: str) -> Any:
module_name, separator, attribute = path.partition(":") module_name, attribute = validate_object_path(path)
if not separator: _validate_trusted_import_prefix(module_name, trusted_import_prefixes())
raise ValueError(f"Object path must use module:attribute syntax: {path!r}") module = import_module(module_name) # nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import
module = import_module(module_name)
value: Any = module value: Any = module
for part in attribute.split("."): for part in attribute.split("."):
value = getattr(value, part) value = getattr(value, part)
return value return value
def validate_object_path(path: str) -> tuple[str, str]:
module_name, separator, attribute = path.strip().partition(":")
if not separator:
raise ValueError(f"Object path must use module:attribute syntax: {path!r}")
if not _MODULE_PATH_RE.fullmatch(module_name):
raise ValueError(f"Object path module is not a valid Python module path: {path!r}")
if not _ATTRIBUTE_PATH_RE.fullmatch(attribute) or any(part.startswith("_") for part in attribute.split(".")):
raise ValueError(f"Object path attribute is not a public attribute path: {path!r}")
return module_name, attribute
def trusted_import_prefixes() -> tuple[str, ...]:
configured = os.getenv(TRUSTED_IMPORT_PREFIXES_ENV, "").strip()
if not configured:
return DEFAULT_TRUSTED_IMPORT_PREFIXES
prefixes = tuple(item.strip() for item in configured.split(",") if item.strip())
return prefixes or DEFAULT_TRUSTED_IMPORT_PREFIXES
def _validate_trusted_import_prefix(module_name: str, prefixes: Iterable[str]) -> None:
if any(_module_matches_prefix(module_name, prefix) for prefix in prefixes):
return
raise ValueError(
f"Object path module {module_name!r} is outside trusted import prefixes. "
f"Set {TRUSTED_IMPORT_PREFIXES_ENV} to allow custom deployment config modules."
)
def _module_matches_prefix(module_name: str, prefix: str) -> bool:
cleaned = prefix.strip()
if not cleaned:
return False
if cleaned.endswith((".", "_")):
return module_name.startswith(cleaned)
return module_name == cleaned or module_name.startswith(f"{cleaned}.")
def load_server_config(path: str | None = None) -> GovoplanServerConfig: def load_server_config(path: str | None = None) -> GovoplanServerConfig:
config_path = path or os.getenv("GOVOPLAN_SERVER_CONFIG") or "govoplan_core.server.default_config:get_server_config" config_path = path or os.getenv("GOVOPLAN_SERVER_CONFIG") or "govoplan_core.server.default_config:get_server_config"
try: try:

View File

@@ -0,0 +1,380 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field, SecretStr
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.db.session import get_session
from govoplan_core.security.credential_envelopes import (
CredentialEnvelope,
CredentialEnvelopeError,
create_credential_envelope,
credential_envelope_summary,
get_managed_credential_envelope,
list_managed_credential_envelopes,
normalize_credential_scope,
retire_credential_envelope,
update_credential_envelope,
)
CredentialScopeType = Literal["system", "tenant", "group", "user", "campaign"]
class CredentialEnvelopeResponse(BaseModel):
id: str
tenant_id: str | None = None
scope_type: CredentialScopeType
scope_id: str | None = None
name: str
description: str | None = None
credential_kind: str
public_data: dict[str, Any] = Field(default_factory=dict)
secret_keys: list[str] = Field(default_factory=list)
secret_configured: bool = False
allowed_modules: list[str] = Field(default_factory=list)
allowed_server_refs: list[str] = Field(default_factory=list)
inherit_to_lower_scopes: bool = False
is_active: bool = True
revision: str
created_at: datetime | None = None
updated_at: datetime | None = None
deleted_at: datetime | None = None
class CredentialEnvelopeListResponse(BaseModel):
credentials: list[CredentialEnvelopeResponse] = Field(default_factory=list)
class CredentialEnvelopeCreateRequest(BaseModel):
scope_type: CredentialScopeType = "tenant"
scope_id: str | None = Field(default=None, max_length=255)
name: str = Field(min_length=1, max_length=255)
description: str | None = None
credential_kind: str = Field(default="username_password", max_length=40)
public_data: dict[str, Any] = Field(default_factory=dict)
secret_data: dict[str, SecretStr] = Field(default_factory=dict)
allowed_modules: list[str] = Field(default_factory=list)
allowed_server_refs: list[str] = Field(default_factory=list)
inherit_to_lower_scopes: bool = False
is_active: bool = True
class CredentialEnvelopeUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = None
credential_kind: str | None = Field(default=None, max_length=40)
public_data: dict[str, Any] | None = None
secret_data: dict[str, SecretStr] | None = None
clear_secret: bool = False
allowed_modules: list[str] | None = None
allowed_server_refs: list[str] | None = None
inherit_to_lower_scopes: bool | None = None
is_active: bool | None = None
router = APIRouter(prefix="/credentials", tags=["credentials"])
@router.get("", response_model=CredentialEnvelopeListResponse)
def list_credentials(
scope_type: CredentialScopeType = Query(default="tenant"),
scope_id: str | None = Query(default=None),
include_inactive: bool = Query(default=False),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> CredentialEnvelopeListResponse:
tenant_id, normalized_scope_type, normalized_scope_id = _target_scope(
principal,
scope_type=scope_type,
scope_id=scope_id,
write=False,
)
rows = list_managed_credential_envelopes(
session,
tenant_id=tenant_id,
include_inactive=include_inactive,
)
return CredentialEnvelopeListResponse(
credentials=[
_response(row)
for row in rows
if row.scope_type == normalized_scope_type
and row.scope_id == normalized_scope_id
]
)
@router.post(
"",
response_model=CredentialEnvelopeResponse,
status_code=status.HTTP_201_CREATED,
)
def create_credential(
payload: CredentialEnvelopeCreateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> CredentialEnvelopeResponse:
tenant_id, scope_type, scope_id = _target_scope(
principal,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
write=True,
)
try:
row = create_credential_envelope(
session,
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
name=payload.name,
description=payload.description,
credential_kind=payload.credential_kind,
public_data=payload.public_data,
secret_data=_secret_values(payload.secret_data),
allowed_modules=payload.allowed_modules,
allowed_server_refs=payload.allowed_server_refs,
inherit_to_lower_scopes=payload.inherit_to_lower_scopes,
is_active=payload.is_active,
user_id=principal.membership_id,
metadata={"created_by_module": "core"},
)
session.commit()
session.refresh(row)
return _response(row)
except CredentialEnvelopeError as exc:
session.rollback()
raise _credential_error(exc) from exc
@router.patch("/{credential_id}", response_model=CredentialEnvelopeResponse)
def update_credential(
credential_id: str,
payload: CredentialEnvelopeUpdateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> CredentialEnvelopeResponse:
try:
row = _managed_row_for_write(session, principal, credential_id)
row = update_credential_envelope(
session,
row,
name=payload.name,
description=payload.description,
description_supplied="description" in payload.model_fields_set,
credential_kind=payload.credential_kind,
public_data=payload.public_data,
secret_data=(
_secret_values(payload.secret_data)
if payload.secret_data is not None
else None
),
clear_secret=payload.clear_secret,
allowed_modules=payload.allowed_modules,
allowed_server_refs=payload.allowed_server_refs,
inherit_to_lower_scopes=payload.inherit_to_lower_scopes,
is_active=payload.is_active,
user_id=principal.membership_id,
)
session.commit()
session.refresh(row)
return _response(row)
except CredentialEnvelopeError as exc:
session.rollback()
raise _credential_error(exc) from exc
@router.delete("/{credential_id}", response_model=CredentialEnvelopeResponse)
def delete_credential(
credential_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> CredentialEnvelopeResponse:
try:
row = _managed_row_for_write(session, principal, credential_id)
retire_credential_envelope(
session,
row,
user_id=principal.membership_id,
)
response = _response(row)
session.commit()
return response
except CredentialEnvelopeError as exc:
session.rollback()
raise _credential_error(exc) from exc
def _managed_row_for_write(
session: Session,
principal: ApiPrincipal,
credential_id: str,
) -> CredentialEnvelope:
system_row = None
if _can_manage_system_credentials(principal):
try:
system_row = get_managed_credential_envelope(
session,
credential_id=credential_id,
tenant_id=None,
for_update=True,
)
except CredentialEnvelopeError:
pass
row = system_row or get_managed_credential_envelope(
session,
credential_id=credential_id,
tenant_id=principal.tenant_id,
for_update=True,
)
_target_scope(
principal,
scope_type=row.scope_type,
scope_id=row.scope_id,
write=True,
)
return row
def _target_scope(
principal: ApiPrincipal,
*,
scope_type: str,
scope_id: str | None,
write: bool,
) -> tuple[str | None, str, str | None]:
requested_type = str(scope_type or "tenant").strip().casefold()
requested_id = str(scope_id).strip() if scope_id else None
if requested_type == "system":
if not (
_can_manage_system_credentials(principal)
if write
else _can_read_system_credentials(principal)
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="System credential permission is required.",
)
return _normalize_target_scope(
tenant_id=None,
scope_type="system",
scope_id=None,
)
if requested_type == "user" and requested_id == principal.membership_id:
own_scope = (
has_scope(principal, "access:credential:manage_own")
or has_scope(principal, "mail:secret:manage_own")
)
if own_scope:
return _normalize_target_scope(
tenant_id=principal.tenant_id,
scope_type=requested_type,
scope_id=requested_id,
)
allowed = (
_can_manage_tenant_credentials(principal)
if write
else _can_read_tenant_credentials(principal)
)
if not allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Tenant credential permission is required.",
)
return _normalize_target_scope(
tenant_id=principal.tenant_id,
scope_type=requested_type,
scope_id=requested_id,
)
def _normalize_target_scope(
*,
tenant_id: str | None,
scope_type: str,
scope_id: str | None,
) -> tuple[str | None, str, str | None]:
try:
return normalize_credential_scope(
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
)
except CredentialEnvelopeError as exc:
raise _credential_error(exc) from exc
def _can_read_system_credentials(principal: ApiPrincipal) -> bool:
return any(
has_scope(principal, scope)
for scope in (
"access:system_credential:read",
"access:system_setting:read",
"system:settings:read",
)
)
def _can_manage_system_credentials(principal: ApiPrincipal) -> bool:
return any(
has_scope(principal, scope)
for scope in (
"access:system_credential:write",
"access:system_setting:write",
"system:settings:write",
)
)
def _can_read_tenant_credentials(principal: ApiPrincipal) -> bool:
return any(
has_scope(principal, scope)
for scope in (
"access:credential:read",
"access:setting:read",
"admin:settings:read",
"mail_servers:read",
)
)
def _can_manage_tenant_credentials(principal: ApiPrincipal) -> bool:
return any(
has_scope(principal, scope)
for scope in (
"access:credential:write",
"access:setting:write",
"admin:settings:write",
"mail_servers:manage_credentials",
)
)
def _secret_values(values: dict[str, SecretStr]) -> dict[str, str]:
return {
str(key): value.get_secret_value()
for key, value in values.items()
if str(key).strip() and value.get_secret_value()
}
def _response(row: CredentialEnvelope) -> CredentialEnvelopeResponse:
return CredentialEnvelopeResponse.model_validate(
credential_envelope_summary(row)
)
def _credential_error(exc: CredentialEnvelopeError) -> HTTPException:
return HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
__all__ = ["router"]

View File

@@ -1,17 +1,91 @@
from __future__ import annotations from __future__ import annotations
import logging
import os
import time
from collections.abc import AsyncIterator, Callable, Iterable from collections.abc import AsyncIterator, Callable, Iterable
from contextlib import AbstractAsyncContextManager from contextlib import AbstractAsyncContextManager
from typing import Any from typing import Any
from fastapi import APIRouter, FastAPI, Request from fastapi import APIRouter, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware 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.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.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.conditional_requests import conditional_json_get_middleware
from govoplan_core.server.request_limits import RequestBodyLimitMiddleware
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]] 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:
raw = os.getenv("GOVOPLAN_SLOW_REQUEST_MS", "500").strip()
try:
return float(raw)
except ValueError:
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( def create_govoplan_app(
@@ -24,8 +98,27 @@ def create_govoplan_app(
cors_origins: Iterable[str] = (), cors_origins: Iterable[str] = (),
health_payload: dict[str, Any] | None = None, health_payload: dict[str, Any] | None = None,
) -> FastAPI: ) -> FastAPI:
_validate_production_startup()
app = FastAPI(title=title, version=version, lifespan=lifespan) 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 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") @app.middleware("http")
async def request_correlation_context(request: Request, call_next): async def request_correlation_context(request: Request, call_next):
@@ -39,6 +132,42 @@ def create_govoplan_app(
response.headers["X-Correlation-ID"] = correlation_id response.headers["X-Correlation-ID"] = correlation_id
return response return response
@app.middleware("http")
async def slow_request_logging(request: Request, call_next):
started_at = time.perf_counter()
with collect_query_metrics() as db_metrics:
try:
response = await call_next(request)
except Exception:
elapsed_ms = (time.perf_counter() - started_at) * 1000
if slow_request_threshold_ms > 0 and elapsed_ms >= slow_request_threshold_ms:
logger.warning(
"slow request failed method=%s path=%s duration_ms=%.1f db_query_count=%s db_time_ms=%.1f db_slowest_ms=%.1f db_error_count=%s",
request.method,
request.url.path,
elapsed_ms,
db_metrics.query_count,
db_metrics.total_ms,
db_metrics.slowest_ms,
db_metrics.error_count,
exc_info=True,
)
raise
elapsed_ms = (time.perf_counter() - started_at) * 1000
if slow_request_threshold_ms > 0 and elapsed_ms >= slow_request_threshold_ms:
logger.warning(
"slow request method=%s path=%s status=%s duration_ms=%.1f db_query_count=%s db_time_ms=%.1f db_slowest_ms=%.1f db_error_count=%s",
request.method,
request.url.path,
response.status_code,
elapsed_ms,
db_metrics.query_count,
db_metrics.total_ms,
db_metrics.slowest_ms,
db_metrics.error_count,
)
return response
app.middleware("http")(conditional_json_get_middleware) app.middleware("http")(conditional_json_get_middleware)
origins = [item.strip() for item in cors_origins if item.strip()] origins = [item.strip() for item in cors_origins if item.strip()]

View File

@@ -1,13 +1,20 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from govoplan_core.admin.models import SystemSettings from govoplan_core.admin.models import SystemSettings
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID 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.maintenance import saved_maintenance_mode
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleManifest, NavItem, PublicFrontendRoute
from govoplan_core.core.registry import PlatformRegistry 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.db.session import get_database
from govoplan_core.i18n import system_i18n_payload from govoplan_core.i18n import system_i18n_payload
@@ -19,7 +26,7 @@ def _registry(request: Request) -> PlatformRegistry:
return registry 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 { return {
"path": item.path, "path": item.path,
"label": item.label, "label": item.label,
@@ -28,20 +35,60 @@ def _nav_item_payload(item: NavItem) -> dict[str, object]:
"required_all": list(item.required_all), "required_all": list(item.required_all),
"required_any": list(item.required_any), "required_any": list(item.required_any),
"order": item.order, "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 { return {
"path": route.path, "path": route.path,
"component": route.component, "component": route.component,
"required_all": list(route.required_all), "required_all": list(route.required_all),
"required_any": list(route.required_any), "required_any": list(route.required_any),
"order": route.order, "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: if frontend is None:
return None return None
return { 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_public_key_id": frontend.asset_manifest_public_key_id,
"asset_manifest_integrity": frontend.asset_manifest_integrity, "asset_manifest_integrity": frontend.asset_manifest_integrity,
"asset_manifest_contract_version": frontend.asset_manifest_contract_version, "asset_manifest_contract_version": frontend.asset_manifest_contract_version,
"routes": [_frontend_route_payload(route) for route in frontend.routes], "routes": [_frontend_route_payload(route, manifest.id) for route in frontend.routes],
"nav": [_nav_item_payload(item) for item in frontend.nav_items], "public_routes": [
"settings_routes": [_frontend_route_payload(route) for route in frontend.settings_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") @router.get("/modules")
def modules(request: Request): def modules(
request: Request,
_principal: ApiPrincipal = Depends(get_api_principal),
):
registry = _registry(request) registry = _registry(request)
return { return {
"modules": [ "modules": [
@@ -97,15 +169,35 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
"optional_dependencies": list(manifest.optional_dependencies), "optional_dependencies": list(manifest.optional_dependencies),
"enabled": True, "enabled": True,
"runtime_ui_capabilities": _runtime_ui_capabilities(manifest.id, settings, registry), "runtime_ui_capabilities": _runtime_ui_capabilities(manifest.id, settings, registry),
"nav": [_nav_item_payload(item) for item in manifest.nav_items], "nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items],
"frontend": _frontend_payload(manifest.frontend), "frontend": _frontend_payload(manifest),
} }
for manifest in registry.manifests() 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") @router.get("/permissions")
def permissions(request: Request): def permissions(
request: Request,
_principal: ApiPrincipal = Depends(get_api_principal),
):
registry = _registry(request) registry = _registry(request)
return { return {
"permissions": [ "permissions": [
@@ -125,7 +217,10 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
} }
@router.get("/navigation") @router.get("/navigation")
def navigation(request: Request): def navigation(
request: Request,
_principal: ApiPrincipal = Depends(get_api_principal),
):
registry = _registry(request) registry = _registry(request)
return { return {
"items": [_nav_item_payload(item) for item in registry.nav_items()] "items": [_nav_item_payload(item) for item in registry.nav_items()]

View File

@@ -3,9 +3,13 @@ from __future__ import annotations
from collections.abc import Callable, Iterable, Sequence from collections.abc import Callable, Iterable, Sequence
import importlib import importlib
from govoplan_core.core.access import (
DEFAULT_CAPABILITY_PROVIDERS,
)
from govoplan_core.core.discovery import discover_module_manifests from govoplan_core.core.discovery import discover_module_manifests
from govoplan_core.core.modules import ModuleManifest from govoplan_core.core.modules import ModuleManifest
from govoplan_core.core.registry import PlatformRegistry, RegistryError from govoplan_core.core.registry import PlatformRegistry, RegistryError
from govoplan_core.server.config import validate_object_path
ManifestFactory = Callable[[], ModuleManifest] ManifestFactory = Callable[[], ModuleManifest]
@@ -14,7 +18,6 @@ _BUILTIN_MANIFESTS = {
"tenancy": "govoplan_tenancy.backend.manifest:get_manifest", "tenancy": "govoplan_tenancy.backend.manifest:get_manifest",
} }
def parse_enabled_modules(value: str | Iterable[str]) -> list[str]: def parse_enabled_modules(value: str | Iterable[str]) -> list[str]:
if isinstance(value, str): if isinstance(value, str):
items = [item.strip() for item in value.split(",")] items = [item.strip() for item in value.split(",")]
@@ -56,26 +59,74 @@ def available_module_manifests(
return 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) requested = parse_enabled_modules(enabled_modules)
if "access" not in requested: available = _resolve_required_manifests(
requested.insert(0, "access") requested,
manifest_factories=manifest_factories,
available = available_module_manifests(manifest_factories, enabled_modules=requested) )
registry = PlatformRegistry() registry = PlatformRegistry()
for module_id in requested: for module_id, manifest in available.items():
manifest = available.get(module_id)
if manifest is None:
raise RegistryError(f"Enabled module is not available: {module_id}")
registry.register(manifest) registry.register(manifest)
registry.validate() registry.validate()
return registry 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: def _load_builtin_manifest(module_id: str) -> ModuleManifest:
target = _BUILTIN_MANIFESTS[module_id] target = _BUILTIN_MANIFESTS[module_id]
module_name, function_name = target.split(":", 1) module_name, function_name = validate_object_path(target)
module = importlib.import_module(module_name) if module_name != _BUILTIN_MANIFESTS[module_id].split(":", 1)[0]:
raise RegistryError(f"Built-in module manifest target changed unexpectedly: {module_id}")
module = importlib.import_module(module_name) # nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import
factory = getattr(module, function_name) factory = getattr(module, function_name)
manifest = factory() manifest = factory()
if not isinstance(manifest, ModuleManifest): if not isinstance(manifest, ModuleManifest):

View 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

View File

@@ -6,26 +6,62 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=None, extra="ignore") model_config = SettingsConfigDict(env_file=None, extra="ignore")
app_env: str = Field(default="dev", alias="APP_ENV") 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( database_url: str = Field(
default="postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev", default="postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev",
alias="DATABASE_URL", 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_database_url: str | None = Field(default=None, alias="ACCESS_DATABASE_URL")
access_db_schema: str | None = Field(default=None, alias="ACCESS_DB_SCHEMA") access_db_schema: str | None = Field(default=None, alias="ACCESS_DB_SCHEMA")
access_table_prefix: str = Field(default="access_", alias="ACCESS_TABLE_PREFIX") access_table_prefix: str = Field(default="access_", alias="ACCESS_TABLE_PREFIX")
tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE") 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_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") 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,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") migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK")
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL") redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED") celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
s3_endpoint_url: str = Field(default="http://garage:3900", alias="S3_ENDPOINT_URL") s3_endpoint_url: str = Field(default="http://garage:3900", alias="S3_ENDPOINT_URL")
s3_region: str = Field(default="garage", alias="S3_REGION") s3_region: str = Field(default="garage", alias="S3_REGION")
s3_access_key_id: str = Field(default="GKmultimailerdev0000000000000000", alias="S3_ACCESS_KEY_ID") s3_access_key_id: str = Field(default="GKgovoplandev0000000000000000000", alias="S3_ACCESS_KEY_ID")
s3_secret_access_key: str = Field(default="multimailer-dev-secret-change-me", alias="S3_SECRET_ACCESS_KEY") s3_secret_access_key: str = Field(default="govoplan-dev-secret-change-me", alias="S3_SECRET_ACCESS_KEY")
s3_bucket: str = Field(default="attachments", alias="S3_BUCKET") s3_bucket: str = Field(default="attachments", alias="S3_BUCKET")
# Managed file storage. Development defaults to local filesystem storage; # Managed file storage. Development defaults to local filesystem storage;
@@ -41,19 +77,95 @@ class Settings(BaseSettings):
file_upload_max_bytes: int = Field(default=50 * 1024 * 1024, alias="FILE_UPLOAD_MAX_BYTES") file_upload_max_bytes: int = Field(default=50 * 1024 * 1024, alias="FILE_UPLOAD_MAX_BYTES")
file_upload_zip_max_bytes: int = Field(default=250 * 1024 * 1024, alias="FILE_UPLOAD_ZIP_MAX_BYTES") file_upload_zip_max_bytes: int = Field(default=250 * 1024 * 1024, alias="FILE_UPLOAD_ZIP_MAX_BYTES")
auth_session_cookie_name: str = Field(default="msm_session", alias="AUTH_SESSION_COOKIE_NAME") auth_session_cookie_name: str = Field(default="govoplan_session", alias="AUTH_SESSION_COOKIE_NAME")
auth_csrf_cookie_name: str = Field(default="msm_csrf", alias="AUTH_CSRF_COOKIE_NAME") auth_csrf_cookie_name: str = Field(default="govoplan_csrf", alias="AUTH_CSRF_COOKIE_NAME")
auth_cookie_secure: bool = Field(default=False, alias="AUTH_COOKIE_SECURE") auth_cookie_secure: bool = Field(default=False, alias="AUTH_COOKIE_SECURE")
auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE") auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE")
auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN") auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN")
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS") 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") master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64")
celery_queues: str = Field(default="send_email,append_sent,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") mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR")
# Development bootstrap only. Do not use this in production. # Development bootstrap only. Do not use this in production.
dev_bootstrap_api_key: str | None = Field(default="dev-multimailer-api-key", alias="DEV_BOOTSTRAP_API_KEY") dev_bootstrap_api_key: str | None = Field(default="dev-govoplan-api-key", alias="DEV_BOOTSTRAP_API_KEY")
dev_auto_migrate_enabled: bool = Field(default=True, alias="DEV_AUTO_MIGRATE_ENABLED") dev_auto_migrate_enabled: bool = Field(default=True, alias="DEV_AUTO_MIGRATE_ENABLED")
dev_bootstrap_enabled: bool = Field(default=False, alias="DEV_BOOTSTRAP_ENABLED") dev_bootstrap_enabled: bool = Field(default=False, alias="DEV_BOOTSTRAP_ENABLED")
dev_bootstrap_password: str = Field(default="dev-admin", alias="DEV_BOOTSTRAP_PASSWORD") dev_bootstrap_password: str = Field(default="dev-admin", alias="DEV_BOOTSTRAP_PASSWORD")

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