71 Commits

Author SHA1 Message Date
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
157 changed files with 12880 additions and 3471 deletions

2
.gitignore vendored
View File

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

View File

@@ -30,10 +30,14 @@ _TABLE_RENAMES = (
("governance_templates", "admin_governance_templates"),
("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:
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:

View File

@@ -19,11 +19,14 @@ LEGACY_SCOPE_TABLE = "tenancy_tenants"
CORE_SCOPE_TABLE = "core_scopes"
LEGACY_SLUG_INDEX = "ix_tenancy_tenants_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:
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)
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]:

View File

@@ -16,6 +16,7 @@ revision = "9d0e1f2a3b4c"
down_revision = "8c9d0e1f2a3b"
branch_labels = None
depends_on = None
_RECONCILE_CREATE_ALL_TABLES = ("admin_governance_template_assignments", "admin_governance_templates", "core_system_settings")
def _now() -> datetime:
@@ -28,9 +29,10 @@ def upgrade() -> None:
tables = set(inspector.get_table_names())
# 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:
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:
raise RuntimeError(f"Cannot reconcile non-empty create_all 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))
params = {f"action_{index}": action for index, action in enumerate(SYSTEM_ACTIONS)}
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,
)
bind.execute(sa.text("UPDATE audit_log SET scope = 'tenant' WHERE scope IS NULL OR scope NOT IN ('tenant', 'system')"))

View File

@@ -20,7 +20,10 @@ database_url = config.attributes.get("database_url") or settings.database_url
config.set_main_option("sqlalchemy.url", database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Migrations can run inside the long-lived application process when module
# state changes. Do not let Alembic's logging setup disable loggers that the
# server already created (for example slow-request diagnostics).
fileConfig(config.config_file_name, disable_existing_loggers=False)
def _target_metadata():

View File

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

View File

@@ -1,13 +1,23 @@
# GovOPlaN Master Roadmap
This roadmap is the durable product north star and sequencing guide for
GovOPlaN as a modular platform for administrative operations. It keeps the
product moving without turning every possible public-sector need into an
immediate implementation track.
This roadmap is the technical and module-sequencing companion for GovOPlaN as
a modular platform for administrative operations. It translates the
cross-product outcome horizons into dependency waves without turning every
possible public-sector need into an immediate implementation track.
Use this document for product direction, sequencing, and module routing. Issues
are the active backlog; this document is durable planning context and should be
mirrored to the Gitea wiki.
Use this document for technical sequencing, module routing, and implementation
gates. Issues are the active backlog; this document is durable architecture
planning context and should be mirrored to the Gitea wiki.
The meta repository's
[Connected Governance Platform Roadmap](https://git.add-ideas.de/add-ideas/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/add-ideas/govoplan/src/branch/main/docs/REFERENCE_JOURNEY_PROGRAM.md).
Those product documents are canonical; this Core roadmap remains their
technical sequencing and module-routing companion.
## Product Thesis
@@ -113,7 +123,8 @@ pattern exists.
## Focus Rules
1. Build one reference journey per wave.
1. Build one selected reference journey stage at a time; a later capability
cluster is not an active program merely because it appears below.
2. Do not implement a module because the repository exists.
3. Do not add module-to-module imports for optional behavior.
4. Every new domain module must justify its own semantics beyond `cases`,
@@ -138,11 +149,15 @@ pattern exists.
| Internal work queues and tasks | `govoplan-tasks` |
| Appointment proposals and booking | `govoplan-appointments`, `govoplan-calendar` |
| Postbox, email, and notifications | `govoplan-postbox`, `govoplan-mail`, `govoplan-notifications` |
| Canonical subjects and account links | `govoplan-identity` |
| Organizational structures, units, and functions | `govoplan-organizations` |
| Identity-to-function assignments and directory synchronization | `govoplan-idm` |
| Identity trust, device keys, and encrypted postbox key contracts | `govoplan-identity-trust`, `govoplan-access`, `govoplan-postbox` |
| Service directory/catalog | `govoplan-portal` |
| Permit/document generation | `govoplan-templates`, `govoplan-dms` |
| Payment capture and accounting handoff | `govoplan-payments`, `govoplan-ledger` |
| Roles, permissions, tenants, policy, audit | `govoplan-access`, `govoplan-tenancy`, `govoplan-policy`, `govoplan-audit` |
| Authentication projection, roles, permissions, acting context | `govoplan-access` |
| Tenants, policy, and audit | `govoplan-tenancy`, `govoplan-policy`, `govoplan-audit` |
| External software integration | `govoplan-connectors` |
| Recurring extraction and transformation | possible future `govoplan-datasources`, possible future `govoplan-dataflow` |
| Reports, BI, and management visibility | `govoplan-reporting` |
@@ -189,56 +204,82 @@ an editor applies a high-impact configuration change.
## Reference Journeys
The roadmap should be driven by three journeys.
The active sequence is selected. Workflow remains deliberately deferred and is
not a dependency of these journeys.
### Journey 1: Permit To Payment
### Journey 1: Campaign Demonstration Composition
This is the primary public-administration journey.
Campaign is the first complete proof of modular composition. Campaign owns
intent, recipient snapshots, personalization, execution state, and delivery
evidence. Mail owns reusable profiles, credentials, protocol policy, and
provider execution; Campaign stores only a selected profile reference. Files
owns storage, connector profiles, file policy, and provenance.
1. A person applies for a permit through the public portal.
2. The applicant uploads required files and submits structured form data.
3. Submission creates a case, a workflow instance, and an internal task.
4. Completing the task creates a postbox message, a notification, and an email
notification with an appointment proposal.
5. The applicant accepts an appointment, which updates the calendar and the
workflow state.
6. During the appointment, the case is opened and the permit is generated from
a governed template.
7. The payment is processed and linked to the case and accounting handoff.
8. The permit, payment evidence, communication history, audit trail, retention
state, and records evidence remain available according to policy.
The technical gate is a pinned Campaign/Mail/Files composition with central
UI, adaptive user/admin/operator/integration documentation, target SMTP/IMAP
and file-provider evidence, and explicit test/send/resend/retry/reconciliation
semantics. Readers must not receive backend paths, worker claims, secrets, or
raw provider diagnostics.
This journey proves the platform can coordinate modules without core knowing
module internals.
### Journey 2: Function-Bound Postbox Delivery
### Journey 2: Training To Certificate
Postbox accepts delivery to an addressable postbox or a function in an
organizational unit. Organizations owns units and functions, Identity owns
subjects, IDM owns identity-to-function assignments and upstream sync, and
Access resolves current roles, delegation, acting context, and permission.
This is the best university-administration and internal-administration journey.
Campaign consumes a typed delivery-target capability without importing Postbox
or identity internals. Reassignment changes future access without moving the
message; vacancy or ambiguous acting context fails visibly; delivery, access,
and correction remain auditable.
1. Course or training offer is planned.
2. Room, trainer, resource, and capacity are booked.
3. Participants register or are assigned.
4. Attendance is tracked.
5. Certificate or participation confirmation is issued.
6. Evidence remains available through records, files, audit, and docs.
### Journey 3: Data-Backed Templates, Reports, And Deep Launch
This journey keeps `booking`, `resources`, `learning`, and `certificates`
focused instead of becoming broad ERP replacements.
An authenticated user follows an opaque, short-lived launch reference from HIS
or another specialist system. GovOPlaN re-authorizes the actor, resolves a
curated data context server-side, displays source/freshness/version, and renders
one reproducible document and report.
### Journey 3: Report To Resolution
Templates owns definition/version/schema/rendering, Reporting owns source
selection/parameters/execution/export, Files owns generated bytes, and
connectors own protocol access. URLs do not carry credentials, arbitrary SQL,
or trusted raw personal data. Retries are idempotent and generation evidence
connects source, snapshot/reference, transformation, definition, parameters,
output checksum, actor, and policy.
This is the internal operations and municipal issue-reporting journey.
### Journey 4: Governed University BI Path
1. A person reports an issue.
2. The issue is triaged into helpdesk, facilities, assets, or a case.
3. Work is assigned, tracked, and escalated.
4. Evidence, communication, and status updates are preserved.
5. Reports show workload, SLA, recurring problems, and completion.
Starting from the Journey 3 source contract, one bounded university dataset is
catalogued, staged by snapshot or watermark, validated, transformed through a
versioned lineage graph, and exposed as a policy-aware analytical data product.
The result must preserve official-key mappings, organizational and reporting
date semantics, quality findings, quarantine/replay, transparent calculation,
and reproducible promotion between development, test, and production.
This journey prevents `helpdesk`, `issue-reporting`, `facilities`, and `assets`
from becoming disconnected ticket silos.
Reporting consumes the product. Create `govoplan-datasources` or
`govoplan-dataflow` only after the concrete path proves repeated ownership that
does not belong to connectors, Reporting, or the producing domain module.
## Roadmap Waves
### Journey 5: Collaborative Document Lifecycle
An uploaded or generated artifact becomes a DMS document. Files continues to
own bytes; DMS owns identity, versions, renditions, editing sessions, locks,
comments, review, approval, comparison, and recovery; a collaboration connector
owns provider-specific protocol behavior; Records owns later classification,
hold, archive, and disposal.
The gate requires no silent lost updates, short-lived and currently authorized
editing sessions, idempotent authenticated callbacks, visible uncertain saves,
immutable accepted renditions, and a Records-ready handoff with stable content
and provenance.
## Capability Dependency Waves
The waves below remain a dependency and ownership catalogue for the wider
product vision. They are not the active delivery order. The five selected
journeys above and the meta roadmap decide what is implemented now; other
clusters remain dormant until a selected journey consumes them or they are
explicitly reprioritized.
### Wave 0: Platform Spine
@@ -248,9 +289,13 @@ Refine:
- `govoplan-core`: module discovery, capabilities, events, migrations, release
catalog, configuration package runtime, WebUI shell.
- `govoplan-access`: identities, sessions, API keys, users, groups, roles,
memberships, function assignments, delegation, RBAC decisions.
- `govoplan-tenancy`: tenant and organizational-unit boundaries.
- `govoplan-identity`: canonical identities and account links.
- `govoplan-organizations`: organizational structures, units, and functions.
- `govoplan-idm`: identity-to-function assignments, directory synchronization,
preview, conflicts, and reconciliation.
- `govoplan-access`: sessions, API keys, users, groups, roles, memberships,
function-to-role projection, delegation, acting context, and RBAC decisions.
- `govoplan-tenancy`: tenant lifecycle and tenant boundaries.
- `govoplan-identity-trust`: initial trust contracts for device keys, public key
directory, assurance, and later encrypted postbox key access.
- `govoplan-policy`: policy sources, policy decisions, retention inputs.
@@ -530,17 +575,30 @@ Before a module becomes release-included, it needs:
- smoke test or permutation test
- no required imports from optional modules
## Priority Order Summary
## Technical Dependency Order Summary
1. Stabilize the platform spine.
2. Deliver permit-to-payment MVP.
3. Build booking and resource operations.
4. Add learning and certificates.
5. Add issue reporting and helpdesk.
6. Add records, DMS, search, and transparency.
7. Add procurement, contracts, grants, and finance handoff.
8. Add committee and consultation workflows.
9. Expand integration, dataflow, reporting, and operations.
Use this active order while respecting the ownership and implementation gates
in the capability waves:
1. Keep the platform/release spine green and extend connector, identity,
external-effect, provenance, documentation, focused-view, recovery, and
version contracts only as the current journey requires.
2. Complete and package Campaign with Mail-owned profiles, Files, target
delivery/recovery, central UI, and adaptive documentation.
3. Implement function-bound Postbox delivery through
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
@@ -548,7 +606,8 @@ Defer these until a reference journey proves the need:
- full ERP replacement
- native project management beyond connector support
- broad BI/dataflow platform
- an unbounded general-purpose dataflow platform; the bounded governed BI
reference journey is selected
- every possible public-sector protocol adapter
- rich LMS behavior beyond training administration
- full qualified digital signing/trust services beyond the identity-trust and
@@ -645,19 +704,24 @@ Release composition and tag-only repository handling are documented in
## Next Practical Work
The next planning step should create or update Gitea issues for Wave 0 and Wave
1 only. Later waves should stay as roadmap context until the permit-to-payment
MVP is demonstrable.
The active cross-product story is
[`add-ideas/govoplan#14`](https://git.add-ideas.de/add-ideas/govoplan/issues/14).
Module repositories own implementation issues; do not clone their state here.
Recommended immediate issue buckets:
Immediate issue buckets:
- platform spine hardening
- configuration package preflight and rollback
- forms-runtime MVP
- portal submission MVP
- cases/workflow/tasks integration MVP
- template-generated decision document
- postbox/notification handoff
- appointment/booking handoff
- payment evidence handoff
- configured documentation for the reference process
- fail-closed connector destination pinning and private-network deployment
control for every real transport
- Mail-profile-only Campaign authoring/build/delivery and safe legacy failure
- immediate audited secret deletion when a provider/profile is removed
- Campaign central-component and role-safe UI acceptance
- adaptive Campaign, Mail, and Files task/process/admin/operator/integration/
security/acceptance documentation
- target SMTP/IMAP, file-provider, queue/reconciliation, install/upgrade, and
restore proof for the pinned Campaign reference composition
Once that gate is demonstrable, activate the existing Postbox model, access,
API, inbox, and Campaign integration issues. Templates/Reporting, governed BI,
and DMS collaboration remain durable selected direction, but should be
decomposed only as the preceding stage stabilizes or a bounded independent
contract can be implemented without pre-deciding target-system choices.

View File

@@ -126,6 +126,15 @@ Feature modules should prefer these capabilities over direct reads of
access/tenant ORM models when they need labels, group membership, default
access provisioning, counts, audit actor labels, or tenant metadata.
Other stable runtime capabilities currently include:
- `identity.directory` and `identity.search`
- `organizations.directory`
- `idm.directory`
- `calendar.outbox` and `calendar.scheduling`
- `poll.scheduling`
- `notifications.dispatch`
### Named Interface Contracts
Capabilities are runtime objects. Named interface contracts are compatibility
@@ -147,15 +156,25 @@ intended for SemVer major-version lines. Missing optional interfaces are
allowed, but an installed provider with an incompatible version blocks
activation because the integration would otherwise bind to an unsafe API.
Current named interfaces:
Current named interfaces, generated from the source manifests by the workspace
contract checks, are:
- `files.campaign_attachments`
- `addresses.contact_writer`, `addresses.lookup`, `addresses.recipient_source`
- `calendar.outbox`, `calendar.scheduling`
- `campaigns.access`, `campaigns.delivery_tasks`,
`campaigns.mail_policy_context`, `campaigns.policy_context`,
`campaigns.retention`
- `dist_lists.expand`, `dist_lists.source`, `dist_lists.writer`
- `evaluation.feedback`, `evaluation.result_aggregation`, `evaluation.scoring`
- `files.access`, `files.campaign_attachments`
- `mail.campaign_delivery`
- `campaigns.access`
- `campaigns.delivery_tasks`
- `campaigns.mail_policy_context`
- `campaigns.policy_context`
- `campaigns.retention`
- `notifications.dispatch`
- `poll.availability_matrix`, `poll.option_selection`,
`poll.response_collection`, `poll.signed_participation`,
`poll.workflow_context`
- `rest.function_publication`
- `scheduling.candidate_slots`, `scheduling.decision_handoff`
- `soap.operation_publication`
Core validates named interface contracts in three places:
@@ -434,6 +453,18 @@ The manifest should declare:
- navigation metadata using serializable icon names
- uninstall guard providers for data, migration, worker, or scheduler vetoes
A tenant-level managed `RoleTemplate` may set `default_authenticated=True`
only when every authenticated tenant member must receive that narrow baseline
while the contributing module is installed. Access derives the explicit grant
from the active manifest set during authorization without mutating the request
transaction. It may materialize a non-assignable role row for administration,
but no per-user assignment is required and role edits cannot remove the
baseline.
This is not a shortcut for feature authorization: keep the template narrow and
continue to enforce each domain action's own permission and resource policy.
System-level, unmanaged, wildcard-bearing, or slug-colliding automatic
templates are rejected by registry validation.
Backend nav metadata must use icon-name strings, not frontend components:
```python

View File

@@ -97,6 +97,20 @@ New backend code should import policy-owned retention behavior from
`govoplan-policy` or request the capability, not add new implementation logic
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.
## Frontend Contract
Policy UIs must:

View File

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

View File

@@ -40,17 +40,26 @@ cd /mnt/DATA/git/govoplan
Update those refs when cutting a release:
```text
govoplan-access git@git.add-ideas.de:add-ideas/govoplan-access.git v0.1.6
govoplan-admin git@git.add-ideas.de:add-ideas/govoplan-admin.git v0.1.6
govoplan-tenancy git@git.add-ideas.de:add-ideas/govoplan-tenancy.git v0.1.6
govoplan-organizations git@git.add-ideas.de:add-ideas/govoplan-organizations.git v0.1.6
govoplan-identity git@git.add-ideas.de:add-ideas/govoplan-identity.git v0.1.6
govoplan-policy git@git.add-ideas.de:add-ideas/govoplan-policy.git v0.1.6
govoplan-audit git@git.add-ideas.de:add-ideas/govoplan-audit.git v0.1.6
govoplan-files git@git.add-ideas.de:add-ideas/govoplan-files.git v0.1.6
govoplan-mail git@git.add-ideas.de:add-ideas/govoplan-mail.git v0.1.6
govoplan-campaign git@git.add-ideas.de:add-ideas/govoplan-campaign.git v0.1.6
govoplan-calendar git@git.add-ideas.de:add-ideas/govoplan-calendar.git v0.1.6
govoplan-tenancy git@git.add-ideas.de:add-ideas/govoplan-tenancy.git v0.1.8
govoplan-organizations git@git.add-ideas.de:add-ideas/govoplan-organizations.git v0.1.8
govoplan-identity git@git.add-ideas.de:add-ideas/govoplan-identity.git v0.1.8
govoplan-idm git@git.add-ideas.de:add-ideas/govoplan-idm.git v0.1.8
govoplan-access git@git.add-ideas.de:add-ideas/govoplan-access.git v0.1.8
govoplan-admin git@git.add-ideas.de:add-ideas/govoplan-admin.git v0.1.8
govoplan-policy git@git.add-ideas.de:add-ideas/govoplan-policy.git v0.1.8
govoplan-audit git@git.add-ideas.de:add-ideas/govoplan-audit.git v0.1.8
govoplan-dashboard git@git.add-ideas.de:add-ideas/govoplan-dashboard.git v0.1.8
govoplan-addresses git@git.add-ideas.de:add-ideas/govoplan-addresses.git v0.1.8
govoplan-files git@git.add-ideas.de:add-ideas/govoplan-files.git v0.1.8
govoplan-mail git@git.add-ideas.de:add-ideas/govoplan-mail.git v0.1.8
govoplan-campaign git@git.add-ideas.de:add-ideas/govoplan-campaign.git v0.1.8
govoplan-calendar git@git.add-ideas.de:add-ideas/govoplan-calendar.git v0.1.8
govoplan-poll git@git.add-ideas.de:add-ideas/govoplan-poll.git v0.1.8
govoplan-scheduling git@git.add-ideas.de:add-ideas/govoplan-scheduling.git v0.1.8
govoplan-notifications git@git.add-ideas.de:add-ideas/govoplan-notifications.git v0.1.8
govoplan-evaluation git@git.add-ideas.de:add-ideas/govoplan-evaluation.git v0.1.8
govoplan-docs git@git.add-ideas.de:add-ideas/govoplan-docs.git v0.1.8
govoplan-ops git@git.add-ideas.de:add-ideas/govoplan-ops.git v0.1.8
```
## WebUI Packages
@@ -70,18 +79,18 @@ cd /mnt/DATA/git/govoplan-core/webui
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run build
```
The module repositories include root-level npm package manifests so git
installs can resolve `@govoplan/access-webui`, `@govoplan/admin-webui`,
`@govoplan/files-webui`, `@govoplan/mail-webui`,
`@govoplan/campaign-webui`, and `@govoplan/calendar-webui` from repository
roots even though their source lives below `webui/src`.
Module repositories with a frontend include root-level npm package manifests
so the `@govoplan/*-webui` dependencies in `webui/package.release.json` can be
resolved from repository roots even though their source lives below
`webui/src`.
### Release Lockfile Strategy
The supported release composition currently is the full GovOPlaN product: core
plus access, admin, tenancy, organizations, identity, policy, audit,
dashboard, files, mail, campaign, calendar, docs, and ops. Keep one committed
full-product release lockfile at
The supported backend release composition is the set pinned in the meta
repository's `requirements-release.txt`. The supported frontend composition
is the independently buildable module set pinned in
`webui/package.release.json`; backend-only modules do not need a frontend
package entry. Keep one committed full-product release lockfile at
`webui/package-lock.release.json`, generated from
`webui/package.release.json` in a clean release workspace. Development
`package-lock.json` may continue to point at local `file:` dependencies.
@@ -104,7 +113,7 @@ working tree has already been bumped, pass the current version explicitly:
```bash
cd /mnt/DATA/git/govoplan
tools/release/push-release-tag.sh --version 0.1.6
tools/release/push-release-tag.sh --version 0.1.8
```
`/mnt/DATA/git/govoplan/tools/release/generate-release-catalog.py` reads installed/discovered
@@ -126,7 +135,6 @@ are not listed in `requirements-release.txt` or `webui/package.release.json`.
Current tag-only module repositories:
- `govoplan-addresses`
- `govoplan-appointments`
- `govoplan-cases`
- `govoplan-connectors`
@@ -135,13 +143,11 @@ Current tag-only module repositories:
- `govoplan-fit-connect`
- `govoplan-forms`
- `govoplan-identity-trust`
- `govoplan-idm`
- `govoplan-ledger`
- `govoplan-notifications`
- `govoplan-payments`
- `govoplan-portal`
- `govoplan-postbox`
- `govoplan-reporting`
- `govoplan-scheduling`
- `govoplan-search`
- `govoplan-tasks`
- `govoplan-templates`

View File

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

21
docs/THROTTLING.md Normal file
View File

@@ -0,0 +1,21 @@
# 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, a missing Redis configuration, and temporary
Redis outages use a bounded process-local fallback. When Redis fails, 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

@@ -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-013 | Contestable decisions must expose provenance. Denials, locks, generated outputs, calculated defaults, policy decisions, access decisions, and retention decisions need a reachable source path. | Accepted | Policy, access, templates, workflow, retention, records |
| UX-014 | Retraction, expiry, undo, rollback, and delete controls must state the real limit of the operation. Corrective or future-only actions must not be described as if they undo already observed effects. | Accepted | Postbox, files, records, installer, workflow, payments |
| UX-015 | Core owns the platform appearance contract. Modules must use shared CSS tokens and shared controls for theme-aware UI; they must not define independent light/dark palette systems. | Accepted | Core shell and all module WebUIs |
| UX-016 | Full-page create/edit surfaces keep `Discard` and the named `Save …` action in the upper-right page action cluster, with Save at the far right. Their position remains stable through validation and loading states. | Accepted | All full-page create/edit surfaces |
| UX-017 | Table row actions use icon-only controls in a stable rightmost column and intent order: inspect/open, edit, copy, transfer/share/download, retry/restore, destructive action last. Every icon requires a translated accessible name and tooltip. | Accepted | All structured tables |
| UX-018 | A collapsible card containing only one table gives the table the card's full available body, without decorative inner wrappers, duplicate padding, max-widths, or nested scrolling. | Accepted | List, detail, workflow, and configuration surfaces |
| UX-019 | Focused-view precedence is manual session pin, current-task suggestion, user default, role/tenant default, then the full interface. The active source and a full-interface escape remain visible; a suggested view never changes authorization or implies consent. | Accepted | Shell, modules, future workflow composition |
| UX-020 | Centrally exported Core components are mandatory wherever their contract covers the interaction. A custom reusable control, presentation primitive, or module-local substitute requires explicit product-owner authorization, a narrowly specific purpose, and documented rationale and scope; it must not duplicate a central component. | Accepted | Core WebUI and all module WebUIs |
| UX-021 | A collection-wide create action belongs in that collection's page heading and is not duplicated in a persistent side panel. When a side panel is the creation surface, it is present for the creation view only. | Accepted | List-detail, directory, and create surfaces |
| UX-022 | Use central `Card` components for logical sections, `DataGrid` for tabular row collections and their ordered actions, and `ToggleSwitch` for boolean settings. Repeatable people/contact editors use one structured row per person with name, email address, and actions; free-form address parsing is reserved for an explicitly designed bulk-import flow. | Accepted | All WebUI forms and collection editors |
| UX-023 | `FieldLabel` is the standard label/help surface for every field that is not self-explanatory. Any field rendered without it must be recorded in the omission register below, including its accessible-name source and rationale. Users may hide inline help markers through their persisted interface preference; the field label itself remains visible. | Accepted | All Core and module forms |
| UX-024 | Explicit `Discard` actions and dirty in-application navigation use the shared `UnsavedChangesProvider` dialog. A page registers save/discard behavior with `useUnsavedDraftGuard`; its Discard button calls `requestDiscard`, and route changes use `useGuardedNavigate` or `requestNavigation`. | Accepted | All create/edit surfaces |
| UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code |
| UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables |
## Confirmed Implementation Decisions
@@ -138,6 +150,112 @@ adaptive form, not force a linear wizard.
- Wizard shells remain available for assisted setup, first-run guidance,
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.
- Feedback and confirmation use `Dialog`, `ConfirmDialog`, or
`DismissibleAlert`. They never fall back to `window.alert`.
#### FieldLabel Omission Register
Every Core field surface that intentionally does not render `FieldLabel` is
listed here. Module repositories keep an equivalent register in their durable
UI documentation until a central cross-repository audit is available.
| Core scope | Why `FieldLabel` is omitted | Accessible/context label source |
| --- | --- | --- |
| `PasswordField`, `ColorPickerField`, `DateField`, `TimeField`, and `DateTimeField` input internals | These are label-neutral composite primitives and are placed inside `FormField`/`FieldLabel` by the consuming form. Rendering another label inside the primitive would duplicate it. | Enclosing label; a direct consumer must pass an accessible name and record that direct composition here. |
| `ToggleSwitch` native checkbox | The shared component already renders its visible text through `FieldLabel`; the native input must not render a second label. | The enclosing native label and derived `aria-label`. |
| `FileDropZone` hidden file input | The input is an implementation detail of the labelled keyboard-operable drop target. | Drop target text and `inputLabel`/`aria-label`. |
| `AdminSelectionList` and `DataGrid` list-filter checkboxes | Each option is self-explanatory and already enclosed by its visible option label. | Enclosing native option label. |
| `EmailAddressInput` compact Name and Email fields | These two conventional fields are self-explanatory in the compact address popover; richer address guidance belongs to the enclosing field. | Visible native labels; the free-form editor also has a descriptive `aria-label`. |
| `DataGrid` page-size, filter, and inline cell editors | The surrounding column header/filter heading supplies field context; repeating a labelled help marker in every cell would add noise. | Column header, filter heading/native label, or generated cell `aria-label`. |
| Retention-policy value controls | `PolicyRow` owns the field label, help, effective value, and provenance for its control. | The containing `PolicyRow` label/help contract. |
#### Alert Exception Register
No `window.alert` or global `alert` exception is authorized.
## Implementation Sequence
| Phase | Scope | Output |
@@ -200,6 +318,22 @@ Every new or changed admin/configuration surface should answer:
- Does the action surface show consequence, reversibility, and audit evidence
when rights, duties, records, money, communication, external systems, or
workflow state are affected?
- Does the surface use every applicable central Core component? If it contains
a custom component, is the product-owner authorization, narrow purpose,
rationale, scope, and non-duplication evidence recorded?
- Is a collection-wide create action in the collection heading rather than
duplicated in a persistent side panel?
- Are logical sections, tabular collections, boolean settings, and repeatable
people/contact rows composed with `Card`, `DataGrid`, `ToggleSwitch`, and one
structured row per person respectively?
- Does every non-self-explanatory field use `FieldLabel`, and is every omission
recorded with its rationale and accessible-name source?
- Do explicit Discard and dirty navigation use the shared unsaved-changes
registration/dialog rather than a page-local confirmation?
- Does every row retain the table's action set in the same order, disabling
unavailable actions and reserving the same empty-row slots?
- Is feedback rendered with a central dialog/alert component, with no
unauthorized `window.alert` or global `alert` call?
- If automation is involved, can the user see the trigger, system actor,
observed effects, and failure/manual-intervention state?
- Are technical details available without being the first thing the user sees?

View File

@@ -45,7 +45,7 @@ Remediation applied on 2026-07-09:
- upgraded the campaign ZIP dependency to `pyzipper>=0.4,<1`, resolving to
`pyzipper==0.4.0`
- 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
Post-remediation result:

View File

@@ -83,6 +83,90 @@
"release": "0.1.7",
"track": "release",
"squash_policy": "reviewed-manual"
},
{
"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",
"track": "release",
"squash_policy": "reviewed-manual"
}
],
"version": 1

View File

@@ -84,7 +84,15 @@
"provides_interfaces": [
{
"name": "mail.campaign_delivery",
"version": "1.4.0"
"version": "0.2.0"
}
],
"requires_interfaces": [
{
"name": "campaigns.access",
"version_min": "0.1.0",
"version_max_exclusive": "0.2.0",
"optional": true
}
],
"artifact_integrity": {

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-core"
version = "0.1.8"
version = "0.1.10"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md"
requires-python = ">=3.12"
@@ -42,3 +42,7 @@ dev = [
"httpx==0.28.1",
"httpx2>=2.5,<3",
]
[tool.ruff.lint.per-file-ignores]
"tests/test_api_smoke.py" = ["E402"]
"tests/test_module_system.py" = ["E402"]

View File

@@ -94,6 +94,28 @@ class UserInfo(BaseModel):
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):
model_config = ConfigDict(extra="forbid")
@@ -143,9 +165,43 @@ class PrincipalContextInfo(BaseModel):
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):
access_token: str
token_type: str = "bearer"
token_type: str = "bearer" # noqa: S105 - OAuth token type, not a credential.
expires_at: datetime
user: UserInfo
# Backwards-compatible alias for the active tenant.
@@ -159,6 +215,9 @@ class LoginResponse(BaseModel):
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
roles_loaded: bool = True
groups_loaded: bool = True
class MeResponse(BaseModel):
@@ -174,3 +233,6 @@ class MeResponse(BaseModel):
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
roles_loaded: bool = True
groups_loaded: bool = True

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
"""Core auth dependency facade.
Routers depend on this module instead of a concrete access-provider package.
@@ -7,6 +5,8 @@ The active auth module provides the request principal through the platform
capability registry.
"""
from __future__ import annotations
from dataclasses import dataclass
from fastapi import Depends, Header, HTTPException, Request, status

View File

@@ -3,8 +3,11 @@ from __future__ import annotations
from celery import Celery
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_OUTBOX, CalendarOutboxProvider
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import configure_runtime
from govoplan_core.settings import settings
from govoplan_core.db.session import configure_database
@@ -13,7 +16,7 @@ from govoplan_core.server.registry import available_module_manifests, build_plat
configure_database(settings.database_url)
celery = Celery(
"multimailer",
"govoplan",
broker=settings.redis_url,
backend=settings.redis_url,
)
@@ -21,21 +24,31 @@ celery = Celery(
celery.conf.update(
task_default_queue="default",
task_routes={
"multimailer.send_email": {"queue": "send_email"},
"multimailer.append_sent": {"queue": "append_sent"},
"govoplan.campaigns.send_email": {"queue": "send_email"},
"govoplan.campaigns.append_sent": {"queue": "append_sent"},
"govoplan.notifications.deliver": {"queue": "notifications"},
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
"govoplan.calendar.dispatch_outbox": {"queue": "calendar"},
},
worker_prefetch_multiplier=1,
task_acks_late=True,
task_reject_on_worker_lost=True,
beat_schedule={
"calendar-outbox-every-minute": {
"task": "govoplan.calendar.dispatch_outbox",
"schedule": 60.0,
"args": (None, 100),
},
},
)
@celery.task(name="multimailer.ping")
@celery.task(name="govoplan.ping")
def ping():
return "pong"
def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider:
def _platform_registry() -> PlatformRegistry:
raw_enabled_modules = load_startup_enabled_modules(settings.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)
@@ -44,13 +57,36 @@ def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider:
context = ModuleContext(registry=registry, settings=settings)
configure_runtime(context)
registry.configure_capability_context(context)
return registry
def _campaign_delivery_tasks() -> CampaignDeliveryTaskProvider:
registry = _platform_registry()
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_DELIVERY_TASKS)
if not isinstance(capability, CampaignDeliveryTaskProvider):
raise RuntimeError("Campaign delivery task capability is invalid")
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
@celery.task(name="govoplan.campaigns.send_email", bind=True, max_retries=0)
def send_email(self, job_id: str):
"""Send one explicitly queued campaign job.
@@ -65,7 +101,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))
@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):
"""Append the exact sent MIME to the configured IMAP Sent folder."""
@@ -78,3 +114,38 @@ def append_sent(self, job_id: str):
if getattr(exc, "temporary", None) is True:
raise self.retry(exc=exc, countdown=300)
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

View File

@@ -5,7 +5,6 @@ import json
from pathlib import Path
import sys
import time
from typing import Any
from govoplan_core.core.module_installer import (
ModuleInstallerError,
@@ -27,6 +26,13 @@ from govoplan_core.core.module_installer import (
update_module_installer_request,
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_package_catalog import sign_module_package_catalog, validate_module_package_catalog
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
def main() -> int:
def _build_parser() -> argparse.ArgumentParser:
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("--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-issuer", help="Optional issuer string 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)
try:
if args.issue_license:
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
return _dispatch_command(args=args, runtime_dir=runtime_dir)
except ModuleInstallerError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
def _dispatch_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
for handler in (
_handle_license_command,
_handle_catalog_command,
_handle_runtime_command,
_handle_queue_command,
_handle_run_history_command,
):
result = handler(args=args, runtime_dir=runtime_dir)
if result is not None:
return result
return _handle_install_command(args=args, runtime_dir=runtime_dir)
def _handle_license_command(*, args: argparse.Namespace, runtime_dir: Path) -> int | None:
del runtime_dir
if args.issue_license:
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:
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"])
options = request.get("options") if isinstance(request.get("options"), dict) else {}
configure_database(str(args.database_url))
_emit_installer_daemon_notification(request, event_kind="module_installer.request.running")
try:
available = available_module_manifests(ignore_load_errors=True)
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),
request_context=_request_context(request),
)
update_module_installer_request(
updated_request = update_module_installer_request(
runtime_dir=runtime_dir,
request_id=request_id,
patch={
@@ -393,8 +493,12 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
"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:
update_module_installer_request(
updated_request = update_module_installer_request(
runtime_dir=runtime_dir,
request_id=request_id,
patch={
@@ -403,6 +507,34 @@ def _process_request(*, request: dict[str, object], args: argparse.Namespace, ru
"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]:
@@ -427,6 +559,7 @@ def _request_context(request: dict[str, object]) -> dict[str, object]:
context: dict[str, object] = {
"request_id": request.get("request_id"),
"requested_by": request.get("requested_by"),
"tenant_id": request.get("tenant_id"),
}
if request.get("retry_of"):
context["retry_of"] = request["retry_of"]

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

@@ -8,8 +8,6 @@ from pathlib import Path
import json
import os
from typing import Any, Literal, Protocol, runtime_checkable
import urllib.error
import urllib.request
from govoplan_core.core.module_package_catalog import (
_canonical_catalog_bytes,
@@ -23,6 +21,7 @@ from govoplan_core.core.module_package_catalog import (
_load_private_key,
_parse_trusted_keys,
)
from govoplan_core.security.http_fetch import fetch_http_text
CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider"
@@ -31,6 +30,20 @@ DiagnosticSeverity = Literal["blocker", "warning", "info"]
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)
class ConfigurationModuleRequirement:
module_id: str
@@ -461,55 +474,148 @@ def validate_configuration_package_catalog(
configured = source is not None and _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}")
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:
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_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()
signature_state = _catalog_signature_state(payload, trusted_keys=effective_trusted_keys)
freshness = _catalog_freshness_state(payload)
replay = _catalog_replay_state(channel=channel, sequence=sequence)
state = _configuration_catalog_validation_state(source, trusted_keys=effective_trusted_keys)
read_state = state.read_state
except Exception as exc:
return _validation_result(source, configured=configured, read_state=read_state, packages=(), error=str(exc))
if signature_state.get("fatal"):
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"]))
if effective_approved_channels and channel not in effective_approved_channels:
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)}.")
if effective_require_trusted and not signature_state["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."))
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 not replay["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(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."))
policy_error = _configuration_catalog_policy_error(
source,
configured=configured,
state=state,
require_trusted=effective_require_trusted,
approved_channels=effective_approved_channels,
)
if policy_error is not None:
return policy_error
return _validation_result(
source,
valid=True,
configured=configured,
read_state=read_state,
packages=packages,
read_state=state.read_state,
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,
sequence=sequence,
generated_at=generated_at,
not_before=not_before,
expires_at=expires_at,
signature_state=signature_state,
warnings=warnings,
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=trusted_keys),
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:
payload = _read_catalog_payload(path)
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:
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()
try:
with urllib.request.urlopen(url, timeout=15) as response:
body = response.read().decode("utf-8")
body = fetch_http_text(url, timeout=15, label="Trusted configuration catalog key URL")
if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8")
return body
except (OSError, urllib.error.URLError):
except OSError:
if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8")
raise
@@ -714,13 +817,12 @@ def _read_catalog_payload_with_metadata(source: Path | str | None) -> tuple[obje
return {"packages": []}, metadata
if isinstance(source, str) and _is_http_url(source):
try:
with urllib.request.urlopen(source, timeout=15) as response:
body = response.read().decode("utf-8")
body = fetch_http_text(source, timeout=15, label="Configuration package catalog URL")
if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8")
return json.loads(body), metadata
except (OSError, urllib.error.URLError):
except OSError:
if cache_path is not None and cache_path.exists():
metadata["cache_used"] = True
return json.loads(cache_path.read_text(encoding="utf-8")), metadata

View File

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

View File

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

View File

@@ -3,9 +3,11 @@ from __future__ import annotations
import base64
import json
import os
import re
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, Literal
from pathlib import Path
from typing import Literal
from cryptography.fernet import Fernet
from sqlalchemy.engine import make_url
@@ -72,6 +74,25 @@ class ConfigValidationResult:
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"}
_PRODUCTION_PROFILES = {"prod", "production", "self-hosted"}
_PRODUCTION_LIKE_PROFILES = {"staging", "production-like", "production-like-dev"}
@@ -114,95 +135,192 @@ def validate_runtime_configuration(
strict: bool = False,
) -> ConfigValidationResult:
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"))
issues: list[ConfigIssue] = []
runtime = _runtime_profile(env, profile=profile)
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_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"))
if not app_env and 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.")
elif app_env.lower() in {"dev", "test", "local"} and 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.")
if not app_env and runtime.production_like:
collector.add("error", "APP_ENV", "APP_ENV is missing for a production-like install.", "Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.")
elif app_env.lower() in {"dev", "test", "local"} and runtime.production_like:
collector.add("error", "APP_ENV", f"APP_ENV={app_env!r} is not valid for profile {runtime.name!r}.", "Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.")
def _validate_database_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
database_url = _clean(env.get("DATABASE_URL"))
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.")
else:
backend = _database_backend(database_url)
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.")
elif backend == "sqlite" and production_like:
issue("error", "DATABASE_URL", "SQLite is only supported for disposable local development.", "Use PostgreSQL for production-like and self-hosted installs.")
elif backend != "postgresql" and production:
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.")
if backend == "postgresql" and not _clean(env.get("GOVOPLAN_DATABASE_URL_PGTOOLS")):
issue("warning", "GOVOPLAN_DATABASE_URL_PGTOOLS", "PostgreSQL backup/restore tools URL is missing.", "Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.")
collector.add("error", "DATABASE_URL", "DATABASE_URL is missing.", "Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.")
return
backend = _database_backend(database_url)
if backend is None:
collector.add("error", "DATABASE_URL", "DATABASE_URL is not a valid SQLAlchemy URL.", "Use a value like postgresql+psycopg://user:password@host:5432/database.")
return
if backend == "sqlite" and runtime.production_like:
collector.add("error", "DATABASE_URL", "SQLite is only supported for disposable local development.", "Use PostgreSQL for production-like and self-hosted installs.")
elif backend != "postgresql" and runtime.production:
collector.add("warning", "DATABASE_URL", f"Database backend {backend!r} is not the preferred production target.", "Use PostgreSQL unless this deployment has an explicit support decision.")
if backend == "postgresql" and not _clean(env.get("GOVOPLAN_DATABASE_URL_PGTOOLS")):
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"))
if not master_key and not 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.")
elif master_key:
error = _master_key_error(master_key)
if error:
issue("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():
issue("error", "MASTER_KEY_B64", "MASTER_KEY_B64 still looks like a placeholder.", "Generate a real deployment key and store it outside git.")
if not master_key and not runtime.local:
collector.add("error", "MASTER_KEY_B64", "MASTER_KEY_B64 is required outside local dev/test.", "Generate a Fernet key with `govoplan-config env-template --generate-secrets` or `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` and store it in deployment secrets.")
return
if not master_key:
return
error = _master_key_error(master_key)
if error:
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"))
if not enabled_modules and production_like:
issue("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:
issue("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:
issue("warning", "ENABLED_MODULES", "The admin module is not enabled.", "Keep `admin` enabled for operator UI unless this is a deliberately headless install.")
if not enabled_modules and runtime.production_like:
collector.add("error", "ENABLED_MODULES", "ENABLED_MODULES is missing.", "Set ENABLED_MODULES explicitly so startup module composition is intentional.")
elif "access" not in enabled_modules and runtime.production_like:
collector.add("error", "ENABLED_MODULES", "The access module is not enabled.", "Include `access` unless this deployment has a replacement auth/principal provider.")
elif enabled_modules and "admin" not in enabled_modules and runtime.production_like:
collector.add("warning", "ENABLED_MODULES", "The admin module is not enabled.", "Keep `admin` enabled for operator UI unless this is a deliberately headless install.")
celery_enabled = _truthy(env.get("CELERY_ENABLED"))
if celery_enabled and not _clean(env.get("REDIS_URL")):
issue("error", "REDIS_URL", "CELERY_ENABLED=true but REDIS_URL is missing.", "Set REDIS_URL to the Redis broker/result backend used by workers.")
if production and _truthy(env.get("DEV_BOOTSTRAP_ENABLED")):
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.")
def _validate_async_and_auth_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
if _truthy(env.get("CELERY_ENABLED")) and not _clean(env.get("REDIS_URL")):
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"))
if production_like and not cors_origins:
issue("error", "CORS_ORIGINS", "CORS_ORIGINS is missing.", "Set CORS_ORIGINS to the exact WebUI origin or origins.")
elif "*" in cors_origins and production_like:
issue("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:
issue("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.")
if runtime.production_like and not cors_origins:
collector.add("error", "CORS_ORIGINS", "CORS_ORIGINS is missing.", "Set CORS_ORIGINS to the exact WebUI origin or origins.")
elif "*" in cors_origins and runtime.production_like:
collector.add("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.")
elif runtime.production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
collector.add("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.")
trusted_hosts = _csv(env.get("GOVOPLAN_TRUSTED_HOSTS"))
if runtime.production_like and not trusted_hosts:
collector.add("error", "GOVOPLAN_TRUSTED_HOSTS", "Trusted HTTP hosts are not configured.", "Set GOVOPLAN_TRUSTED_HOSTS to the exact API host names accepted by this deployment.")
elif "*" in trusted_hosts and runtime.production_like:
collector.add("error", "GOVOPLAN_TRUSTED_HOSTS", "Wildcard trusted hosts are not allowed for production-like installs.", "Replace `*` with exact host names or narrowly scoped `*.example.org` entries.")
forwarded_allow_ips = _csv(env.get("FORWARDED_ALLOW_IPS"))
if runtime.production_like and "*" in forwarded_allow_ips:
collector.add("error", "FORWARDED_ALLOW_IPS", "Proxy headers must not be trusted from every address.", "Set FORWARDED_ALLOW_IPS to the reverse proxy address or network passed to Uvicorn.")
def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
storage_backend = _clean(env.get("FILE_STORAGE_BACKEND")) or "local"
if storage_backend == "local":
if not _clean(env.get("FILE_STORAGE_LOCAL_ROOT")) and production_like:
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.")
_validate_local_file_storage(env, runtime, collector)
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"):
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.")
_validate_s3_file_storage(env, collector)
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"))
if production and catalog_source:
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE")):
issue("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "A module catalog source is configured without a trusted keyring file.", "Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.")
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL")):
issue("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL", "A module catalog source is configured without an approved release channel.", "Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.")
return ConfigValidationResult(profile=clean_profile, issues=tuple(issues))
if not runtime.production or not catalog_source:
return
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE")):
collector.add("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "A module catalog source is configured without a trusted keyring file.", "Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.")
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.")
def _self_hosted_env_template(master_key: str) -> str:
@@ -220,9 +338,31 @@ ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,aud
CELERY_ENABLED=true
REDIS_URL=redis://127.0.0.1:6379/0
CELERY_QUEUES=send_email,append_sent,default
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
# Deployment-wide connector egress policy. Enable private networks only when
# this installation intentionally integrates with internal services.
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES=536870912
# Exact names/paths only. Keep empty until a deployment-owned connector needs them.
GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=
GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=
GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES=536870912
GOVOPLAN_HTTP_HSTS_SECONDS=31536000
AUTH_LOGIN_THROTTLE_ENABLED=true
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS=30
CORS_ORIGINS=https://govoplan.example.org
GOVOPLAN_TRUSTED_HOSTS=govoplan.example.org
# Uvicorn reads FORWARDED_ALLOW_IPS when proxy headers are enabled. Keep this
# restricted to the actual reverse proxy address or network.
FORWARDED_ALLOW_IPS=127.0.0.1
AUTH_COOKIE_SECURE=true
AUTH_COOKIE_SAMESITE=lax
AUTH_COOKIE_DOMAIN=
@@ -263,10 +403,27 @@ DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
REDIS_URL=redis://127.0.0.1:56379/0
CELERY_ENABLED=true
CELERY_QUEUES=send_email,append_sent,default
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES=536870912
GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=
GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=
GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES=536870912
GOVOPLAN_HTTP_HSTS_SECONDS=0
AUTH_LOGIN_THROTTLE_ENABLED=true
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS=30
ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
GOVOPLAN_TRUSTED_HOSTS=127.0.0.1,localhost,testserver
FORWARDED_ALLOW_IPS=127.0.0.1
AUTH_COOKIE_SECURE=false
FILE_STORAGE_BACKEND=local
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
@@ -299,8 +456,8 @@ def _master_key_error(value: str) -> str | None:
try:
Fernet(candidate)
return None
except Exception:
pass
except (TypeError, ValueError):
raw = None
try:
raw = base64.b64decode(candidate)
except Exception:
@@ -309,6 +466,6 @@ def _master_key_error(value: str) -> str | None:
return "MASTER_KEY_B64 must decode to exactly 32 bytes."
try:
Fernet(base64.urlsafe_b64encode(raw))
except Exception:
except (TypeError, ValueError):
return "MASTER_KEY_B64 is not usable as a Fernet key."
return None

View File

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

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 os
from pathlib import Path
from typing import Any
import urllib.error
import urllib.request
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
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]:
@@ -257,17 +255,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
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()
try:
with urllib.request.urlopen(url, timeout=15) as response:
body = response.read().decode("utf-8")
body = fetch_http_text(url, timeout=15, label="Trusted license key URL")
if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8")
return body
except (OSError, urllib.error.URLError):
except OSError:
if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8")
raise

View File

@@ -73,6 +73,23 @@ class ModuleInstallPlanItem:
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)
class ModuleInstallPlan:
items: tuple[ModuleInstallPlanItem, ...] = ()
@@ -291,6 +308,28 @@ def desired_modules_after_package_plan(
def normalize_module_install_plan_item(
item: Mapping[str, object] | 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):
raw = item.as_dict()
elif isinstance(item, Mapping):
@@ -311,33 +350,12 @@ def normalize_module_install_plan_item(
data_safety_acknowledged = _clean_bool(raw.get("data_safety_acknowledged"))
destroy_data = _clean_bool(raw.get("destroy_data"))
notes = _clean_optional_string(raw.get("notes"))
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(
return _NormalizedModuleInstallPlanItem(
module_id=module_id,
action=action,
source=source,
catalog=catalog,
status=status,
python_package=python_package,
python_ref=python_ref,
webui_package=webui_package,
@@ -345,11 +363,41 @@ def normalize_module_install_plan_item(
artifact_integrity=artifact_integrity,
data_safety_acknowledged=data_safety_acknowledged,
destroy_data=destroy_data,
status=status,
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(
requested_enabled: Iterable[str],
available: Mapping[str, ModuleManifest],

View File

@@ -3,20 +3,20 @@ from __future__ import annotations
import base64
import binascii
from collections import defaultdict
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
import json
import os
import re
from typing import Any
import urllib.error
import urllib.request
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
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.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_]*)+$")
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(
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_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):
return {
"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}
return _catalog_error_result(catalog_source, error=f"Module package catalog does not exist: {catalog_source}")
try:
payload, read_state = _read_catalog_payload_with_metadata(catalog_source)
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)
state = _catalog_validation_state(catalog_source, trusted_keys=effective_trusted_keys)
except Exception as exc:
return {
"valid": False,
"configured": catalog_source is not None,
"path": str(catalog_source) if catalog_source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None,
"source_type": _catalog_source_type(catalog_source),
"cache_used": bool(read_state.get("cache_used")),
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
"modules": [],
"channel": None,
"sequence": None,
"generated_at": None,
"not_before": None,
"expires_at": None,
"signed": False,
"trusted": False,
"key_id": None,
"warnings": [],
"error": str(exc),
}
if signature_state.get("fatal"):
return {
"valid": False,
"configured": catalog_source is not None,
"path": str(catalog_source) if catalog_source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None,
"source_type": _catalog_source_type(catalog_source),
"cache_used": bool(read_state.get("cache_used")),
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
"modules": [],
"channel": channel,
"sequence": sequence,
"generated_at": generated_at,
"not_before": not_before,
"expires_at": expires_at,
"signed": signature_state["signed"],
"trusted": signature_state["trusted"],
"key_id": signature_state["key_id"],
"warnings": [],
"error": str(signature_state["error"] or "Module package catalog signature is invalid."),
}
if effective_approved_channels and channel not in effective_approved_channels:
return {
"valid": False,
"configured": catalog_source is not None,
"path": str(catalog_source) if catalog_source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None,
"source_type": _catalog_source_type(catalog_source),
"cache_used": bool(read_state.get("cache_used")),
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
"modules": [],
"channel": channel,
"sequence": sequence,
"generated_at": generated_at,
"not_before": not_before,
"expires_at": expires_at,
"signed": signature_state["signed"],
"trusted": signature_state["trusted"],
"key_id": signature_state["key_id"],
"warnings": [],
"error": f"Module package catalog channel {channel!r} is not approved. Approved channels: {', '.join(effective_approved_channels)}.",
}
if effective_require_trusted and not signature_state["trusted"]:
return {
"valid": False,
"configured": catalog_source is not None,
"path": str(catalog_source) if catalog_source is not None else None,
"source": str(catalog_source) if catalog_source is not None else None,
"source_type": _catalog_source_type(catalog_source),
"cache_used": bool(read_state.get("cache_used")),
"cache_path": read_state.get("cache_path") if isinstance(read_state.get("cache_path"), str) else None,
"modules": [],
"channel": channel,
"sequence": sequence,
"generated_at": generated_at,
"not_before": not_before,
"expires_at": expires_at,
"signed": signature_state["signed"],
"trusted": False,
"key_id": signature_state["key_id"],
"warnings": [],
"error": str(signature_state["error"] or "Module package catalog must be signed by a trusted key."),
}
if not freshness["valid"]:
return _invalid_catalog_result(
catalog_source,
modules=(),
channel=channel,
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"]),
return _catalog_error_result(catalog_source, error=str(exc))
policy_error = _catalog_policy_error(
catalog_source,
state,
require_trusted=effective_require_trusted,
approved_channels=effective_approved_channels,
)
if policy_error is not None:
return policy_error
return _valid_catalog_result(catalog_source, state)
def _catalog_validation_state(
source: Path | str | None,
*,
trusted_keys: dict[str, str],
) -> _CatalogValidationState:
payload, read_state = _read_catalog_payload_with_metadata(source)
modules = _normalize_catalog_modules(payload)
channel = _catalog_channel(payload)
sequence = _catalog_sequence(payload)
return _CatalogValidationState(
modules=modules,
channel=channel,
sequence=sequence,
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=trusted_keys),
freshness=_catalog_freshness_state(payload),
replay=_catalog_replay_state(channel=channel, sequence=sequence),
read_state=read_state,
)
def _catalog_policy_error(
source: Path | str | None,
state: _CatalogValidationState,
*,
require_trusted: bool,
approved_channels: tuple[str, ...],
) -> dict[str, object] | None:
if state.signature_state.get("fatal"):
return _invalid_catalog_state_result(source, state, error=str(state.signature_state["error"] or "Module package catalog signature is invalid."))
if approved_channels and state.channel not in approved_channels:
return _invalid_catalog_state_result(
source,
state,
error=f"Module package catalog channel {state.channel!r} is not approved. Approved channels: {', '.join(approved_channels)}.",
)
if not replay["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(replay["error"]),
)
warnings.extend(str(item) for item in freshness.get("warnings", ()) if item)
warnings.extend(str(item) for item in replay.get("warnings", ()) if item)
warnings.extend(_catalog_interface_warnings(modules))
if not signature_state["signed"]:
warnings.append("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."))
if require_trusted and not state.signature_state["trusted"]:
return _invalid_catalog_state_result(source, state, error=str(state.signature_state["error"] or "Module package catalog must be signed by a trusted key."))
if not state.freshness["valid"]:
return _invalid_catalog_state_result(source, state, error=str(state.freshness["error"]))
if not state.replay["valid"]:
return _invalid_catalog_state_result(source, state, error=str(state.replay["error"]))
return None
def _catalog_error_result(source: Path | str | None, *, error: str) -> dict[str, object]:
return _invalid_catalog_result(
source,
modules=(),
channel=None,
sequence=None,
generated_at=None,
not_before=None,
expires_at=None,
signature_state=_unsigned_catalog_signature_state(),
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 {
"valid": True,
"configured": catalog_source is not None and _catalog_source_exists(catalog_source),
"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": list(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": warnings,
"configured": source is not None and _catalog_source_exists(source),
"path": str(source) if source is not None else None,
"source": str(source) if source is not None else None,
"source_type": _catalog_source_type(source),
"cache_used": bool(state.read_state.get("cache_used")),
"cache_path": state.read_state.get("cache_path") if isinstance(state.read_state.get("cache_path"), str) else None,
"modules": list(state.modules),
"channel": state.channel,
"sequence": state.sequence,
"generated_at": state.generated_at,
"not_before": state.not_before,
"expires_at": state.expires_at,
"signed": state.signature_state["signed"],
"trusted": state.signature_state["trusted"],
"key_id": state.signature_state["key_id"],
"warnings": _catalog_validation_warnings(state),
"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(
*,
path: Path,
@@ -350,17 +329,14 @@ def _configured_trusted_keys_cache_path() -> Path | None:
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()
try:
with urllib.request.urlopen(url, timeout=15) as response:
body = response.read().decode("utf-8")
body = fetch_http_text(url, timeout=15, label="Trusted catalog key URL")
if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8")
return body
except (OSError, urllib.error.URLError):
except OSError:
if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8")
raise
@@ -421,13 +397,12 @@ def _read_catalog_url(url: str) -> str:
def _read_catalog_url_with_metadata(url: str) -> tuple[str, bool]:
cache_path = _configured_catalog_cache_path()
try:
with urllib.request.urlopen(url, timeout=15) as response:
body = response.read().decode("utf-8")
body = fetch_http_text(url, timeout=15, label="Module package catalog URL")
if cache_path is not None:
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body, encoding="utf-8")
return body, False
except (OSError, urllib.error.URLError):
except OSError:
if cache_path is not None and cache_path.exists():
return cache_path.read_text(encoding="utf-8"), True
raise
@@ -935,7 +910,7 @@ def _catalog_source_type(source: Path | str | None) -> str | None:
def _is_http_url(value: str) -> bool:
return value.startswith(("https://", "http://"))
return is_http_url(value)
def _invalid_catalog_result(

View File

@@ -45,6 +45,7 @@ class RoleTemplate:
level: PermissionLevel = "tenant"
managed: bool = True
protected: bool = False
default_authenticated: bool = False
@dataclass(frozen=True, slots=True)

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

@@ -5,8 +5,10 @@ from typing import Any, Iterable, Literal, Mapping, Protocol, cast, runtime_chec
from urllib.parse import quote, unquote
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
SchedulingParticipantVisibility = Literal["aggregates_only", "names_and_statuses"]
CAPABILITY_POLICY_PRIVACY_RETENTION = "policy.privacyRetention"
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY = "policy.schedulingParticipantPrivacy"
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign")
@@ -126,6 +128,55 @@ class PolicyDecision:
}
@dataclass(frozen=True, slots=True)
class SchedulingParticipantPrivacyRequest:
"""Context for resolving what one Scheduling participant may see.
``requested_visibility`` is the Scheduling-owned configuration result. A
policy provider may preserve or reduce it, but must not broaden it. Core
deliberately defines no fallback when the optional capability is absent;
that secure default remains the responsibility of Scheduling.
"""
tenant_id: str
scheduling_request_id: str
participant_id: str
requested_visibility: SchedulingParticipantVisibility
actor_user_id: str | None = None
context: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class SchedulingParticipantPrivacyDecision:
"""Policy-resolved visibility of other participants' names and statuses."""
effective_visibility: SchedulingParticipantVisibility
reason: str | None = None
source_path: tuple[PolicySourceStep, ...] = ()
details: Mapping[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"effective_visibility": self.effective_visibility,
"reason": self.reason,
"source_path": [step.to_dict() for step in self.source_path],
"details": dict(self.details),
}
@runtime_checkable
class SchedulingParticipantPrivacyPolicy(Protocol):
"""Optional policy hook for Scheduling participant-roster disclosure."""
def resolve_scheduling_participant_visibility(
self,
session: object,
*,
request: SchedulingParticipantPrivacyRequest,
) -> SchedulingParticipantPrivacyDecision:
...
@runtime_checkable
class PrivacyRetentionService(Protocol):
def privacy_policy_from_settings(self, *args: Any, **kwargs: Any) -> Any:
@@ -160,3 +211,16 @@ class PrivacyRetentionService(Protocol):
def apply_retention_policy(self, *args: Any, **kwargs: Any) -> Any:
...
def scheduling_participant_privacy_policy(
registry: object | None,
) -> SchedulingParticipantPrivacyPolicy | None:
"""Return the optional provider without selecting a visibility fallback."""
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY):
return None
capability = registry.capability(CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY)
return capability if isinstance(capability, SchedulingParticipantPrivacyPolicy) else None

View File

@@ -0,0 +1,267 @@
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 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 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, ...] = ()
@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 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:
...
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

View File

@@ -186,46 +186,20 @@ class PlatformRegistry:
def validate(self) -> RegistrySnapshot:
ordered = tuple(self._topologically_sorted())
available_capabilities = set(self._capability_factories)
seen_permissions: dict[str, PermissionDefinition] = {}
for manifest in ordered:
_validate_manifest_shape(manifest)
for dependency in manifest.dependencies:
if dependency not in self._manifests:
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")
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_manifest_relationships(
manifest,
known_modules=self._manifests,
available_capabilities=available_capabilities,
)
permissions = _collect_manifest_permissions(ordered)
_validate_interface_closure(ordered)
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}")
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
return RegistrySnapshot(
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),
nav_items=self.nav_items(),
)
@@ -299,7 +273,105 @@ def _attribute_delete_veto_issue(
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:
_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)
def _validate_manifest_identity(manifest: ModuleManifest) -> None:
if not _MODULE_ID_RE.match(manifest.id):
raise RegistryError(f"Module manifest id must match {_MODULE_ID_RE.pattern}: {manifest.id!r}")
if not manifest.name.strip():
@@ -313,12 +385,17 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
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, "optional_dependencies", manifest.optional_dependencies)
_validate_capability_list(manifest.id, "required_capabilities", manifest.required_capabilities)
_validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities)
_validate_interface_providers(manifest.id, manifest.provides_interfaces)
_validate_interface_requirements(manifest.id, manifest.requires_interfaces)
def _validate_manifest_overlaps(manifest: ModuleManifest) -> None:
overlap = set(manifest.dependencies) & set(manifest.optional_dependencies)
if overlap:
joined = ", ".join(sorted(overlap))
@@ -328,36 +405,42 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
joined = ", ".join(sorted(capability_overlap))
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.module_id != manifest.id:
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:
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):
_validate_frontend_route(manifest.id, route.path, route.component)
for item in frontend.nav_items:
_validate_nav_item(manifest.id, item)
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_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list)
for manifest in manifests:

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
from typing import Any
from govoplan_core.core.modules import ModuleContext
_context: ModuleContext | None = None
@@ -21,3 +23,41 @@ def get_runtime_context() -> ModuleContext | None:
def get_registry() -> object | 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,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,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

@@ -65,7 +65,7 @@ def bootstrap_dev_data(
api_key_secret: str | None = None,
tenant_slug: str = "default",
user_email: str = "admin@example.local",
user_password: str = "dev-admin",
user_password: str = "dev-admin", # noqa: S107 - development bootstrap only.
) -> BootstrapResult:
tenant = session.query(Tenant).filter(Tenant.slug == tenant_slug).one_or_none()
if tenant is None:

View File

@@ -1,10 +1,12 @@
from __future__ import annotations
from collections.abc import Mapping
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, replace
import json
import logging
import os
from pathlib import Path
import re
from typing import Any
from alembic import command
@@ -24,6 +26,8 @@ from govoplan_core.server.config import ManifestFactory
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.settings import settings
logger = logging.getLogger(__name__)
# Historic development databases could be created partly through Alembic and
# 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
@@ -44,6 +48,7 @@ MIGRATION_TASK_PHASES = (*PRE_MIGRATION_TASK_PHASES, *POST_MIGRATION_TASK_PHASES
MIGRATION_TRACK_RELEASE = "release"
MIGRATION_TRACK_DEV = "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 = (
("tenants", "tenancy_tenants"),
@@ -164,6 +169,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)
class MigrationResult:
previous_revision: str | None
@@ -201,7 +215,10 @@ def _registered_module_registry(
server_config = get_server_config()
active_database_url = database_url or settings.database_url
if active_database_url:
configure_database(active_database_url)
# Registry planning may target a different database than the currently
# configured handle. The global handle is replaced either way, so
# dispose its superseded pool instead of leaking open DBAPI connections.
configure_database(active_database_url, dispose_previous=True)
active_manifest_factories = manifest_factories or tuple(server_config.manifest_factories)
raw_enabled_modules = tuple(enabled_modules) if enabled_modules is not None else load_startup_enabled_modules(server_config.enabled_modules)
candidate_modules = startup_candidate_module_ids(server_config.enabled_modules, raw_enabled_modules)
@@ -231,10 +248,7 @@ def run_registered_module_migration_tasks(
dry_run: bool = False,
manifest_factories: tuple[ManifestFactory, ...] = (),
) -> tuple[dict[str, object], ...]:
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))
active_phases = _normalized_migration_task_phases(phases)
url = database_url or settings.database_url
registry = _registered_module_registry(
database_url=url,
@@ -242,91 +256,192 @@ def run_registered_module_migration_tasks(
manifest_factories=manifest_factories,
)
manifests = {manifest.id: manifest for manifest in registry.manifests()}
ordered_ids = tuple(dict.fromkeys([
*(str(item).strip() for item in (migration_module_order or ()) if str(item).strip()),
*manifests.keys(),
]))
ordered_ids = _ordered_migration_task_module_ids(migration_module_order, manifests)
records: list[dict[str, object]] = []
database = get_database()
with database.SessionLocal() as session:
for phase in active_phases:
for module_id in ordered_ids:
manifest = manifests.get(module_id)
if manifest is None or manifest.migration_spec is None:
continue
for task in manifest.migration_spec.migration_tasks:
if task.phase != phase:
continue
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
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()
for manifest, task in _iter_phase_migration_tasks(phase, ordered_ids=ordered_ids, manifests=manifests):
_run_registered_module_migration_task(
session,
manifest=manifest,
task=task,
database_url=url,
dry_run=dry_run,
records=records,
)
return tuple(records)
def _normalized_migration_task_phases(phases: tuple[str, ...] | list[str]) -> tuple[str, ...]:
active_phases = tuple(dict.fromkeys(str(item).strip() for item in phases if str(item).strip()))
invalid_phases = tuple(phase for phase in active_phases if phase not in MIGRATION_TASK_PHASES)
if invalid_phases:
raise ValueError("Unsupported module migration task phase(s): " + ", ".join(invalid_phases))
return active_phases
def _ordered_migration_task_module_ids(
migration_module_order: tuple[str, ...] | list[str] | None,
manifests: Mapping[str, object],
) -> tuple[str, ...]:
return tuple(dict.fromkeys([
*(str(item).strip() for item in (migration_module_order or ()) if str(item).strip()),
*manifests.keys(),
]))
def _iter_phase_migration_tasks(
phase: str,
*,
ordered_ids: tuple[str, ...],
manifests: Mapping[str, object],
) -> Iterable[tuple[object, object]]:
for module_id in ordered_ids:
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:
if result is None:
return ModuleMigrationTaskResult()
@@ -521,19 +636,28 @@ def _backfill_user_lock_state_for_create_all_schema(database_url: str) -> None:
def _row_count(connection, table_name: str) -> int:
quoted = connection.dialect.identifier_preparer.quote(table_name)
return int(connection.execute(text(f"SELECT COUNT(*) FROM {quoted}")).scalar_one())
quoted = _quoted_table_name(connection, table_name)
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:
quoted = connection.dialect.identifier_preparer.quote(table_name)
connection.execute(text(f"DROP TABLE {quoted}"))
quoted = _quoted_table_name(connection, table_name)
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:
quoted_old = connection.dialect.identifier_preparer.quote(old_name)
quoted_new = connection.dialect.identifier_preparer.quote(new_name)
connection.execute(text(f"ALTER TABLE {quoted_old} RENAME TO {quoted_new}"))
quoted_old = _quoted_table_name(connection, old_name)
quoted_new = _quoted_table_name(connection, new_name)
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:
@@ -707,7 +831,8 @@ def reconcile_covered_alembic_dependency_heads(
for revision_id in current:
try:
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
ancestors = {
item.revision
@@ -741,58 +866,68 @@ def reconcile_legacy_create_all_schema(
"""
url = database_url or settings.database_url
engine = create_engine(url)
try:
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
state = _legacy_create_all_schema_state(url)
target = _legacy_create_all_reconciliation_target(state)
if target is None:
return None
if target == REVISION_HIERARCHICAL_SETTINGS:
_backfill_user_lock_state_for_create_all_schema(url)
command.stamp(alembic_config(database_url=url, migration_track=migration_track), target)
return target
def _legacy_create_all_schema_state(database_url: str) -> _LegacyCreateAllSchemaState:
engine = create_engine(database_url)
try:
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(
*,
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

@@ -7,6 +7,8 @@ from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.db.query_metrics import instrument_engine
def default_connect_args(database_url: str) -> dict[str, Any]:
return {"check_same_thread": False} if database_url.startswith("sqlite") else {}
@@ -22,13 +24,13 @@ def create_database_engine(
merged_connect_args = dict(default_connect_args(database_url))
if 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)
return instrument_engine(create_engine(database_url, pool_pre_ping=pool_pre_ping, connect_args=merged_connect_args, **kwargs))
class DatabaseHandle:
def __init__(self, database_url: str, *, engine: Engine | None = None) -> None:
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)
def session(self) -> Session:

View File

@@ -10,6 +10,9 @@ from collections.abc import Iterable, Sequence
from pathlib import Path
from typing import Any
from sqlalchemy.engine import make_url
from sqlalchemy.exc import ArgumentError
from govoplan_core.core.discovery import iter_module_entry_points
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.modules import ModuleManifest
@@ -112,6 +115,14 @@ def validate_sqlite_database_url(database_url: str) -> None:
)
def redacted_database_url(database_url: str) -> str:
try:
return make_url(database_url).render_as_string(hide_password=True)
except (ArgumentError, TypeError, ValueError):
scheme = database_url.partition(":")[0].strip()
return f"{scheme}:<redacted>" if scheme else "<redacted>"
def _env_truthy(value: str | None) -> bool:
return value is not None and value.strip().lower() in {"1", "true", "yes", "on"}
@@ -297,11 +308,11 @@ def print_devserver_summary(state: DevserverState, *, app: str, no_reload: bool)
print(f"Config: {state.config_path or DEFAULT_CONFIG}")
print(f"Runtime root: {state.runtime_root}")
if state.database_url:
print(f"Database: {state.database_url}")
print(f"Database: {redacted_database_url(state.database_url)}")
if state.database_url.startswith("postgresql"):
pgtools_url = os.getenv("GOVOPLAN_DATABASE_URL_PGTOOLS")
if pgtools_url:
print(f"PostgreSQL tools URL: {pgtools_url}")
print(f"PostgreSQL tools URL: {redacted_database_url(pgtools_url)}")
if state.bootstrap_db_path is not None:
bootstrap_state = "enabled" if getattr(state.config.settings, "dev_bootstrap_enabled", False) else "disabled by DEV_BOOTSTRAP_ENABLED"
print(f"Dev bootstrap for missing SQLite DB: {bootstrap_state} ({state.bootstrap_db_path})")

View File

@@ -67,6 +67,32 @@ class ImapConfig(ImapServerConfig):
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:
if security == TransportSecurity.TLS or security == "tls":
return 465

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
"""Compatibility facade for privacy retention policy.
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.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Mapping
from govoplan_core.core.policy import CAPABILITY_POLICY_PRIVACY_RETENTION, PrivacyRetentionService
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"
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 = {
"store_raw_campaign_json": True,
"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:
if key in raw:
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
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:
service = _runtime_service()
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,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()
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
@@ -110,7 +115,7 @@ def compatible_required_scopes(required: str) -> tuple[str, ...]:
def scopes_grant_compatible(scopes: Iterable[str], required: str) -> bool:
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(
scope != "tenant:*" and scopes_grant([scope], alias)
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 typing import Iterable
from govoplan_core.security.scope_aliases import LEGACY_SCOPE_ALIASES
@dataclass(frozen=True, slots=True)
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)
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]] = {
"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": [

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import re
from collections.abc import Mapping
from typing import Any
def sensitive_key_tokens(key: object) -> set[str]:
value = str(key).strip()
@@ -40,7 +40,7 @@ def contains_plain_secret(value: object) -> bool:
normalized_key = str(key).strip().casefold().replace("-", "_")
if normalized_key in {"credential_ref", "secret_ref", "secret_reference"}:
continue
if is_sensitive_key(key) and item not in (None, "", {"secret_ref": ""}):
if is_sensitive_key(key) and item not in (None, "", {"secret_ref": ""}): # nosec B105 - empty redaction sentinels.
return True
if isinstance(item, Mapping) and contains_plain_secret(item):
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
CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider"
CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider" # noqa: S105 # nosec B105 - capability identifier.
@runtime_checkable
@@ -38,8 +38,8 @@ def _normalize_fernet_key(value: str) -> bytes:
try:
Fernet(candidate)
return candidate
except Exception:
pass
except (TypeError, ValueError):
raw = None
try:
raw = base64.b64decode(candidate)
except Exception as exc:

View File

@@ -13,52 +13,13 @@ from govoplan_core.server.route_validation import validate_no_route_collisions
def create_app(config: GovoplanServerConfig | str | None = None):
if isinstance(config, str) or config is None:
server_config = load_server_config(config)
else:
server_config = config
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,
)
server_config = _server_config(config)
_configure_app_database(server_config)
registry, available_modules = _server_module_registry(server_config)
api_router = _server_api_router(server_config, registry)
lifecycle = _server_lifecycle(server_config, registry=registry, available_modules=available_modules)
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(
title=server_config.title,
version=server_config.version,
@@ -74,4 +35,61 @@ def create_app(config: GovoplanServerConfig | str | None = None):
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))
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()

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import os
import re
from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from importlib import import_module
@@ -15,6 +16,10 @@ from govoplan_core.server.fastapi import LifespanFactory
ManifestFactory = Callable[[], ModuleManifest]
RouterEnabled = Callable[[object | None, PlatformRegistry], bool]
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)
@@ -49,16 +54,52 @@ class GovoplanServerConfig:
def import_object(path: str) -> Any:
module_name, separator, attribute = path.partition(":")
if not separator:
raise ValueError(f"Object path must use module:attribute syntax: {path!r}")
module = import_module(module_name)
module_name, attribute = validate_object_path(path)
_validate_trusted_import_prefix(module_name, trusted_import_prefixes())
module = import_module(module_name) # nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import
value: Any = module
for part in attribute.split("."):
value = getattr(value, part)
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:
config_path = path or os.getenv("GOVOPLAN_SERVER_CONFIG") or "govoplan_core.server.default_config:get_server_config"
try:

View File

@@ -1,17 +1,80 @@
from __future__ import annotations
import logging
import os
import time
from collections.abc import AsyncIterator, Callable, Iterable
from contextlib import AbstractAsyncContextManager
from typing import Any
from fastapi import APIRouter, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.trustedhost import TrustedHostMiddleware
from govoplan_core.core.events import event_context, new_event_id, normalize_trace_id
from govoplan_core.core.install_config import validate_runtime_configuration
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.query_metrics import collect_query_metrics
from govoplan_core.server.conditional_requests import conditional_json_get_middleware
from govoplan_core.server.request_limits import RequestBodyLimitMiddleware
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
logger = logging.getLogger("govoplan.request")
_CONTENT_SECURITY_POLICY = "base-uri 'self'; object-src 'none'; frame-ancestors 'none'"
_PRODUCTION_LIKE_ENVIRONMENTS = frozenset(
{"prod", "production", "self-hosted", "staging", "production-like", "production-like-dev"}
)
def _slow_request_threshold_ms() -> float:
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():
logger.warning(
"Redis is not configured; login throttling is process-local and will not coordinate horizontally"
)
def create_govoplan_app(
@@ -24,8 +87,27 @@ def create_govoplan_app(
cors_origins: Iterable[str] = (),
health_payload: dict[str, Any] | None = None,
) -> FastAPI:
_validate_production_startup()
app = FastAPI(title=title, version=version, lifespan=lifespan)
app.add_middleware(RequestBodyLimitMiddleware, max_bytes=_max_request_body_bytes())
trusted_hosts = _trusted_hosts()
if trusted_hosts:
app.add_middleware(TrustedHostMiddleware, allowed_hosts=list(trusted_hosts))
app.state.govoplan_registry = registry
slow_request_threshold_ms = _slow_request_threshold_ms()
hsts_seconds = _hsts_seconds()
@app.middleware("http")
async def security_response_headers(request: Request, call_next):
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
response.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
response.headers.setdefault("Content-Security-Policy", _CONTENT_SECURITY_POLICY)
if hsts_seconds and request.url.scheme == "https":
response.headers.setdefault("Strict-Transport-Security", f"max-age={hsts_seconds}")
return response
@app.middleware("http")
async def request_correlation_context(request: Request, call_next):
@@ -39,6 +121,42 @@ def create_govoplan_app(
response.headers["X-Correlation-ID"] = correlation_id
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)
origins = [item.strip() for item in cors_origins if item.strip()]

View File

@@ -6,6 +6,7 @@ import importlib
from govoplan_core.core.discovery import discover_module_manifests
from govoplan_core.core.modules import ModuleManifest
from govoplan_core.core.registry import PlatformRegistry, RegistryError
from govoplan_core.server.config import validate_object_path
ManifestFactory = Callable[[], ModuleManifest]
@@ -74,8 +75,10 @@ def build_platform_registry(enabled_modules: str | Iterable[str], *, manifest_fa
def _load_builtin_manifest(module_id: str) -> ModuleManifest:
target = _BUILTIN_MANIFESTS[module_id]
module_name, function_name = target.split(":", 1)
module = importlib.import_module(module_name)
module_name, function_name = validate_object_path(target)
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)
manifest = factory()
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

@@ -17,15 +17,15 @@ class Settings(BaseSettings):
tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE")
tenant_db_url_template: str | None = Field(default=None, alias="TENANT_DB_URL_TEMPLATE")
tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE")
enabled_modules: str = Field(default="tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops", alias="ENABLED_MODULES")
enabled_modules: str = Field(default="tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,notifications,docs,ops", alias="ENABLED_MODULES")
migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK")
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
s3_endpoint_url: str = Field(default="http://garage:3900", alias="S3_ENDPOINT_URL")
s3_region: str = Field(default="garage", alias="S3_REGION")
s3_access_key_id: str = Field(default="GKmultimailerdev0000000000000000", alias="S3_ACCESS_KEY_ID")
s3_secret_access_key: str = Field(default="multimailer-dev-secret-change-me", alias="S3_SECRET_ACCESS_KEY")
s3_access_key_id: str = Field(default="GKgovoplandev0000000000000000000", alias="S3_ACCESS_KEY_ID")
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")
# Managed file storage. Development defaults to local filesystem storage;
@@ -41,19 +41,45 @@ class Settings(BaseSettings):
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")
auth_session_cookie_name: str = Field(default="msm_session", alias="AUTH_SESSION_COOKIE_NAME")
auth_csrf_cookie_name: str = Field(default="msm_csrf", alias="AUTH_CSRF_COOKIE_NAME")
auth_session_cookie_name: str = Field(default="govoplan_session", alias="AUTH_SESSION_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_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE")
auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN")
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS")
auth_login_throttle_enabled: bool = Field(default=True, alias="AUTH_LOGIN_THROTTLE_ENABLED")
auth_login_throttle_identity_limit: int = Field(
default=10,
ge=1,
alias="AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT",
)
auth_login_throttle_client_limit: int = Field(
default=100,
ge=1,
alias="AUTH_LOGIN_THROTTLE_CLIENT_LIMIT",
)
auth_login_throttle_window_seconds: int = Field(
default=15 * 60,
ge=1,
alias="AUTH_LOGIN_THROTTLE_WINDOW_SECONDS",
)
auth_login_throttle_redis_retry_seconds: int = Field(
default=30,
ge=1,
alias="AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS",
)
master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64")
celery_queues: str = Field(default="send_email,append_sent,default", alias="CELERY_QUEUES")
celery_queues: str = Field(default="send_email,append_sent,notifications,calendar,default", alias="CELERY_QUEUES")
calendar_outbox_terminal_retention_days: int = Field(
default=90,
ge=0,
alias="CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS",
)
mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR")
# 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_bootstrap_enabled: bool = Field(default=False, alias="DEV_BOOTSTRAP_ENABLED")
dev_bootstrap_password: str = Field(default="dev-admin", alias="DEV_BOOTSTRAP_PASSWORD")

View File

@@ -83,7 +83,6 @@ from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY,
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import clear_runtime, configure_runtime
from govoplan_core.db.base import Base
from govoplan_core.server.app import create_app
from govoplan_core.server.config import GovoplanServerConfig
from govoplan_core.tenancy.scope import create_scope_tables
from govoplan_access.backend.db.models import (
@@ -1382,6 +1381,8 @@ class AccessContractTests(unittest.TestCase):
app_configurators=(),
)
with temporary_database(f"sqlite:///{root / 'test.db'}") as database:
from govoplan_core.server.app import create_app
app = create_app(config)
create_scope_tables(database.engine)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
from __future__ import annotations
import unittest
from unittest.mock import MagicMock, patch
from govoplan_core.celery_app import celery, dispatch_calendar_outbox
class CalendarOutboxWorkerTests(unittest.TestCase):
def test_worker_commits_provider_outcome(self) -> None:
session = MagicMock()
database = MagicMock()
database.SessionLocal.return_value.__enter__.return_value = session
provider = MagicMock()
provider.dispatch_due.return_value = {
"processed": 1,
"succeeded": 1,
"retrying": 0,
"failed": 0,
"operations": [{"id": "operation-1", "status": "succeeded"}],
}
with (
patch("govoplan_core.celery_app._calendar_outbox", return_value=provider),
patch("govoplan_core.db.session.get_database", return_value=database),
):
result = dispatch_calendar_outbox.run("tenant-1", 25)
provider.dispatch_due.assert_called_once_with(
session,
tenant_id="tenant-1",
limit=25,
)
session.commit.assert_called_once_with()
self.assertEqual(result["succeeded"], 1)
def test_calendar_worker_route_and_periodic_recovery_are_registered(self) -> None:
self.assertEqual(
celery.conf.task_routes["govoplan.calendar.dispatch_outbox"],
{"queue": "calendar"},
)
schedule = celery.conf.beat_schedule["calendar-outbox-every-minute"]
self.assertEqual(schedule["task"], "govoplan.calendar.dispatch_outbox")
self.assertEqual(schedule["schedule"], 60.0)
if __name__ == "__main__":
unittest.main()

View File

@@ -4,8 +4,9 @@ import shutil
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi import APIRouter
from fastapi import APIRouter, Request
from fastapi.testclient import TestClient
from govoplan_core.audit.logging import audit_event, audit_operation_context
@@ -142,6 +143,75 @@ class CoreEventTests(unittest.TestCase):
cors_origins=("*",),
)
def test_app_sets_safe_default_browser_headers_and_https_hsts(self) -> None:
with patch.dict(
"os.environ",
{"APP_ENV": "test", "GOVOPLAN_HTTP_HSTS_SECONDS": "86400"},
):
app = create_govoplan_app(
title="security header test",
version="test",
registry=PlatformRegistry(),
)
with TestClient(app, base_url="https://govoplan.example.test") as client:
response = client.get("/health")
self.assertEqual("nosniff", response.headers["X-Content-Type-Options"])
self.assertEqual("DENY", response.headers["X-Frame-Options"])
self.assertEqual("strict-origin-when-cross-origin", response.headers["Referrer-Policy"])
self.assertIn("frame-ancestors 'none'", response.headers["Content-Security-Policy"])
self.assertEqual("max-age=86400", response.headers["Strict-Transport-Security"])
def test_production_app_startup_rejects_an_unvalidated_environment(self) -> None:
with patch.dict("os.environ", {"APP_ENV": "production"}, clear=True), self.assertRaisesRegex(
RuntimeError,
"GovOPlaN configuration validation: FAILED",
):
create_govoplan_app(
title="unsafe production test",
version="test",
registry=PlatformRegistry(),
)
def test_app_rejects_request_bodies_above_deployment_limit(self) -> None:
router = APIRouter()
@router.post("/body")
async def body(request: Request):
return {"size": len(await request.body())}
with patch.dict("os.environ", {"GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES": "10"}):
app = create_govoplan_app(
title="request body limit test",
version="test",
registry=PlatformRegistry(),
api_router=router,
)
with TestClient(app) as client:
response = client.post("/body", content=b"12345678901")
streamed = client.post("/body", content=(chunk for chunk in (b"123456", b"78901")))
self.assertEqual(413, response.status_code, response.text)
self.assertIn("10 bytes", response.json()["detail"])
self.assertEqual(413, streamed.status_code, streamed.text)
def test_app_enforces_configured_trusted_hosts(self) -> None:
with patch.dict("os.environ", {"GOVOPLAN_TRUSTED_HOSTS": "govoplan.example.test,*.internal.test"}):
app = create_govoplan_app(
title="trusted host test",
version="test",
registry=PlatformRegistry(),
)
with TestClient(app, base_url="https://govoplan.example.test") as client:
allowed = client.get("/health")
rejected = client.get("https://attacker.example.test/health")
self.assertEqual(200, allowed.status_code, allowed.text)
self.assertEqual(400, rejected.status_code, rejected.text)
def test_audit_event_persists_trace_details_from_event_context(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-audit-trace-"))
try:

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import logging
import tempfile
import unittest
from pathlib import Path
@@ -39,6 +40,22 @@ def database_migration_heads(connection) -> set[str]:
class DatabaseMigrationTests(unittest.TestCase):
def test_migration_logging_keeps_application_loggers_enabled(self) -> None:
logger = logging.getLogger("govoplan.request")
previous_disabled = logger.disabled
logger.disabled = False
try:
with tempfile.TemporaryDirectory(prefix="govoplan-migration-logging-test-") as directory:
database = Path(directory) / "logging.db"
command.stamp(
alembic_config(database_url=f"sqlite:///{database}", enabled_modules=()),
"heads",
)
self.assertFalse(logger.disabled)
finally:
logger.disabled = previous_disabled
def test_migration_tracks_use_separate_version_locations(self) -> None:
release_locations = alembic_config(database_url="sqlite:////tmp/govoplan-release.db").get_main_option("version_locations")
dev_locations = alembic_config(
@@ -184,6 +201,7 @@ class DatabaseMigrationTests(unittest.TestCase):
self.assertIsNone(result.reconciled_revision)
self.assertEqual(current, configured_migration_heads(url))
self.assertEqual(result.current_revision, ",".join(sorted(current)))
self.assertIn("calendar_outbox_operations", tables)
self.assertIn("calendar_sync_credentials", tables)
self.assertIn("campaign_recipient_import_mapping_profiles", tables)
self.assertIn("file_connector_credentials", tables)
@@ -216,6 +234,7 @@ class DatabaseMigrationTests(unittest.TestCase):
self.assertEqual(result.current_revision, ",".join(sorted(current)))
self.assertIn("0f1e2d3c4b5a", current)
self.assertIn("audit_outbox_events", tables)
self.assertIn("calendar_outbox_operations", tables)
self.assertIn("file_connector_profiles", tables)
self.assertIn("core_scopes", tables)
finally:

View File

@@ -7,8 +7,22 @@ import tempfile
import unittest
from pathlib import Path
from govoplan_core.devserver import redacted_database_url
class DevserverSmokeTests(unittest.TestCase):
def test_database_url_redaction_hides_passwords(self) -> None:
rendered = redacted_database_url(
"postgresql+psycopg://govoplan:database-secret@db.example.test/govoplan"
)
self.assertEqual(
rendered,
"postgresql+psycopg://govoplan:***@db.example.test/govoplan",
)
self.assertNotIn("database-secret", rendered)
self.assertEqual(redacted_database_url("://database-secret"), "<redacted>")
def test_smoke_mode_bootstraps_missing_local_sqlite_database(self) -> None:
repo_root = Path(__file__).resolve().parents[1]
src_root = repo_root / "src"

223
tests/test_http_fetch.py Normal file
View File

@@ -0,0 +1,223 @@
from __future__ import annotations
import io
import unittest
from unittest.mock import patch
from govoplan_core.security.http_fetch import _PolicyRedirectHandler, is_http_url, validate_http_url
from govoplan_core.security.outbound_http import (
DEFAULT_FILE_TRANSFER_BYTES,
DEFAULT_STRUCTURED_RESPONSE_BYTES,
OutboundHttpBlocked,
OutboundResponseTooLarge,
bounded_chunks_bytes,
bounded_response_bytes,
create_outbound_connection,
outbound_http_policy,
validate_outbound_http_url,
validate_unpinned_sdk_host,
validate_unpinned_sdk_http_url,
)
class HttpFetchTests(unittest.TestCase):
def test_validate_http_url_accepts_absolute_http_urls_without_credentials(self) -> None:
self.assertEqual("https://example.test/catalog.json", validate_http_url("https://example.test/catalog.json"))
self.assertTrue(is_http_url("http://example.test/catalog.json"))
def test_validate_http_url_rejects_non_http_urls_and_embedded_credentials(self) -> None:
for value in (
"file:///etc/passwd",
"/relative/catalog.json",
"https://user@example.test/catalog.json",
"https://user:secret@example.test/catalog.json",
):
with self.subTest(value=value):
self.assertFalse(is_http_url(value))
with self.assertRaises(ValueError):
validate_http_url(value)
def test_outbound_policy_has_practical_bounded_defaults(self) -> None:
policy = outbound_http_policy({"APP_ENV": "production"})
self.assertFalse(policy.allow_private_networks)
self.assertEqual(DEFAULT_STRUCTURED_RESPONSE_BYTES, policy.structured_response_bytes)
self.assertEqual(DEFAULT_FILE_TRANSFER_BYTES, policy.file_transfer_bytes)
configured = outbound_http_policy(
{
"APP_ENV": "production",
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "true",
"GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES": "1024",
"GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES": "2048",
}
)
self.assertTrue(configured.allow_private_networks)
self.assertEqual(1024, configured.structured_response_bytes)
self.assertEqual(2048, configured.file_transfer_bytes)
def test_private_network_policy_is_deployment_wide(self) -> None:
denied_policy = outbound_http_policy({"APP_ENV": "production"})
with patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[(2, 1, 6, "", ("127.0.0.1", 443))],
), self.assertRaisesRegex(OutboundHttpBlocked, "non-public network"):
validate_outbound_http_url("https://connector.example.test/path", policy=denied_policy)
allowed_policy = outbound_http_policy(
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "true"}
)
with patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[(2, 1, 6, "", ("127.0.0.1", 443))],
):
self.assertEqual(
"https://127.0.0.1/path",
validate_outbound_http_url("https://127.0.0.1/path", policy=allowed_policy),
)
def test_private_network_policy_still_blocks_non_peer_and_metadata_addresses(self) -> None:
policy = outbound_http_policy(
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "true"}
)
for address in (
"0.0.0.0",
"169.254.169.254",
"224.0.0.1",
"255.255.255.255",
"::",
"::ffff:255.255.255.255",
"64:ff9b::169.254.169.254",
"64:ff9b::255.255.255.255",
"fd00:ec2::254",
"fe80::1",
"ff02::1",
):
with self.subTest(address=address), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[(2, 1, 6, "", (address, 443))],
), self.assertRaisesRegex(OutboundHttpBlocked, "forbidden special-purpose network"):
validate_outbound_http_url("https://connector.example.test/path", policy=policy)
def test_public_policy_rejects_reserved_and_ipv4_embedded_ipv6_addresses(self) -> None:
policy = outbound_http_policy({"APP_ENV": "production"})
for address in (
"fec0::1",
"::127.0.0.1",
"64:ff9b::8.8.8.8",
"64:ff9b::127.0.0.1",
"2002:7f00:1::",
):
with self.subTest(address=address), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[(10, 1, 6, "", (address, 443, 0, 0))],
), self.assertRaises(OutboundHttpBlocked):
validate_outbound_http_url("https://connector.example.test/path", policy=policy)
def test_outbound_policy_rejects_mixed_public_and_private_dns_answers(self) -> None:
policy = outbound_http_policy({"APP_ENV": "production"})
with patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[
(2, 1, 6, "", ("93.184.216.34", 443)),
(2, 1, 6, "", ("10.0.0.4", 443)),
],
), self.assertRaises(OutboundHttpBlocked):
validate_outbound_http_url("https://connector.example.test/path", policy=policy)
def test_bounded_response_rejects_declared_and_streamed_oversize_payloads(self) -> None:
with self.assertRaises(OutboundResponseTooLarge):
bounded_response_bytes(io.BytesIO(b"small"), headers={"Content-Length": "20"}, max_bytes=10)
with self.assertRaises(OutboundResponseTooLarge):
bounded_response_bytes(io.BytesIO(b"eleven-byte"), max_bytes=10)
self.assertEqual(b"ten-bytes!", bounded_response_bytes(io.BytesIO(b"ten-bytes!"), max_bytes=10))
with patch.dict("os.environ", {"GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES": "5"}), self.assertRaises(
OutboundResponseTooLarge
):
bounded_response_bytes(io.BytesIO(b"123456"), max_bytes=10)
def test_bounded_chunks_rejects_an_oversized_chunk_before_retaining_it(self) -> None:
with self.assertRaises(OutboundResponseTooLarge):
bounded_chunks_bytes((b"one-large-chunk",), max_bytes=8)
def test_connection_time_resolution_blocks_dns_rebinding_before_socket_open(self) -> None:
policy = outbound_http_policy({"APP_ENV": "production"})
public = [(2, 1, 6, "", ("93.184.216.34", 443))]
private = [(2, 1, 6, "", ("127.0.0.1", 443))]
with patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
side_effect=(public, private),
) as resolver, patch("govoplan_core.security.outbound_http.socket.socket") as socket_factory:
validate_outbound_http_url("https://connector.example.test/path", policy=policy)
with self.assertRaisesRegex(OutboundHttpBlocked, "non-public network"):
create_outbound_connection("connector.example.test", 443, policy=policy)
self.assertEqual(2, resolver.call_count)
socket_factory.assert_not_called()
def test_unpinned_sdk_endpoints_fail_closed_in_public_and_private_modes(self) -> None:
for allow_private, address in ((False, "93.184.216.34"), (True, "10.0.0.5")):
with self.subTest(allow_private=allow_private):
policy = outbound_http_policy(
{
"APP_ENV": "production",
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": str(allow_private).lower(),
}
)
with patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[(2, 1, 6, "", (address, 443))],
), self.assertRaisesRegex(OutboundHttpBlocked, "until that transport supports.*DNS/IP pinning"):
validate_unpinned_sdk_http_url(
"https://objects.example.test",
label="S3 endpoint",
policy=policy,
)
def test_unpinned_host_sdk_fails_closed_for_dns_and_explicit_ip_targets(self) -> None:
policy = outbound_http_policy(
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "true"}
)
for host in ("files.internal.example", "10.0.0.5"):
with self.subTest(host=host), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[(2, 1, 6, "", ("10.0.0.5", 445))],
), self.assertRaisesRegex(OutboundHttpBlocked, "redirects/referrals.*DNS/IP pinning"):
validate_unpinned_sdk_host(host, port=445, label="SMB endpoint", policy=policy)
def test_core_redirects_strip_credentials_cross_origin_and_reject_https_downgrades(self) -> None:
import urllib.request
request = urllib.request.Request(
"https://catalog.example.test/releases",
headers={"Authorization": "Bearer secret", "X-Request-ID": "request-1"},
)
handler = _PolicyRedirectHandler(label="Catalog URL")
with patch.dict("os.environ", {"APP_ENV": "test"}), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[(2, 1, 6, "", ("127.0.0.1", 443))],
):
redirected = handler.redirect_request(
request,
None,
302,
"Found",
{},
"https://cdn.example.test/releases",
)
downgrade = handler.redirect_request(
request,
None,
302,
"Found",
{},
"http://catalog.example.test/releases",
)
self.assertIsNotNone(redirected)
self.assertIsNone(redirected.get_header("Authorization"))
self.assertEqual("request-1", redirected.get_header("X-request-id"))
self.assertIsNone(downgrade)
if __name__ == "__main__":
unittest.main()

View File

@@ -396,8 +396,11 @@ class IdentityOrganizationContractTests(unittest.TestCase):
from govoplan_organizations.backend.directory import SqlOrganizationDirectory
identity_directory = SqlIdentityDirectory()
idm_directory = SqlIdmDirectory()
organization_directory = SqlOrganizationDirectory()
idm_directory = SqlIdmDirectory(
identities=identity_directory,
organizations=organization_directory,
)
self.assertEqual("Directory Person", identity_directory.get_identity("identity-directory").display_name) # type: ignore[union-attr]
self.assertEqual("identity-directory", identity_directory.identity_for_account("account-directory").id) # type: ignore[union-attr]

View File

@@ -12,11 +12,18 @@ from govoplan_access.backend.db.models import Account, User
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.configuration_control import configuration_control_snapshot, create_configuration_change_request
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, CAPABILITY_IDENTITY_SEARCH
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import clear_runtime, configure_runtime
from govoplan_core.db.base import Base
from govoplan_identity.backend.directory import SqlIdentityDirectory
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment, IdmTenantSettings
from govoplan_idm.backend.api.v1.routes import router as idm_router
from govoplan_idm.backend.manifest import get_manifest
from govoplan_organizations.backend.directory import SqlOrganizationDirectory
from govoplan_organizations.backend.db.models import OrganizationFunction, OrganizationUnit
from tests.db_isolation import temporary_database
@@ -50,7 +57,37 @@ class IdmApiTests(unittest.TestCase):
)
def _app(self, scopes: set[str]) -> FastAPI:
identity_directory = SqlIdentityDirectory()
organization_directory = SqlOrganizationDirectory()
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="identity",
name="Identity",
version="test",
capability_factories={
CAPABILITY_IDENTITY_DIRECTORY: lambda context: identity_directory,
CAPABILITY_IDENTITY_SEARCH: lambda context: identity_directory,
},
)
)
registry.register(
ModuleManifest(
id="organizations",
name="Organizations",
version="test",
capability_factories={
CAPABILITY_ORGANIZATION_DIRECTORY: lambda context: organization_directory,
},
)
)
context = ModuleContext(registry=registry, settings=object())
registry.configure_capability_context(context)
configure_runtime(context)
self.addCleanup(clear_runtime)
app = FastAPI()
app.state.govoplan_registry = registry
app.include_router(idm_router, prefix="/api/v1")
app.dependency_overrides[get_api_principal] = lambda: self._principal(scopes)
return app

View File

@@ -0,0 +1,50 @@
from __future__ import annotations
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from govoplan_core.server.config import import_object, validate_object_path
from govoplan_core.server.registry import available_module_manifests
class ImportTrustBoundaryTests(unittest.TestCase):
def test_object_path_validation_rejects_invalid_shapes(self) -> None:
with self.assertRaisesRegex(ValueError, "module:attribute"):
validate_object_path("govoplan_core.server.default_config")
with self.assertRaisesRegex(ValueError, "valid Python module"):
validate_object_path("os;system:path")
with self.assertRaisesRegex(ValueError, "public attribute"):
validate_object_path("govoplan_core.server.default_config:_private")
def test_import_object_allows_default_govoplan_config_path(self) -> None:
value = import_object("govoplan_core.server.default_config:get_server_config")
self.assertTrue(callable(value))
def test_import_object_rejects_untrusted_stdlib_module(self) -> None:
with self.assertRaisesRegex(ValueError, "outside trusted import prefixes"):
import_object("os:path")
def test_import_object_allows_explicit_custom_prefix(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-config-import-") as directory:
root = Path(directory)
(root / "custom_config.py").write_text("value = 42\n", encoding="utf-8")
sys.path.insert(0, str(root))
try:
with patch.dict(os.environ, {"GOVOPLAN_TRUSTED_IMPORT_PREFIXES": "custom_config"}):
self.assertEqual(import_object("custom_config:value"), 42)
finally:
sys.path.remove(str(root))
sys.modules.pop("custom_config", None)
def test_builtin_manifest_imports_are_loaded_from_fixed_targets(self) -> None:
manifests = available_module_manifests(enabled_modules=("access",), ignore_load_errors=True)
if "access" in manifests:
self.assertEqual(manifests["access"].id, "access")
if __name__ == "__main__":
unittest.main()

View File

@@ -3,11 +3,24 @@ from __future__ import annotations
import unittest
from cryptography.fernet import Fernet
from pydantic import ValidationError
from govoplan_core.core.install_config import env_template, validate_runtime_configuration
from govoplan_core.settings import Settings
class InstallConfigTests(unittest.TestCase):
def test_calendar_outbox_retention_setting_is_validated(self) -> None:
self.assertEqual(Settings().calendar_outbox_terminal_retention_days, 90)
self.assertEqual(
Settings(
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS="0"
).calendar_outbox_terminal_retention_days,
0,
)
with self.assertRaises(ValidationError):
Settings(CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS="-1")
def test_self_hosted_validation_reports_actionable_missing_settings(self) -> None:
result = validate_runtime_configuration({}, profile="self-hosted")
@@ -32,7 +45,9 @@ class InstallConfigTests(unittest.TestCase):
"CELERY_ENABLED": "true",
"REDIS_URL": "redis://redis.example.internal:6379/0",
"CORS_ORIGINS": "https://govoplan.example.org",
"GOVOPLAN_TRUSTED_HOSTS": "govoplan.example.org",
"AUTH_COOKIE_SECURE": "true",
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false",
"FILE_STORAGE_BACKEND": "local",
"FILE_STORAGE_LOCAL_ROOT": "/var/lib/govoplan/files",
},
@@ -62,14 +77,57 @@ class InstallConfigTests(unittest.TestCase):
self.assertIn("DATABASE_URL", error_keys)
self.assertIn("DEV_BOOTSTRAP_ENABLED", error_keys)
def test_production_validation_rejects_wildcard_host_and_proxy_trust(self) -> None:
result = validate_runtime_configuration(
{
"APP_ENV": "production",
"GOVOPLAN_TRUSTED_HOSTS": "*",
"FORWARDED_ALLOW_IPS": "*",
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false",
},
profile="self-hosted",
)
error_keys = {issue.key for issue in result.errors}
self.assertIn("GOVOPLAN_TRUSTED_HOSTS", error_keys)
self.assertIn("FORWARDED_ALLOW_IPS", error_keys)
def test_connector_deployment_allowlists_require_exact_names_and_absolute_paths(self) -> None:
result = validate_runtime_configuration(
{
"APP_ENV": "development",
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST": "VALID_SECRET,invalid-name",
"GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST": "relative/connector-ca.pem",
},
profile="development",
)
error_keys = {issue.key for issue in result.errors}
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST", error_keys)
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST", error_keys)
def test_env_templates_surface_install_profiles(self) -> None:
self_hosted = env_template(profile="self-hosted")
production_like = env_template(profile="production-like", generate_secrets=True)
self.assertIn("GOVOPLAN_INSTALL_PROFILE=self-hosted", self_hosted)
self.assertIn("MASTER_KEY_B64=<generate", self_hosted)
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", self_hosted)
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false", self_hosted)
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", self_hosted)
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", self_hosted)
self.assertIn("GOVOPLAN_TRUSTED_HOSTS=govoplan.example.org", self_hosted)
self.assertIn("GOVOPLAN_HTTP_HSTS_SECONDS=31536000", self_hosted)
self.assertIn("AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10", self_hosted)
self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like)
self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", production_like)
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true", production_like)
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", production_like)
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", production_like)
self.assertIn("GOVOPLAN_TRUSTED_HOSTS=127.0.0.1,localhost,testserver", production_like)
self.assertIn("GOVOPLAN_HTTP_HSTS_SECONDS=0", production_like)
self.assertIn("AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10", production_like)
if __name__ == "__main__":

32
tests/test_mail_config.py Normal file
View File

@@ -0,0 +1,32 @@
from __future__ import annotations
import unittest
from govoplan_core.mail.config import normalize_split_transport_credentials
class MailConfigTests(unittest.TestCase):
def test_normalize_split_transport_credentials_moves_legacy_auth_fields(self) -> None:
payload = normalize_split_transport_credentials(
{
"smtp": {"host": "smtp.example.test", "username": "smtp-user", "password": "smtp-secret"},
"imap": {"host": "imap.example.test", "enabled": True, "username": "imap-user"},
"credentials": {"smtp": {"username": "existing"}},
}
)
self.assertEqual(
{
"smtp": {"host": "smtp.example.test"},
"imap": {"host": "imap.example.test"},
"credentials": {
"smtp": {"username": "existing", "password": "smtp-secret"},
"imap": {"username": "imap-user"},
},
},
payload,
)
if __name__ == "__main__":
unittest.main()

View File

@@ -6,21 +6,22 @@ import base64
import json
import os
import re
import runpy
import shlex
import shutil
import stat
import subprocess
import sys
import tempfile
import textwrap
import tomllib
import unittest
import urllib.error
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from sqlalchemy import Column, Integer, MetaData, Table, inspect
from sqlalchemy import Column, Integer, MetaData, Table, create_engine, insert, inspect
from sqlalchemy.orm import Session
# Keep the default app import side effect from bootstrapping a development DB.
_TEST_ROOT = Path(tempfile.mkdtemp(prefix="govoplan-module-tests-"))
@@ -35,6 +36,7 @@ from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from govoplan_core.core.events import event_context
from govoplan_core.core import module_installer as module_installer_module
from govoplan_core.core.migrations import migration_metadata_plan
from govoplan_core.core.module_management import (
ModuleInstallPlan,
@@ -99,7 +101,7 @@ from govoplan_core.core.module_package_catalog import (
)
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult
from govoplan_core.core.module_guards import drop_table_retirement_provider
from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest
from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest, RoleTemplate
from govoplan_core.core.registry import PlatformRegistry, RegistryError
from govoplan_core.admin.models import SystemSettings
from govoplan_core.db.base import Base
@@ -227,7 +229,7 @@ class ModuleSystemTests(unittest.TestCase):
self.assertTrue({"access", "admin", "tenancy", "policy", "audit", "dashboard", "files", "mail", "campaigns", "docs", "ops"}.issubset(manifests))
self.assertEqual(manifests["campaigns"].dependencies, ())
self.assertTrue(manifests["campaigns"].required_capabilities)
self.assertEqual(manifests["campaigns"].optional_dependencies, ("files", "mail"))
self.assertEqual(manifests["campaigns"].optional_dependencies, ("files", "mail", "notifications", "addresses"))
self.assertEqual(manifests["dashboard"].dependencies, ())
self.assertTrue(manifests["dashboard"].required_capabilities)
self.assertEqual(manifests["docs"].dependencies, ())
@@ -272,14 +274,16 @@ class ModuleSystemTests(unittest.TestCase):
self.assertTrue(scopes_grant_compatible(["admin:users:read"], "access:membership:read"))
self.assertTrue(scopes_grant_compatible(["access:tenant:read"], "system:tenants:read"))
self.assertTrue(scopes_grant_compatible(["system:*"], "access:tenant:read"))
self.assertFalse(scopes_grant_compatible(["tenant:*"], "system:tenants:read"))
self.assertFalse(scopes_grant_compatible(["tenant:*"], "access:tenant:read"))
def test_core_webui_retired_legacy_admin_api_surface(self) -> None:
webui_src = Path(__file__).resolve().parents[1] / "webui" / "src"
legacy_admin_import_pattern = re.compile(r"api/admin(?=[\"'#?]|$)")
self.assertFalse((webui_src / "api" / "admin.ts").exists())
for path in webui_src.rglob("*.ts*"):
with self.subTest(path=path.relative_to(webui_src)):
self.assertNotIn("api/admin", path.read_text(encoding="utf-8"))
self.assertIsNone(legacy_admin_import_pattern.search(path.read_text(encoding="utf-8")))
def test_platform_modules_own_live_legacy_model_tables(self) -> None:
from govoplan_admin.backend.db.models import GovernanceTemplate
@@ -355,6 +359,88 @@ class ModuleSystemTests(unittest.TestCase):
with self.assertRaisesRegex(RegistryError, "unsupported manifest contract version"):
registry.validate()
def test_default_authenticated_role_templates_are_managed_tenant_roles(self) -> None:
for template, message in (
(
RoleTemplate(
slug="invalid-system-default",
name="Invalid",
description="Invalid system default.",
permissions=("system:*",),
level="system",
default_authenticated=True,
),
"must be tenant-level",
),
(
RoleTemplate(
slug="invalid-unmanaged-default",
name="Invalid",
description="Invalid unmanaged default.",
permissions=("tenant:*",),
managed=False,
default_authenticated=True,
),
"must be managed",
),
):
with self.subTest(template=template.slug):
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="example",
name="Example",
version="test",
role_templates=(template,),
)
)
with self.assertRaisesRegex(RegistryError, message):
registry.validate()
def test_default_authenticated_role_templates_reject_wildcard_permissions(self) -> None:
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="example",
name="Example",
version="test",
role_templates=(
RoleTemplate(
slug="unsafe-default",
name="Unsafe default",
description="Must not grant broad authority automatically.",
permissions=("tenant:*",),
default_authenticated=True,
),
),
)
)
with self.assertRaisesRegex(RegistryError, "must use explicit permissions"):
registry.validate()
def test_role_template_slugs_are_unique_per_level_across_modules(self) -> None:
registry = PlatformRegistry()
for module_id in ("first", "second"):
registry.register(
ModuleManifest(
id=module_id,
name=module_id.title(),
version="test",
role_templates=(
RoleTemplate(
slug="shared-reader",
name="Shared reader",
description="A colliding role template.",
permissions=(),
),
),
)
)
with self.assertRaisesRegex(RegistryError, "Duplicate tenant role template slug"):
registry.validate()
def test_registry_rejects_invalid_frontend_manifest_shape(self) -> None:
registry = PlatformRegistry()
registry.register(ModuleManifest(
@@ -2037,6 +2123,25 @@ finally:
self.assertEqual(["check", "verify"], [record["task_id"] for record in records])
self.assertEqual(["warning", "warning"], [record["status"] for record in records])
def test_registered_module_migration_tasks_dispose_replaced_database(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-migration-dispose-", dir=_TEST_ROOT))
previous = configure_database(f"sqlite:///{root / 'previous.db'}")
previous_pool = previous.engine.pool
manifest = ModuleManifest(id="taskmod", name="Task Module", version="1.0.0")
try:
run_registered_module_migration_tasks(
database_url=f"sqlite:///{root / 'target.db'}",
enabled_modules=("taskmod",),
phases=(),
manifest_factories=(lambda: manifest,),
)
self.assertIsNot(previous.engine.pool, previous_pool)
self.assertEqual(f"sqlite:///{root / 'target.db'}", get_database().database_url)
finally:
reset_database(dispose=True)
def test_module_installer_preflight_blocks_provider_update_that_breaks_installed_consumer(self) -> None:
available = {
"files": ModuleManifest(
@@ -2348,6 +2453,31 @@ finally:
self.assertEqual("example", record["retirements"][0]["module_id"])
self.assertIn("database_backup", record["snapshot"])
def test_drop_table_retirement_is_atomic_with_session_writes(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-retirement-transaction-", dir=_TEST_ROOT))
engine = create_engine(
f"sqlite:///{root / 'retirement.db'}",
connect_args={"timeout": 0.1},
)
metadata = MetaData()
audit_table = Table("retirement_audit", metadata, Column("id", Integer, primary_key=True))
owned_table = Table("retirement_owned", metadata, Column("id", Integer, primary_key=True))
metadata.create_all(bind=engine)
owned_model = type("OwnedModel", (), {"__table__": owned_table})
with Session(engine) as session:
session.execute(insert(audit_table).values(id=1))
plan = drop_table_retirement_provider(owned_model, label="Owned")(session, "owned")
self.assertIsNotNone(plan.destroy_data_executor)
plan.destroy_data_executor(session, "owned") # type: ignore[misc]
self.assertFalse(inspect(session.connection()).has_table("retirement_owned"))
session.rollback()
self.assertTrue(inspect(engine).has_table("retirement_owned"))
with engine.connect() as connection:
self.assertEqual([], connection.execute(audit_table.select()).all())
engine.dispose()
def test_module_installer_dry_run_writes_run_record_without_applying(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-dry-run-", dir=_TEST_ROOT))
settings = _settings(root)
@@ -2474,7 +2604,8 @@ finally:
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
def fake_run(*_args, **kwargs):
if kwargs.get("shell"):
argv = tuple(_args[0]) if _args else ()
if kwargs.get("shell") or argv == ("restart-fails",):
return SimpleNamespace(returncode=1, stdout="", stderr="restart failed")
return SimpleNamespace(returncode=0, stdout="", stderr="")
@@ -2559,9 +2690,70 @@ finally:
self.assertEqual("external", database_backup["metadata"]["snapshot"])
self.assertEqual("postgresql://govoplan:***@db.example.invalid/govoplan", database_backup["metadata"]["pgtools_url"])
self.assertEqual("postgresql+psycopg://govoplan:***@db.example.invalid/govoplan", database_backup["stdout"].strip())
self.assertEqual(database_url, (result.record_path.parent / database_backup["database_url_secret"]).read_text(encoding="utf-8"))
secret_path = result.record_path.parent / database_backup["database_url_secret"]
self.assertEqual(database_url, secret_path.read_text(encoding="utf-8"))
self.assertEqual(0o700, stat.S_IMODE(result.record_path.parent.stat().st_mode))
self.assertEqual(0o600, stat.S_IMODE(secret_path.stat().st_mode))
self.assertEqual("backup", (result.record_path.parent / "database.external.backup").read_text(encoding="utf-8"))
def test_installer_secret_creation_refuses_an_existing_path(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-secret-existing-", dir=_TEST_ROOT))
secret_path = root / "database-url.secret"
secret_path.write_text("existing-value", encoding="utf-8")
with self.assertRaisesRegex(module_installer_module.ModuleInstallerError, "Could not create private installer secret file"):
module_installer_module._write_installer_secret(secret_path, "replacement-value")
self.assertEqual("existing-value", secret_path.read_text(encoding="utf-8"))
def test_installer_run_directory_remains_usable_with_a_restrictive_umask(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-run-umask-", dir=_TEST_ROOT))
run_dir = root / "run"
previous_umask = os.umask(0o777)
try:
module_installer_module._create_private_installer_run_dir(run_dir)
finally:
os.umask(previous_umask)
self.assertEqual(0o700, stat.S_IMODE(run_dir.stat().st_mode))
def test_module_installer_rejects_unsafe_external_database_hook_command(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-unsafe-hook-", dir=_TEST_ROOT))
settings = _settings(root)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
backup_command = f"{sys.executable} -c {shlex.quote('print(1)')} && {sys.executable} -c {shlex.quote('print(2)')}"
with database.session() as session:
save_maintenance_mode(session, MaintenanceMode(enabled=True))
plan = save_module_install_plan(session, [{
"module_id": "files",
"action": "install",
"python_package": "govoplan-files",
"python_ref": "govoplan-files==0.1.4",
}])
session.commit()
with self.assertRaisesRegex(module_installer_module.ModuleInstallerError, "Database backup command failed"):
run_module_install_plan(
session=session,
plan=plan,
available=available_module_manifests(),
current_enabled=("tenancy", "access"),
desired_enabled=("tenancy", "access"),
database_url="postgresql://db.example.invalid/govoplan",
runtime_dir=root / "installer",
migrate_database=True,
database_backup_command=backup_command,
database_restore_command="restore-database",
dry_run=True,
)
rejected = module_installer_module._run_operator_command("DATABASE_URL=postgres://db.example.invalid/govoplan pg_dump")
self.assertEqual(2, rejected.returncode)
self.assertIn("Environment assignments", rejected.stderr)
def test_module_installer_external_database_restore_check_runs_before_migrations(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-restore-check-", dir=_TEST_ROOT))
settings = _settings(root)
@@ -2654,11 +2846,13 @@ finally:
queued = queue_module_installer_request(
runtime_dir=runtime_dir,
requested_by="user-1",
tenant_id="tenant-1",
options={"migrate_database": True, "health_urls": ["http://127.0.0.1:8000/health"]},
)
request_id = str(queued["request_id"])
self.assertEqual("queued", queued["status"])
self.assertEqual("tenant-1", queued["tenant_id"])
self.assertEqual("installer-trace-1", queued["trace"]["correlation_id"])
self.assertEqual((request_id,), tuple(item["request_id"] for item in list_module_installer_requests(runtime_dir=runtime_dir)))
claimed = claim_next_module_installer_request(runtime_dir=runtime_dir)
@@ -2675,7 +2869,7 @@ finally:
self.assertEqual("completed", updated["status"])
self.assertEqual("completed", read_module_installer_request(runtime_dir=runtime_dir, request_id=request_id)["status"])
cancellable = queue_module_installer_request(runtime_dir=runtime_dir, requested_by="user-1")
cancellable = queue_module_installer_request(runtime_dir=runtime_dir, requested_by="user-1", tenant_id="tenant-1")
cancelled = cancel_module_installer_request(
runtime_dir=runtime_dir,
request_id=str(cancellable["request_id"]),
@@ -2691,6 +2885,7 @@ finally:
)
self.assertEqual("queued", retry["status"])
self.assertEqual(cancellable["request_id"], retry["retry_of"])
self.assertEqual("tenant-1", retry["tenant_id"])
settings = _settings(root)
configure_database(settings.database_url)
@@ -2992,27 +3187,21 @@ finally:
self.assertEqual("stable", validation["channel"])
def test_release_catalog_generator_reads_manifest_interface_contracts(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-release-catalog-generator-", dir=_TEST_ROOT))
catalog_path = root / "catalog.json"
result = subprocess.run(
[
sys.executable,
str(Path(__file__).resolve().parents[2] / "govoplan" / "tools" / "generate-release-catalog.py"),
"--version",
"0.1.7",
"--catalog-output",
str(catalog_path),
],
cwd=str(Path(__file__).resolve().parents[1]),
env={**os.environ.copy(), "GOVOPLAN_CORE_ROOT": str(Path(__file__).resolve().parents[1])},
text=True,
capture_output=True,
check=False,
generator = runpy.run_path(
str(Path(__file__).resolve().parents[2] / "govoplan" / "tools" / "release" / "generate-release-catalog.py"),
run_name="govoplan_release_catalog_contract_test",
)
generated_at = generator["datetime"].now(tz=generator["UTC"])
catalog = generator["_catalog_payload"](
version="0.1.9",
tag="v0.1.9",
channel="test",
sequence=1,
generated_at=generated_at,
expires_at=generated_at + generator["timedelta"](days=1),
repository_base="git+ssh://git@example.test/add-ideas",
public_base_url="https://example.test",
)
self.assertEqual(0, result.returncode, result.stderr)
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
modules = {item["module_id"]: item for item in catalog["modules"]}
self.assertIn({"name": "files.campaign_attachments", "version": "0.1.6"}, modules["files"]["provides_interfaces"])
@@ -3023,18 +3212,30 @@ finally:
"version_max_exclusive": "0.2.0",
}, modules["files"]["requires_interfaces"])
self.assertEqual(["campaigns"], modules["files"]["optional_dependencies"])
self.assertIn({"name": "mail.campaign_delivery", "version": "0.1.6"}, modules["mail"]["provides_interfaces"])
self.assertIn({"name": "mail.campaign_delivery", "version": "0.2.0"}, modules["mail"]["provides_interfaces"])
self.assertIn({
"name": "campaigns.access",
"optional": True,
"version_min": "0.1.0",
"version_max_exclusive": "0.2.0",
}, modules["mail"]["requires_interfaces"])
self.assertIn({
"name": "files.campaign_attachments",
"optional": True,
"version_min": "0.1.0",
"version_max_exclusive": "0.2.0",
}, modules["campaigns"]["requires_interfaces"])
self.assertEqual(["files", "mail"], modules["campaigns"]["optional_dependencies"])
self.assertIn({
"name": "mail.campaign_delivery",
"optional": True,
"version_min": "0.2.0",
"version_max_exclusive": "0.3.0",
}, modules["campaigns"]["requires_interfaces"])
self.assertEqual(["files", "mail", "notifications", "addresses"], modules["campaigns"]["optional_dependencies"])
self.assertEqual("requires_review", modules["files"]["migration_safety"])
self.assertIn("migration", modules["files"]["migration_notes"].lower())
self.assertEqual("0.1.7", modules["files"]["version"])
self.assertIn("@v0.1.7", modules["files"]["python_ref"])
self.assertEqual("0.1.9", modules["files"]["version"])
self.assertIn("@v0.1.9", modules["files"]["python_ref"])
def test_module_package_catalog_validates_remote_url_and_cache_fallback(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-remote-", dir=_TEST_ROOT))
@@ -3067,16 +3268,6 @@ finally:
)
body = signed_path.read_text(encoding="utf-8")
class _Response:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def read(self) -> bytes:
return body.encode("utf-8")
env = {
"GOVOPLAN_MODULE_PACKAGE_CATALOG_URL": "https://catalog.example.invalid/stable.json",
"GOVOPLAN_MODULE_PACKAGE_CATALOG_CACHE": str(cache_path),
@@ -3085,11 +3276,11 @@ finally:
}
trusted_keys = {"release-remote": base64.b64encode(public_key).decode("ascii")}
with patch.dict(os.environ, env):
with patch("govoplan_core.core.module_package_catalog.urllib.request.urlopen", return_value=_Response()):
with patch("govoplan_core.core.module_package_catalog.fetch_http_text", return_value=body):
fetched = validate_module_package_catalog(trusted_keys=trusted_keys)
with patch(
"govoplan_core.core.module_package_catalog.urllib.request.urlopen",
side_effect=urllib.error.URLError("offline"),
"govoplan_core.core.module_package_catalog.fetch_http_text",
side_effect=OSError("offline"),
):
cached = validate_module_package_catalog(trusted_keys=trusted_keys)
@@ -3800,12 +3991,14 @@ finally:
self._run_physical_absence_probe(enabled_modules=enabled_modules, blocked_modules=blocked_modules)
def test_module_route_factories_receive_runtime_settings(self) -> None:
app, settings = self._app_for_modules(("files", "mail"))
app, settings = self._app_for_modules(("calendar", "files", "mail"))
self.assertIsNotNone(app)
from govoplan_calendar.backend.runtime import get_settings as get_calendar_settings
from govoplan_files.backend.runtime import get_settings as get_files_settings
from govoplan_mail.backend.runtime import get_settings as get_mail_settings
self.assertIs(settings, get_calendar_settings())
self.assertIs(settings, get_files_settings())
self.assertIs(settings, get_mail_settings())

View File

@@ -0,0 +1,36 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from govoplan_core.core.poll import PollAnswerRef, PollOptionUpdateCommand, PollResponseRef
class PollContractTests(unittest.TestCase):
def test_response_ref_remains_backwards_compatible_and_can_carry_answers(self) -> None:
submitted_at = datetime(2026, 7, 20, tzinfo=timezone.utc)
legacy = PollResponseRef(None, submitted_at)
detailed = PollResponseRef(
invitation_id="invitation-1",
submitted_at=submitted_at,
respondent_id="person-1",
answers=(PollAnswerRef(option_id="option-1", option_key="slot-1", value="available"),),
)
self.assertEqual(legacy.answers, ())
self.assertEqual(detailed.answers[0].value, "available")
def test_option_update_command_is_a_complete_immutable_snapshot(self) -> None:
command = PollOptionUpdateCommand(
label="Tuesday 10:00",
value={"slot_id": "slot-1"},
metadata={"source": "scheduling"},
)
self.assertEqual(command.label, "Tuesday 10:00")
self.assertEqual(command.value, {"slot_id": "slot-1"})
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
import unittest
from pydantic import ValidationError
from govoplan_core.privacy.schemas import (
RETENTION_POLICY_FIELD_KEYS,
PrivacyRetentionPolicyItem,
PrivacyRetentionPolicyPatchItem,
default_allow_lower_level_limits,
normalize_allow_lower_level_limits,
)
class PrivacySchemaContractTests(unittest.TestCase):
def test_default_allow_lower_level_limits_cover_every_retention_field(self) -> None:
self.assertEqual({key: True for key in RETENTION_POLICY_FIELD_KEYS}, default_allow_lower_level_limits())
def test_policy_item_fills_missing_lower_level_limits(self) -> None:
item = PrivacyRetentionPolicyItem.model_validate({"allow_lower_level_limits": {"audit_detail_level": False}})
self.assertFalse(item.allow_lower_level_limits["audit_detail_level"])
self.assertTrue(item.allow_lower_level_limits["store_raw_campaign_json"])
def test_policy_patch_keeps_lower_level_limits_sparse(self) -> None:
patch = PrivacyRetentionPolicyPatchItem.model_validate({"allow_lower_level_limits": {"audit_detail_level": False}})
self.assertEqual({"audit_detail_level": False}, patch.allow_lower_level_limits)
self.assertIsNone(normalize_allow_lower_level_limits("", fill_defaults=False))
def test_unknown_lower_level_limit_field_is_rejected(self) -> None:
with self.assertRaises(ValidationError):
PrivacyRetentionPolicyItem.model_validate({"allow_lower_level_limits": {"unknown": True}})
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,61 @@
from __future__ import annotations
import os
from unittest import TestCase
from unittest.mock import patch
from fastapi import APIRouter
from fastapi.testclient import TestClient
from sqlalchemy import text
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.query_metrics import collect_query_metrics
from govoplan_core.db.session import create_database_engine
from govoplan_core.server.fastapi import create_govoplan_app
class QueryMetricsTests(TestCase):
def test_collect_query_metrics_counts_instrumented_engine_queries(self) -> None:
engine = create_database_engine("sqlite:///:memory:")
try:
with collect_query_metrics() as metrics:
with engine.connect() as connection:
self.assertEqual(1, connection.execute(text("select 1")).scalar_one())
self.assertEqual(1, metrics.query_count)
self.assertGreaterEqual(metrics.total_ms, 0.0)
self.assertGreaterEqual(metrics.slowest_ms, 0.0)
self.assertEqual(0, metrics.error_count)
finally:
engine.dispose()
def test_slow_request_log_includes_query_metrics(self) -> None:
engine = create_database_engine("sqlite:///:memory:")
router = APIRouter()
@router.get("/query")
def query_route() -> dict[str, bool]:
with engine.connect() as connection:
connection.execute(text("select 1")).scalar_one()
return {"ok": True}
try:
with patch.dict(os.environ, {"GOVOPLAN_SLOW_REQUEST_MS": "0.001"}):
app = create_govoplan_app(
title="query metrics test",
version="0",
registry=PlatformRegistry(),
api_router=router,
)
with TestClient(app) as client, self.assertLogs("govoplan.request", level="WARNING") as logs:
response = client.get("/query")
self.assertEqual(200, response.status_code, response.text)
output = "\n".join(logs.output)
self.assertIn("db_query_count=1", output)
self.assertIn("db_time_ms=", output)
self.assertIn("db_slowest_ms=", output)
self.assertIn("db_error_count=0", output)
finally:
engine.dispose()

View File

@@ -1,187 +0,0 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import sys
import unittest
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "release-doctor.py"
def load_doctor_module():
spec = importlib.util.spec_from_file_location("release_doctor", SCRIPT)
if spec is None or spec.loader is None:
raise RuntimeError(f"Could not load {SCRIPT}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
class ReleaseDoctorTests(unittest.TestCase):
def test_release_repo_names_include_catalog_and_migration_baseline_owners(self) -> None:
doctor = load_doctor_module()
names = set(doctor.release_repo_names(ROOT.parent))
self.assertIn("govoplan-core", names)
self.assertIn("govoplan-files", names)
self.assertIn("govoplan-idm", names)
def test_repo_findings_detect_dirty_repositories(self) -> None:
doctor = load_doctor_module()
repo = doctor.RepoState(
name="govoplan-files",
path="/tmp/govoplan-files",
exists=True,
dirty_entries=(" M pyproject.toml",),
pyproject_version="0.1.7",
local_tag_exists=True,
)
findings = doctor.repo_findings((repo,), target_version="0.1.7", target_tag="v0.1.7")
self.assertEqual("blocker", findings[0].severity)
self.assertEqual("repo-dirty", findings[0].check_id)
self.assertIn("git status --short", [item.command for item in findings[0].commands])
def test_dubious_ownership_errors_suggest_safe_directory_command(self) -> None:
doctor = load_doctor_module()
repo = doctor.RepoState(
name="govoplan-files",
path="/tmp/govoplan-files",
exists=True,
safe_directory_required=True,
safe_directory_command="git config --global --add safe.directory /tmp/govoplan-files",
errors=("fatal: detected dubious ownership in repository",),
)
findings = doctor.repo_findings((repo,), target_version="0.1.7", target_tag="v0.1.7")
self.assertEqual("repo-safe-directory", findings[0].check_id)
self.assertEqual("blocker", findings[0].severity)
self.assertTrue(findings[0].commands[0].mutating)
self.assertIn("safe.directory", findings[0].commands[0].command)
def test_dubious_ownership_detection_matches_git_error(self) -> None:
doctor = load_doctor_module()
self.assertTrue(
doctor.is_dubious_ownership_error(
"fatal: detected dubious ownership in repository at '/workspace/repo'\n"
"git config --global --add safe.directory /workspace/repo"
)
)
def test_release_migration_strict_errors_suggest_recording_baseline(self) -> None:
doctor = load_doctor_module()
findings = doctor.migration_findings(
{
"release": {
"ok": True,
"graph_errors": [],
"strict_errors": ["current migration heads are not recorded"],
},
"dev": {"ok": True, "graph_errors": [], "strict_errors": []},
},
target_version="0.2.0",
)
self.assertEqual("migration-release-baseline", findings[0].check_id)
self.assertEqual("blocker", findings[0].severity)
commands = [item.command for item in findings[0].commands]
self.assertTrue(any("--record-release 0.2.0" in item for item in commands))
def test_catalog_findings_detect_missing_signatures(self) -> None:
doctor = load_doctor_module()
findings = doctor.catalog_findings(
{
"catalog_exists": True,
"catalog_path": "/tmp/stable.json",
"core_release_version": "0.1.7",
"signature_count": 0,
"keyring_exists": True,
"key_count": 1,
},
target_version="0.1.7",
online=False,
)
self.assertEqual("catalog-signatures", findings[0].check_id)
self.assertEqual("blocker", findings[0].severity)
def test_suggested_commands_are_deduplicated(self) -> None:
doctor = load_doctor_module()
suggested = doctor.SuggestedCommand(title="Status", command="git status --short", cwd="/tmp/repo")
report = doctor.DoctorReport(
generated_at="2026-07-11T00:00:00Z",
workspace_root="/tmp",
target_version="0.1.7",
target_tag="v0.1.7",
online=False,
overall_status="blocked",
repos=(),
findings=(
doctor.Finding("a", "blocker", "A", "", (suggested,)),
doctor.Finding("b", "warning", "B", "", (suggested,)),
),
)
self.assertEqual((suggested,), doctor.collect_suggested_commands(report))
def test_default_report_is_concise_and_keeps_details_out(self) -> None:
doctor = load_doctor_module()
repo = doctor.RepoState(
name="govoplan-files",
path="/tmp/govoplan-files",
exists=True,
dirty_entries=(" M pyproject.toml",),
)
report = doctor.DoctorReport(
generated_at="2026-07-11T00:00:00Z",
workspace_root="/tmp",
target_version="0.1.7",
target_tag="v0.1.7",
online=False,
overall_status="blocked",
repos=(repo,),
findings=(doctor.Finding("repo-dirty", "blocker", "govoplan-files has uncommitted changes", "M pyproject.toml"),),
)
concise = doctor.render_text_report(report, detailed=False, include_commands=False)
detailed = doctor.render_text_report(report, detailed=True, include_commands=False)
self.assertIn("dirty=1", concise)
self.assertNotIn("M pyproject.toml", concise)
self.assertIn("M pyproject.toml", detailed)
def test_interactive_actions_offer_dirty_bulk_commit_push(self) -> None:
doctor = load_doctor_module()
repo = doctor.RepoState(
name="govoplan-files",
path="/tmp/govoplan-files",
exists=True,
dirty_entries=(" M pyproject.toml",),
)
report = doctor.DoctorReport(
generated_at="2026-07-11T00:00:00Z",
workspace_root="/tmp",
target_version="0.1.7",
target_tag="v0.1.7",
online=False,
overall_status="blocked",
repos=(repo,),
findings=(),
)
actions = dict(doctor.interactive_actions(report))
self.assertIn("commit-push-dirty", actions)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,190 +0,0 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "release-migration-audit.py"
def load_audit_module():
spec = importlib.util.spec_from_file_location("release_migration_audit", SCRIPT)
if spec is None or spec.loader is None:
raise RuntimeError(f"Could not load {SCRIPT}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
class ReleaseMigrationAuditTests(unittest.TestCase):
def test_typed_alembic_variables_are_parsed(self) -> None:
audit = load_audit_module()
with tempfile.TemporaryDirectory(prefix="migration-audit-test-") as directory:
path = Path(directory) / "1234_example.py"
path.write_text(
"""
from __future__ import annotations
from typing import Sequence, Union
revision: str = "1234"
down_revision: Union[str, None] = "base"
depends_on: Union[str, None] = "core"
branch_labels: Union[str, Sequence[str], None] = None
""",
encoding="utf-8",
)
migration = audit.parse_migration_file("govoplan-core", path)
self.assertIsNotNone(migration)
self.assertEqual(migration.revision, "1234")
self.assertEqual(migration.down_revisions, ("base",))
self.assertEqual(migration.depends_on, ("core",))
self.assertEqual(migration.branch_labels, ())
def test_release_baseline_matches_current_heads_in_strict_report(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
audit.Migration("govoplan-core", Path("head.py"), "head", ("base",), (), ()),
]
report = audit.build_report(
migrations,
baseline={
"version": 1,
"releases": [
{
"release": "0.1.0",
"heads": [{"owner": "govoplan-core", "revision": "head"}],
}
],
},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
)
self.assertEqual(report["current_heads"], ["head"])
self.assertEqual(report["graph_errors"], [])
self.assertEqual(report["strict_errors"], [])
def test_owner_heads_are_accepted_for_subset_installs(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
audit.Migration("govoplan-core", Path("core_head.py"), "core-head", ("base",), (), ()),
]
report = audit.build_report(
migrations,
baseline={
"version": 1,
"releases": [
{
"release": "0.1.0",
"heads": [{"owner": "govoplan-files", "revision": "files-head"}],
"owner_heads": [{"owner": "govoplan-core", "revisions": ["core-head"]}],
}
],
},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
)
self.assertEqual(report["current_heads"], ["core-head"])
self.assertEqual(report["strict_errors"], [])
def test_records_current_release_baseline(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
audit.Migration("govoplan-core", Path("core_head.py"), "core-head", ("base",), (), ()),
audit.Migration("govoplan-files", Path("files_head.py"), "files-head", ("core-head",), (), ()),
]
report = audit.build_report(
migrations,
baseline={"version": 1, "releases": []},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
)
baseline = audit.record_release_baseline(
{"version": 1, "releases": []},
report,
release="0.2.0",
replace=False,
)
release = baseline["releases"][0]
self.assertEqual(release["release"], "0.2.0")
self.assertEqual(release["squash_policy"], "reviewed-manual")
self.assertEqual(release["heads"], [{"owner": "govoplan-files", "revision": "files-head"}])
self.assertIn({"owner": "govoplan-core", "revisions": ["core-head"]}, release["owner_heads"])
def test_missing_down_revision_is_a_graph_error(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-core", Path("head.py"), "head", ("missing",), (), ()),
]
report = audit.build_report(
migrations,
baseline={"version": 1, "releases": []},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
)
self.assertIn("references missing down_revision", report["graph_errors"][0])
def test_depends_on_participates_in_graph_validation_and_heads(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-core", Path("core.py"), "core", (), (), ()),
audit.Migration("govoplan-files", Path("files.py"), "files", (), ("core",), ()),
]
report = audit.build_report(
migrations,
baseline={"version": 1, "releases": []},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
)
self.assertEqual(report["graph_errors"], [])
self.assertEqual(report["current_heads"], ["files"])
def test_missing_depends_on_is_a_graph_error(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-files", Path("files.py"), "files", (), ("missing",), ()),
]
report = audit.build_report(
migrations,
baseline={"version": 1, "releases": []},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
)
self.assertIn("references missing depends_on", report["graph_errors"][0])
def test_dev_track_does_not_emit_release_baseline_warnings(self) -> None:
audit = load_audit_module()
migrations = [
audit.Migration("govoplan-core", Path("base.py"), "base", (), (), ()),
audit.Migration("govoplan-core", Path("head.py"), "head", ("base",), (), ()),
]
report = audit.build_report(
migrations,
baseline={"version": 1, "releases": []},
baseline_file=Path("docs/migration-release-baselines.json"),
workspace_root=Path("/workspace"),
track="dev",
)
self.assertEqual(report["track"], "dev")
self.assertEqual(report["strict_errors"], [])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
import unittest
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.policy import (
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
PolicySourceStep,
SchedulingParticipantPrivacyDecision,
SchedulingParticipantPrivacyPolicy,
SchedulingParticipantPrivacyRequest,
scheduling_participant_privacy_policy,
)
from govoplan_core.core.registry import PlatformRegistry
class _PrivacyPolicy:
def resolve_scheduling_participant_visibility(
self,
session: object,
*,
request: SchedulingParticipantPrivacyRequest,
) -> SchedulingParticipantPrivacyDecision:
del session
return SchedulingParticipantPrivacyDecision(
effective_visibility="aggregates_only",
reason=f"Participant roster is restricted for {request.scheduling_request_id}.",
details={"requested_visibility": request.requested_visibility},
)
class SchedulingPrivacyContractTests(unittest.TestCase):
def test_capability_name_and_request_context_are_stable(self) -> None:
self.assertEqual(
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
"policy.schedulingParticipantPrivacy",
)
request = SchedulingParticipantPrivacyRequest(
tenant_id="tenant-1",
scheduling_request_id="request-1",
participant_id="participant-1",
actor_user_id="user-1",
requested_visibility="names_and_statuses",
context={"request_status": "open"},
)
self.assertEqual(request.requested_visibility, "names_and_statuses")
self.assertEqual(request.context["request_status"], "open")
def test_decision_serializes_policy_provenance(self) -> None:
decision = SchedulingParticipantPrivacyDecision(
effective_visibility="aggregates_only",
reason="Tenant privacy policy restricts the participant roster.",
source_path=(
PolicySourceStep(
scope_type="tenant",
scope_id="tenant-1",
label="Tenant",
applied_fields=("scheduling_participant_visibility",),
),
),
details={"requested_visibility": "names_and_statuses"},
)
payload = decision.to_dict()
self.assertEqual(payload["effective_visibility"], "aggregates_only")
self.assertEqual(payload["source_path"][0]["path"], "tenant:tenant-1")
self.assertEqual(payload["details"]["requested_visibility"], "names_and_statuses")
def test_optional_provider_resolves_through_platform_registry(self) -> None:
provider = _PrivacyPolicy()
self.assertIsInstance(provider, SchedulingParticipantPrivacyPolicy)
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="policy_test",
name="Policy test",
version="test",
capability_factories={
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY: lambda context: provider,
},
),
)
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
self.assertIs(scheduling_participant_privacy_policy(registry), provider)
def test_optional_provider_lookup_does_not_choose_a_fallback(self) -> None:
self.assertIsNone(scheduling_participant_privacy_policy(None))
self.assertIsNone(scheduling_participant_privacy_policy(PlatformRegistry()))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,72 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from sqlalchemy import DateTime, String, create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
from govoplan_core.core.sqlalchemy_change_tracking import (
ensure_object_id,
operation_for_object,
operation_for_soft_deletable,
previous_value,
)
class Base(DeclarativeBase):
pass
class ExampleRecord(Base):
__tablename__ = "example_records"
id: Mapped[str] = mapped_column(String, primary_key=True)
name: Mapped[str] = mapped_column(String)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class SqlalchemyChangeTrackingTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine, expire_on_commit=False)
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_pending_object_is_created_and_can_receive_id(self) -> None:
record = ExampleRecord(id="", name="Draft", deleted_at=None)
self.session.add(record)
self.assertEqual(ensure_object_id(record, lambda: "record-1"), "record-1")
self.assertEqual(record.id, "record-1")
self.assertEqual(operation_for_object(record, changed_attrs=("name",)), "created")
def test_changed_object_reports_update_and_previous_value(self) -> None:
record = ExampleRecord(id="record-1", name="Before", deleted_at=None)
self.session.add(record)
self.session.flush()
record.name = "After"
self.assertEqual(operation_for_object(record, changed_attrs=("name",)), "updated")
self.assertEqual(previous_value(record, "name"), "Before")
def test_soft_delete_and_restore_are_classified(self) -> None:
deleted_at = datetime(2026, 7, 13, tzinfo=timezone.utc)
record = ExampleRecord(id="record-1", name="Record", deleted_at=None)
self.session.add(record)
self.session.flush()
record.deleted_at = deleted_at
self.assertEqual(operation_for_soft_deletable(record, changed_attrs=("deleted_at",)), "deleted")
self.session.flush()
record.deleted_at = None
self.assertEqual(operation_for_soft_deletable(record, changed_attrs=("deleted_at",)), "created")
if __name__ == "__main__":
unittest.main()

130
tests/test_throttling.py Normal file
View File

@@ -0,0 +1,130 @@
from __future__ import annotations
import unittest
from redis.exceptions import RedisError
from govoplan_core.core.throttling import (
FixedWindowBucket,
FixedWindowThrottle,
InMemoryFixedWindowStore,
ResilientFixedWindowStore,
ThrottleDimension,
build_fixed_window_throttle,
)
class _Clock:
def __init__(self) -> None:
self.value = 100.0
def __call__(self) -> float:
return self.value
class _RecordingStore:
def __init__(self) -> None:
self.values: dict[str, int] = {}
def read(self, key: str) -> FixedWindowBucket:
return FixedWindowBucket(self.values.get(key, 0), 60)
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
del window_seconds
self.values[key] = self.values.get(key, 0) + 1
return FixedWindowBucket(self.values[key], 60)
def delete(self, key: str) -> None:
self.values.pop(key, None)
class _FailingStore(_RecordingStore):
def read(self, key: str) -> FixedWindowBucket:
del key
raise RedisError("offline")
def increment(self, key: str, *, window_seconds: int) -> FixedWindowBucket:
del key, window_seconds
raise RedisError("offline")
def delete(self, key: str) -> None:
del key
raise RedisError("offline")
class FixedWindowThrottleTests(unittest.TestCase):
def test_blocks_at_limit_and_expires_without_sleeping(self) -> None:
clock = _Clock()
store = InMemoryFixedWindowStore(clock=clock)
throttle = FixedWindowThrottle(store, window_seconds=60)
dimensions = (ThrottleDimension("password", "secret-subject", 3),)
self.assertTrue(throttle.check(dimensions).allowed)
self.assertTrue(throttle.record(dimensions).allowed)
self.assertTrue(throttle.record(dimensions).allowed)
blocked = throttle.record(dimensions)
self.assertFalse(blocked.allowed)
self.assertEqual(blocked.retry_after_seconds, 60)
self.assertFalse(throttle.check(dimensions).allowed)
clock.value += 61
self.assertTrue(throttle.check(dimensions).allowed)
def test_subject_is_hashed_and_success_can_reset_it(self) -> None:
store = _RecordingStore()
throttle = FixedWindowThrottle(store, window_seconds=60)
dimensions = (ThrottleDimension("poll-participation-password", "tenant:token-secret", 2),)
throttle.record(dimensions)
self.assertEqual(len(store.values), 1)
stored_key = next(iter(store.values))
self.assertNotIn("tenant:token-secret", stored_key)
self.assertIn(":poll-participation-password:", stored_key)
throttle.reset(dimensions)
self.assertEqual(store.values, {})
def test_resilient_store_uses_bounded_local_fallback(self) -> None:
clock = _Clock()
fallback = InMemoryFixedWindowStore(max_entries=2, clock=clock)
store = ResilientFixedWindowStore(
_FailingStore(),
fallback,
retry_seconds=30,
clock=clock,
)
first = store.increment("first", window_seconds=60)
second = store.increment("first", window_seconds=60)
store.increment("second", window_seconds=60)
store.increment("third", window_seconds=60)
self.assertEqual(first.count, 1)
self.assertEqual(second.count, 2)
self.assertEqual(fallback.read("first").count, 0)
self.assertEqual(fallback.read("third").count, 1)
def test_builder_operates_without_redis(self) -> None:
throttle = build_fixed_window_throttle(
redis_url=None,
window_seconds=30,
max_local_entries=10,
)
dimensions = (ThrottleDimension("test", "subject", 1),)
self.assertFalse(throttle.record(dimensions).allowed)
self.assertFalse(throttle.check(dimensions).allowed)
def test_rejects_invalid_dimensions(self) -> None:
throttle = FixedWindowThrottle(_RecordingStore(), window_seconds=60)
with self.assertRaisesRegex(ValueError, "At least one"):
throttle.check(())
with self.assertRaisesRegex(ValueError, "positive"):
throttle.check((ThrottleDimension("test", "subject", 0),))
with self.assertRaisesRegex(ValueError, "namespaces"):
throttle.check((ThrottleDimension("Invalid namespace", "subject", 1),))
if __name__ == "__main__":
unittest.main()

View File

@@ -1,14 +1,15 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.8",
"version": "0.1.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.8",
"version": "0.1.10",
"dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui",
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
"@govoplan/admin-webui": "file:../../govoplan-admin/webui",
"@govoplan/audit-webui": "file:../../govoplan-audit/webui",
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
@@ -18,9 +19,11 @@
"@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
"@govoplan/notifications-webui": "file:../../govoplan-notifications/webui",
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
"@govoplan/policy-webui": "file:../../govoplan-policy/webui"
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui"
},
"devDependencies": {
"@types/react": "^19.0.2",
@@ -60,6 +63,22 @@
}
}
},
"../../govoplan-addresses/webui": {
"name": "@govoplan/addresses-webui",
"version": "0.1.8",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-admin/webui": {
"name": "@govoplan/admin-webui",
"version": "0.1.8",
@@ -228,6 +247,25 @@
}
}
},
"../../govoplan-notifications/webui": {
"name": "@govoplan/notifications-webui",
"version": "0.1.8",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-ops/webui": {
"name": "@govoplan/ops-webui",
"version": "0.1.8",
@@ -282,6 +320,25 @@
}
}
},
"../../govoplan-scheduling/webui": {
"name": "@govoplan/scheduling-webui",
"version": "0.1.8",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -1010,6 +1067,10 @@
"resolved": "../../govoplan-access/webui",
"link": true
},
"node_modules/@govoplan/addresses-webui": {
"resolved": "../../govoplan-addresses/webui",
"link": true
},
"node_modules/@govoplan/admin-webui": {
"resolved": "../../govoplan-admin/webui",
"link": true
@@ -1046,6 +1107,10 @@
"resolved": "../../govoplan-mail/webui",
"link": true
},
"node_modules/@govoplan/notifications-webui": {
"resolved": "../../govoplan-notifications/webui",
"link": true
},
"node_modules/@govoplan/ops-webui": {
"resolved": "../../govoplan-ops/webui",
"link": true
@@ -1058,6 +1123,10 @@
"resolved": "../../govoplan-policy/webui",
"link": true
},
"node_modules/@govoplan/scheduling-webui": {
"resolved": "../../govoplan-scheduling/webui",
"link": true
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",

View File

@@ -1,12 +1,12 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.8",
"version": "0.1.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.8",
"version": "0.1.10",
"dependencies": {
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.8",
"version": "0.1.10",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -21,13 +21,21 @@
"build": "tsc && vite build",
"preview": "vite preview --host 127.0.0.1 --port 4173",
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js",
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
"test:data-grid-actions": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/data-grid-actions.test.js",
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
"test:explorer-tree": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/explorer-tree.test.js",
"test:icon-button": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/icon-button.test.js",
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js",
"test:module-permutations": "node scripts/test-module-permutations.mjs",
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js"
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js",
"test:resource-access": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/resource-access-explanation.test.js",
"test:selection-list": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/selection-list.test.js"
},
"dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui",
"@govoplan/admin-webui": "file:../../govoplan-admin/webui",
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
"@govoplan/audit-webui": "file:../../govoplan-audit/webui",
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
@@ -36,9 +44,11 @@
"@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
"@govoplan/notifications-webui": "file:../../govoplan-notifications/webui",
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
"@govoplan/policy-webui": "file:../../govoplan-policy/webui"
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui"
},
"devDependencies": {
"@types/react": "^19.0.2",

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.8",
"version": "0.1.10",
"private": true,
"type": "module",
"main": "src/index.ts",

View File

@@ -0,0 +1,22 @@
import { readFileSync } from "node:fs";
function assert(condition, message) {
if (!condition) throw new Error(message);
}
const source = readFileSync("src/components/Dialog.tsx", "utf8");
const stackSource = readFileSync("src/components/dialogStack.ts", "utf8");
assert(source.includes("ref={panelRef}"), "Dialog wires its panel ref to the focus boundary");
assert(source.includes("tabIndex={-1}"), "Dialog exposes a programmatically focusable fallback");
assert(source.includes("return registerDialog({"), "Dialog registers with the shared modal stack");
assert(!source.includes('window.addEventListener("keydown"'), "Dialog instances do not install competing keyboard listeners");
assert(stackSource.includes('window.addEventListener("keydown", handleWindowKeyDown)'), "the modal stack owns one keyboard listener");
assert(stackSource.includes("handleTopDialogKeyDown(event, currentActiveElement())"), "the keyboard listener delegates only to the topmost dialog");
assert(stackSource.includes('panel.setAttribute("inert", "")'), "underlying dialog panels become inert");
assert(stackSource.includes('panel.setAttribute("aria-hidden", "true")'), "underlying dialogs leave the accessibility tree");
assert(source.includes('data-dialog-stack-state="topmost"'), "Dialog exposes stack state for custom keyboard interactions");
assert(source.includes("dialogIsTopmost(stackIdRef.current)"), "underlying backdrops cannot close their dialog");
assert(source.includes("shouldCloseDialogOnBackdrop(event.target, event.currentTarget, closeOnBackdrop, canClose)"), "Dialog preserves explicit backdrop-close conditions");
console.log("Dialog focus and stack lifecycle structure checks passed.");

View File

@@ -0,0 +1,41 @@
import { readFileSync } from "node:fs";
const source = readFileSync(new URL("../src/components/FileDropZone.tsx", import.meta.url), "utf8");
const normalized = source.replace(/\s+/g, " ");
function assertIncludes(fragment, message) {
if (!normalized.includes(fragment.replace(/\s+/g, " "))) throw new Error(message);
}
assertIncludes(
"function prepareFileDrag(event: ReactDragEvent<HTMLDivElement>) { event.preventDefault(); event.stopPropagation();",
"drag enter/over must prevent the browser default and stop propagation"
);
assertIncludes("onDragEnter={prepareFileDrag}", "drag enter must use the guarded file-drag handler");
assertIncludes("onDragOver={prepareFileDrag}", "drag over must use the guarded file-drag handler");
assertIncludes(
"onDragLeave={(event) => { event.preventDefault(); event.stopPropagation();",
"drag leave must prevent the browser default and stop propagation"
);
assertIncludes(
"onDrop={(event) => { event.preventDefault(); event.stopPropagation(); setDragActive(false);",
"drop must prevent the browser default and stop propagation before resolving files"
);
assertIncludes(
"if (interactionDisabled) { onRejectedDrop?.(\"disabled\"); return; }",
"disabled drops must route the disabled rejection reason"
);
assertIncludes(
"void resolveDroppedFiles(event.dataTransfer).then((files) => {",
"drop must route the event DataTransfer through the shared resolver"
);
assertIncludes(
"if (files.length === 0) { onRejectedDrop?.(\"empty\"); return; } void handleFiles(files);",
"resolved files and empty drops must be routed through their explicit callbacks"
);
assertIncludes(
".catch(() => onRejectedDrop?.(\"unreadable\"))",
"resolver failures must route the unreadable rejection reason"
);
console.log("FileDropZone event and result routing structure is intact.");

View File

@@ -3,34 +3,43 @@ import { spawnSync } from "node:child_process";
const packageByModule = {
access: "@govoplan/access-webui",
admin: "@govoplan/admin-webui",
addresses: "@govoplan/addresses-webui",
audit: "@govoplan/audit-webui",
calendar: "@govoplan/calendar-webui",
campaigns: "@govoplan/campaign-webui",
dashboard: "@govoplan/dashboard-webui",
docs: "@govoplan/docs-webui",
files: "@govoplan/files-webui",
idm: "@govoplan/idm-webui",
mail: "@govoplan/mail-webui",
notifications: "@govoplan/notifications-webui",
organizations: "@govoplan/organizations-webui",
ops: "@govoplan/ops-webui",
policy: "@govoplan/policy-webui"
policy: "@govoplan/policy-webui",
scheduling: "@govoplan/scheduling-webui"
};
const cases = [
{ name: "core-only", modules: [] },
{ name: "access-only", modules: ["access"] },
{ name: "admin-only", modules: ["admin"] },
{ name: "addresses-only", modules: ["addresses"] },
{ name: "access-with-admin", modules: ["access", "admin"] },
{ name: "admin-with-policy-and-audit", modules: ["access", "admin", "policy", "audit"] },
{ name: "dashboard-only", modules: ["dashboard"] },
{ name: "calendar-only", modules: ["calendar"] },
{ name: "files-only", modules: ["files"] },
{ name: "mail-only", modules: ["mail"] },
{ name: "notifications-only", modules: ["notifications"] },
{ name: "organizations-only", modules: ["organizations"] },
{ name: "idm-with-organizations", modules: ["organizations", "idm"] },
{ name: "campaign-only", modules: ["campaigns"] },
{ name: "campaign-with-files-no-mail", modules: ["campaigns", "files"] },
{ name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] },
{ name: "scheduling-only", modules: ["scheduling"] },
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
{ name: "full-product", modules: ["access", "admin", "policy", "audit", "dashboard", "organizations", "idm", "campaigns", "files", "mail", "docs", "ops"] }
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling"] }
];
const npmExec = process.env.npm_execpath;

View File

@@ -1,9 +1,9 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { fetchMe, updateProfile } from "./api/auth";
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
import { fetchPlatformModules, fetchPlatformStatus } from "./api/platform";
import { AUTH_REQUIRED_EVENT, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
import type { ApiSettings, AuthInfo, AuthTenant, AuthTenantMembership, AuthUser, LoginResponse, PlatformModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
import AppShell from "./layout/AppShell";
import PublicLandingPage from "./features/auth/PublicLandingPage";
import LoginModal from "./features/auth/LoginModal";
@@ -32,6 +32,7 @@ export default function App() {
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null });
const [backendReachable, setBackendReachable] = useState(true);
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
const [reloginMessage, setReloginMessage] = useState("");
@@ -47,9 +48,9 @@ export default function App() {
saveApiSettings(next);
}
function updateAuth(next: AuthInfo | null, accessToken?: string) {
function updateAuth(next: AuthUpdate | null, accessToken?: string) {
const nextSettings = accessToken !== undefined ? { ...settings, accessToken } : settings;
setAuth(next ? normalizeAuthInfo(next) : null);
setAuth((current) => next ? normalizeAuthInfo(mergeAuthPayload(current, next)) : null);
if (accessToken !== undefined) {
setSettings(nextSettings);
saveApiSettings(nextSettings);
@@ -87,6 +88,7 @@ export default function App() {
try {
const response = await fetchPlatformStatus(settings);
if (cancelled) return;
setBackendReachable(true);
setMaintenanceMode(response.maintenance_mode);
if (response.i18n) {
setSystemLanguages({
@@ -100,7 +102,10 @@ export default function App() {
});
}
} catch {
if (!cancelled) setMaintenanceMode({ enabled: false, message: null });
if (!cancelled) {
setBackendReachable(false);
setMaintenanceMode({ enabled: false, message: null });
}
}
}
@@ -117,21 +122,30 @@ export default function App() {
useEffect(() => {
let cancelled = false;
setCheckingSession(true);
fetchMe(settings).
then((me) => {if (!cancelled) setAuth(normalizeAuthInfo(me));}).
catch(() => {
if (!cancelled) {
const cleared = { ...settings, accessToken: "" };
setSettings(cleared);
saveApiSettings(cleared);
setAuth(null);
setPlatformModules(null);
setRemoteWebModules([]);
async function bootstrapAuth() {
try {
const shellAuth = await fetchShellAuth(settings);
if (!cancelled) {
setBackendReachable(true);
setAuth(normalizeAuthInfo(shellAuth));
}
} catch (error) {
if (!cancelled) {
setBackendReachable(isApiError(error));
const cleared = { ...settings, accessToken: "" };
setSettings(cleared);
saveApiSettings(cleared);
setAuth(null);
setPlatformModules(null);
setRemoteWebModules([]);
}
} finally {
if (!cancelled) setCheckingSession(false);
}
}).
finally(() => {
if (!cancelled) setCheckingSession(false);
});
}
void bootstrapAuth();
return () => {cancelled = true;};
}, [settings.apiBaseUrl, settings.apiKey]);
@@ -180,11 +194,25 @@ export default function App() {
root.classList.toggle("ui-hide-help-hints", !preferences.show_inline_help_hints);
root.classList.toggle("ui-reduce-motion", preferences.reduce_motion);
root.classList.toggle("ui-no-sticky-section-sidebars", !preferences.sticky_section_sidebars);
if (preferences.theme === "system") {
delete root.dataset.theme;
} else {
root.dataset.theme = preferences.theme;
const systemDarkQuery = window.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
const applyTheme = () => {
const resolvedTheme = preferences.theme === "system" ?
systemDarkQuery?.matches ? "dark" : "light" :
preferences.theme;
root.dataset.theme = resolvedTheme;
root.dataset.themePreference = preferences.theme;
};
applyTheme();
if (preferences.theme !== "system" || !systemDarkQuery) {
return undefined;
}
systemDarkQuery.addEventListener("change", applyTheme);
return () => {
systemDarkQuery.removeEventListener("change", applyTheme);
};
}, [
auth?.user.ui_preferences?.compact_tables,
auth?.user.ui_preferences?.show_inline_help_hints,
@@ -208,8 +236,11 @@ export default function App() {
useEffect(() => {
if (!auth) return;
const currentAuth = auth;
let cancelled = false;
let inFlight = false;
let lastRefreshAt = 0;
let lastShellRefreshAt = Date.now();
async function refreshVisibleSession() {
if (document.visibilityState === "hidden" || inFlight) return;
@@ -219,8 +250,22 @@ export default function App() {
inFlight = true;
lastRefreshAt = now;
try {
setAuth(normalizeAuthInfo(await fetchMe(settings)));
} catch {
const sessionInfo = await fetchSession(settings);
if (cancelled) return;
setBackendReachable(true);
const shellRefreshDue = now - lastShellRefreshAt >= 60_000;
if (!sessionMatchesAuth(sessionInfo, currentAuth) || shellRefreshDue) {
const shellAuth = await fetchShellAuth(settings);
if (cancelled) return;
lastShellRefreshAt = Date.now();
setAuth((current) => current && sessionMatchesAuth(sessionInfo, current)
? normalizeAuthInfo(mergeAuthPayload(current, shellAuth))
: normalizeAuthInfo(shellAuth));
}
} catch (error) {
if (!cancelled && !isApiError(error)) {
setBackendReachable(false);
}
// A background refresh must not log the user out on a transient network error.
@@ -230,6 +275,7 @@ export default function App() {
window.addEventListener("focus", refreshVisibleSession);
document.addEventListener("visibilitychange", refreshVisibleSession);
return () => {
cancelled = true;
window.removeEventListener("focus", refreshVisibleSession);
document.removeEventListener("visibilitychange", refreshVisibleSession);
};
@@ -240,7 +286,7 @@ export default function App() {
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={webModules}>
<UnsavedChangesProvider>
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode}>
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<div className="public-landing">
<section className="public-card">
<div className="public-kicker">i18n:govoplan-core.govoplan.a84c0a85</div>
@@ -260,7 +306,7 @@ export default function App() {
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={webModules}>
<UnsavedChangesProvider>
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode}>
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />
</AppShell>
</UnsavedChangesProvider>
@@ -291,7 +337,7 @@ export default function App() {
moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={webModules}>
<UnsavedChangesProvider>
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode}>
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
@@ -327,18 +373,40 @@ export default function App() {
}
type AuthPayload = Partial<AuthInfo> & {
principal?: AuthInfo["principal"];
user?: Partial<AuthUser> | null;
tenant?: AuthTenant | null;
active_tenant?: AuthTenant | null;
tenants?: AuthTenantMembership[] | null;
};
type AuthPayload = AuthUpdate;
function mergeAuthPayload(current: AuthInfo | null, next: AuthPayload): AuthPayload {
if (!current) return next;
const nextActiveTenant = next.active_tenant ?? next.tenant ?? null;
const currentActiveTenant = current.active_tenant ?? current.tenant;
const tenantChanged = Boolean(nextActiveTenant && nextActiveTenant.id !== currentActiveTenant.id);
return {
...current,
...next,
user: next.user ? { ...current.user, ...next.user } : current.user,
tenant: nextActiveTenant ?? current.tenant,
active_tenant: nextActiveTenant ?? currentActiveTenant,
tenants: next.tenants ?? current.tenants,
scopes: next.scopes ?? current.scopes,
roles: next.roles ?? (next.roles_loaded === false || tenantChanged ? [] : current.roles),
groups: next.groups ?? (next.groups_loaded === false || tenantChanged ? [] : current.groups),
principal: next.principal === undefined ? current.principal : next.principal,
available_languages: next.available_languages ?? (tenantChanged ? undefined : current.available_languages),
enabled_language_codes: next.enabled_language_codes ?? (tenantChanged ? undefined : current.enabled_language_codes),
default_language: next.default_language ?? (tenantChanged ? undefined : current.default_language),
profile_loaded: next.profile_loaded ?? (tenantChanged ? false : current.profile_loaded),
roles_loaded: next.roles_loaded ?? (tenantChanged ? false : current.roles_loaded),
groups_loaded: next.groups_loaded ?? (tenantChanged ? false : current.groups_loaded)
};
}
function normalizeAuthInfo(response: AuthPayload): AuthInfo {
const principal = response.principal ?? null;
const activeTenant = response.active_tenant ?? response.tenant ?? response.tenants?.[0] ?? null;
const user = normalizeAuthUser(response.user, principal);
const profileLoaded = response.profile_loaded ?? hasFullProfilePayload(response);
const rolesLoaded = response.roles_loaded ?? response.roles !== undefined;
const groupsLoaded = response.groups_loaded ?? response.groups !== undefined;
if (!activeTenant) {
throw new Error("Authentication response did not include an active tenant.");
@@ -356,12 +424,25 @@ function normalizeAuthInfo(response: AuthPayload): AuthInfo {
roles: response.roles ?? [],
groups: response.groups ?? [],
principal,
available_languages: response.available_languages ?? [],
enabled_language_codes: response.enabled_language_codes ?? activeTenant.enabled_language_codes ?? [],
default_language: response.default_language ?? user.preferred_language ?? activeTenant.default_locale ?? "en"
available_languages: response.available_languages,
enabled_language_codes: profileLoaded ? response.enabled_language_codes ?? activeTenant.enabled_language_codes ?? [] : undefined,
default_language: profileLoaded ? response.default_language ?? user.preferred_language ?? activeTenant.default_locale ?? "en" : undefined,
profile_loaded: profileLoaded,
roles_loaded: rolesLoaded,
groups_loaded: groupsLoaded
};
}
function hasFullProfilePayload(response: AuthPayload): boolean {
return Boolean(
response.default_language ||
response.available_languages ||
response.enabled_language_codes ||
response.roles ||
response.groups
);
}
function normalizeAuthUser(user: Partial<AuthUser> | null | undefined, principal: AuthInfo["principal"]): AuthUser | null {
if (user?.id && user.account_id) {
return {
@@ -396,6 +477,17 @@ function normalizeAuthUser(user: Partial<AuthUser> | null | undefined, principal
};
}
function sessionMatchesAuth(sessionInfo: AuthSessionInfo, auth: AuthInfo): boolean {
const activeTenant = auth.active_tenant ?? auth.tenant;
if (sessionInfo.user.id !== auth.user.id) return false;
if (sessionInfo.user.account_id !== auth.user.account_id) return false;
if ((sessionInfo.active_tenant ?? sessionInfo.tenant).id !== activeTenant.id) return false;
if (auth.principal?.auth_method && sessionInfo.auth_method !== auth.principal.auth_method) return false;
if (auth.principal?.session_id && sessionInfo.session_id && auth.principal.session_id !== sessionInfo.session_id) return false;
if (auth.principal?.api_key_id && sessionInfo.api_key_id && auth.principal.api_key_id !== sessionInfo.api_key_id) return false;
return true;
}
function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undefined): UserUiPreferences {
const theme = value?.theme === "light" || value?.theme === "dark" || value?.theme === "system" ? value.theme : "system";
return {

View File

@@ -0,0 +1,56 @@
import type { ApiSettings } from "../types";
import { apiFetch, apiGetList } from "./client";
export type PermissionItem = {
scope: string;
label: string;
description: string;
category: string;
level: "tenant" | "system";
system_template_id?: string | null;
system_required?: boolean;
};
export type AdminOverview = {
active_tenant_id: string;
active_tenant_name: string;
tenant_count?: number | null;
system_account_count?: number | null;
system_group_template_count?: number | null;
system_role_template_count?: number | null;
user_count: number;
active_user_count: number;
group_count: number;
role_count: number;
active_api_key_count: number;
capabilities: string[];
};
export type TenantAdminItem = {
id: string;
slug: string;
name: string;
description?: string | null;
default_locale: string;
settings: Record<string, unknown>;
allow_custom_groups?: boolean | null;
allow_custom_roles?: boolean | null;
allow_api_keys?: boolean | null;
effective_governance: Record<string, boolean>;
is_active: boolean;
counts: Record<string, number>;
created_at: string;
updated_at: string;
};
export function fetchAdminOverview(settings: ApiSettings): Promise<AdminOverview> {
return apiFetch(settings, "/api/v1/admin/overview");
}
export async function fetchPermissionCatalog(settings: ApiSettings): Promise<PermissionItem[]> {
return apiGetList<PermissionItem, "permissions">(settings, "/api/v1/admin/permissions", "permissions");
}
export async function fetchTenants(settings: ApiSettings): Promise<TenantAdminItem[]> {
return apiGetList<TenantAdminItem, "tenants">(settings, "/api/v1/admin/tenants", "tenants");
}

View File

@@ -1,4 +1,4 @@
import type { ApiSettings, AuthInfo, LoginResponse, UserUiPreferences } from "../types";
import type { ApiSettings, AuthGroupsInfo, AuthInfo, AuthProfileInfo, AuthRolesInfo, AuthSessionInfo, AuthShellInfo, LoginResponse, UserUiPreferences } from "../types";
import { apiFetch } from "./client";
export async function login(
@@ -15,8 +15,28 @@ export async function fetchMe(settings: ApiSettings): Promise<AuthInfo> {
return apiFetch<AuthInfo>(settings, "/api/v1/auth/me");
}
export async function switchTenant(settings: ApiSettings, tenantId: string): Promise<AuthInfo> {
return apiFetch<AuthInfo>(settings, "/api/v1/auth/switch-tenant", {
export async function fetchSession(settings: ApiSettings): Promise<AuthSessionInfo> {
return apiFetch<AuthSessionInfo>(settings, "/api/v1/auth/session", { cache: "no-store" });
}
export async function fetchShellAuth(settings: ApiSettings): Promise<AuthShellInfo> {
return apiFetch<AuthShellInfo>(settings, "/api/v1/auth/shell", { cache: "no-store" });
}
export async function fetchAuthProfile(settings: ApiSettings): Promise<AuthProfileInfo> {
return apiFetch<AuthProfileInfo>(settings, "/api/v1/auth/profile", { cache: "no-store" });
}
export async function fetchAuthRoles(settings: ApiSettings): Promise<AuthRolesInfo> {
return apiFetch<AuthRolesInfo>(settings, "/api/v1/auth/roles", { cache: "no-store" });
}
export async function fetchAuthGroups(settings: ApiSettings): Promise<AuthGroupsInfo> {
return apiFetch<AuthGroupsInfo>(settings, "/api/v1/auth/groups", { cache: "no-store" });
}
export async function switchTenant(settings: ApiSettings, tenantId: string): Promise<AuthShellInfo> {
return apiFetch<AuthShellInfo>(settings, "/api/v1/auth/switch-tenant", {
method: "POST",
body: JSON.stringify({ tenant_id: tenantId })
});
@@ -35,8 +55,8 @@ export async function updateProfile(
enabled_language_codes?: string[] | null;
ui_preferences?: Partial<UserUiPreferences> | null;
}
): Promise<AuthInfo> {
return apiFetch<AuthInfo>(settings, "/api/v1/auth/profile", {
): Promise<AuthProfileInfo> {
return apiFetch<AuthProfileInfo>(settings, "/api/v1/auth/profile", {
method: "PATCH",
body: JSON.stringify(payload)
});

View File

@@ -1,9 +1,9 @@
import type { ApiSettings } from "../types";
const STORAGE_KEY = "multimailer.apiSettings";
const LEGACY_STORAGE_KEYS = ["i18n:govoplan-core.multimailer_apisettings.1d1601d4"];
const SESSION_STORAGE_KEY = "multimailer.session";
const CSRF_COOKIE_NAME = import.meta.env.VITE_CSRF_COOKIE_NAME ?? "msm_csrf";
const STORAGE_KEY = "govoplan.apiSettings";
const LEGACY_STORAGE_KEYS: string[] = [];
const SESSION_STORAGE_KEY = "govoplan.session";
const CSRF_COOKIE_NAME = import.meta.env.VITE_CSRF_COOKIE_NAME ?? "govoplan_csrf";
const RECENT_SAFE_REQUEST_TTL_MS = 750;
const MAX_RECENT_SAFE_REQUESTS = 100;
const MAX_CONDITIONAL_SAFE_REQUESTS = 200;
@@ -77,6 +77,56 @@ export function apiUrl(settings: ApiSettings, path: string): string {
return baseUrl ? `${baseUrl}${normalizedPath}` : normalizedPath;
}
export type ApiQueryValue = string | number | boolean | null | undefined;
export type ApiQueryParams = Record<string, ApiQueryValue | readonly ApiQueryValue[]>;
function queryValue(value: ApiQueryValue): string | null {
if (value === null || value === undefined || value === "") return null;
return String(value);
}
export function apiQuery(params: ApiQueryParams = {}): string {
const search = new URLSearchParams();
for (const [key, rawValue] of Object.entries(params)) {
const values = Array.isArray(rawValue) ? rawValue : [rawValue];
for (const value of values) {
const normalized = queryValue(value);
if (normalized !== null) search.append(key, normalized);
}
}
const query = search.toString();
return query ? `?${query}` : "";
}
export function apiPath(path: string, params: ApiQueryParams = {}): string {
const query = apiQuery(params);
if (!query) return path;
return path.includes("?") ? `${path}&${query.slice(1)}` : `${path}${query}`;
}
export async function apiGetList<TItem, K extends string>(
settings: ApiSettings,
path: string,
key: K,
params: ApiQueryParams = {}
): Promise<TItem[]> {
const response = await apiFetch<Record<K, TItem[] | null | undefined>>(settings, apiPath(path, params));
return response[key] ?? [];
}
export function apiPost<TResponse>(settings: ApiSettings, path: string, init: Omit<RequestInit, "method"> = {}): Promise<TResponse> {
return apiFetch<TResponse>(settings, path, { ...init, method: "POST" });
}
export function apiPostJson<TResponse, TPayload = unknown>(
settings: ApiSettings,
path: string,
payload: TPayload,
init: Omit<RequestInit, "method" | "body"> = {}
): Promise<TResponse> {
return apiPost<TResponse>(settings, path, { ...init, body: JSON.stringify(payload) });
}
export function loadApiSettings(): ApiSettings {
const storedBaseUrl = loadStoredSetting("baseUrl");
const storedApiKey = sessionStorage.getItem(`${SESSION_STORAGE_KEY}.apiKey`);

View File

@@ -0,0 +1,143 @@
import type {
MailImapTransportSettings,
MailProfilePatternKey,
MailProfilePolicy,
MailProfileScope,
MailSecurity,
MailServerProfile,
MailServerProfileCredentials,
MailTransportCredentials,
MailTransportSettings
} from "../types";
export type {
MailCredentialPolicy,
MailProfilePatternKey,
MailProfilePolicy,
MailProfileScope,
MailSecurity,
MailServerProfile
} from "../types";
export type MailSmtpTestPayload = MailTransportSettings;
export type MailImapTestPayload = MailImapTransportSettings;
export type MailTransportCredentialsPayload = MailTransportCredentials;
export type MailServerProfileCredentialsPayload = MailServerProfileCredentials;
export type MailServerProfileListResponse = { profiles: MailServerProfile[] };
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
export type MailConnectionTestResponse = {
ok: boolean;
protocol: "smtp" | "imap";
host?: string | null;
port?: number | null;
security?: MailSecurity | string | null;
message: string;
details?: Record<string, unknown>;
};
export type MailImapFolderResponse = {
name: string;
flags?: string[];
message_count?: number | null;
unseen_count?: number | null;
};
export type MailImapFolderListResponse = {
ok: boolean;
protocol: "imap";
host?: string | null;
port?: number | null;
security?: MailSecurity | string | null;
message: string;
folders: MailImapFolderResponse[];
detected_sent_folder?: string | null;
from_cache?: boolean;
refreshing?: boolean;
indexed_at?: string | null;
details?: Record<string, unknown>;
};
export const mailProfilePatternKeys = [
"smtp_hosts",
"imap_hosts",
"envelope_senders",
"from_headers",
"recipient_domains"
] as const satisfies readonly MailProfilePatternKey[];
export const mailProfilePolicyLimitKeys = [
"allowed_profile_ids",
"allow_user_profiles",
"allow_group_profiles",
"smtp_credentials.inherit",
"imap_credentials.inherit",
"whitelist.smtp_hosts",
"whitelist.imap_hosts",
"whitelist.envelope_senders",
"whitelist.from_headers",
"whitelist.recipient_domains",
"blacklist.smtp_hosts",
"blacklist.imap_hosts",
"blacklist.envelope_senders",
"blacklist.from_headers",
"blacklist.recipient_domains"
] as const;
export type MailProfilePolicyLimitKey = typeof mailProfilePolicyLimitKeys[number];
export type MailProfilePolicyLimitPermissions = Partial<Record<MailProfilePolicyLimitKey, boolean>>;
export type MailPolicySourceStep = {
scope_type: string;
scope_id?: string | null;
label: string;
applied_fields?: string[];
policy?: MailProfilePolicy | null;
};
export type MailProfilePolicyResponse = {
scope_type: MailProfileScope;
scope_id?: string | null;
policy: MailProfilePolicy;
effective_policy?: MailProfilePolicy | null;
parent_policy?: MailProfilePolicy | null;
effective_policy_sources?: MailPolicySourceStep[];
parent_policy_sources?: MailPolicySourceStep[];
};
export type MailServerProfilePayload = {
name: string;
slug?: string | null;
description?: string | null;
is_active?: boolean;
scope_type?: MailProfileScope;
scope_id?: string | null;
smtp: MailSmtpTestPayload;
imap?: MailImapTestPayload | null;
credentials?: MailServerProfileCredentialsPayload | null;
};
export type MockMailboxMessage = {
id: string;
kind: "smtp" | "imap_append" | string;
created_at: string;
envelope_from?: string | null;
envelope_recipients?: string[];
subject?: string | null;
from_header?: string | null;
to_header?: string | null;
cc_header?: string | null;
bcc_header?: string | null;
message_id?: string | null;
size_bytes?: number;
body_preview?: string | null;
attachment_count?: number;
folder?: string | null;
raw_eml?: string | null;
headers?: Record<string, string>;
attachments?: Array<{ filename?: string | null; content_type?: string | null; size_bytes?: number }>;
};
export type MockMailboxMessageResponse = {
message: MockMailboxMessage;
};

View File

@@ -1,5 +1,5 @@
import type { ApiSettings } from "../types";
import { apiFetch } from "./client";
import { apiFetch, apiPath } from "./client";
export type PrivacyRetentionPolicyFieldKey =
| "store_raw_campaign_json"
@@ -78,13 +78,6 @@ export type RetentionRunResponse = {
};
};
function retentionPolicyQuery(scopeId?: string | null): string {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString();
return suffix ? `?${suffix}` : "";
}
export function getPrivacyRetentionPolicy(
settings: ApiSettings,
scope: PrivacyRetentionPolicyScope,
@@ -92,7 +85,7 @@ export function getPrivacyRetentionPolicy(
): Promise<PrivacyRetentionPolicyScopeResponse> {
return apiFetch(
settings,
`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${retentionPolicyQuery(scopeId)}`
apiPath(`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}`, { scope_id: scopeId })
);
}
@@ -103,7 +96,7 @@ export function explainPrivacyRetentionPolicy(
): Promise<PrivacyRetentionPolicyExplainResponse> {
return apiFetch(
settings,
`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}/explain${retentionPolicyQuery(scopeId)}`
apiPath(`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}/explain`, { scope_id: scopeId })
);
}
@@ -116,7 +109,7 @@ export function updatePrivacyRetentionPolicy(
): Promise<PrivacyRetentionPolicyScopeResponse> {
return apiFetch(
settings,
`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${retentionPolicyQuery(scopeId)}`,
apiPath(`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}`, { scope_id: scopeId }),
{ method: "PUT", body: JSON.stringify({ policy, change_request_id: changeRequestId ?? null }) }
);
}

View File

@@ -0,0 +1,35 @@
import type { ApiSettings } from "../types";
import { apiFetch, apiPath } from "./client";
import type {
AccessDecisionProvenanceItem,
ResourceAccessExplanationOptions,
ResourceAccessExplanationResponse,
ResourceAccessExplanationUser
} from "./resourceAccessContracts";
export type {
AccessDecisionProvenanceItem,
ResourceAccessExplanationOptions,
ResourceAccessExplanationResponse,
ResourceAccessExplanationUser
} from "./resourceAccessContracts";
/**
* Fetches the platform's optional resource-access explanation endpoint.
* Core owns the transport contract without requiring the Access module that
* contributes the endpoint at runtime.
*/
export function fetchResourceAccessExplanation<
TUser extends ResourceAccessExplanationUser = ResourceAccessExplanationUser,
TProvenance extends AccessDecisionProvenanceItem = AccessDecisionProvenanceItem
>(
settings: ApiSettings,
options: ResourceAccessExplanationOptions
): Promise<ResourceAccessExplanationResponse<TUser, TProvenance>> {
return apiFetch(settings, apiPath("/api/v1/admin/access/resource-explanation", {
user_id: options.userId,
resource_type: options.resourceType,
resource_id: options.resourceId,
action: options.action,
tenant_id: options.tenantId
}));
}

View File

@@ -0,0 +1,34 @@
export type ResourceAccessExplanationUser = {
id: string;
account_id?: string | null;
email?: string | null;
display_name?: string | null;
};
export type AccessDecisionProvenanceItem = {
kind: string;
id?: string | null;
label?: string | null;
tenant_id?: string | null;
source?: string | null;
details?: Record<string, unknown>;
};
export type ResourceAccessExplanationResponse<
TUser extends ResourceAccessExplanationUser = ResourceAccessExplanationUser,
TProvenance extends AccessDecisionProvenanceItem = AccessDecisionProvenanceItem
> = {
user: TUser;
resource_type: string;
resource_id: string;
action: string;
provenance: TProvenance[];
};
export type ResourceAccessExplanationOptions = {
userId: string;
resourceType: string;
resourceId: string;
action: string;
tenantId?: string | null;
};

View File

@@ -1,5 +1,12 @@
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "primary" | "secondary" | "ghost" | "danger" };
import type { ButtonHTMLAttributes, ReactNode } from "react";
import DisabledActionTooltip from "./DisabledActionTooltip";
export default function Button({ variant = "secondary", className = "", ...props }: Props) {
return <button className={`btn btn-${variant} ${className}`} {...props} />;
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "ghost" | "danger";
disabledReason?: ReactNode;
};
export default function Button({ variant = "secondary", className = "", disabledReason, disabled, ...props }: ButtonProps) {
const button = <button className={`btn btn-${variant} ${className}`} disabled={disabled || Boolean(disabledReason)} {...props} />;
return <DisabledActionTooltip reason={disabledReason}>{button}</DisabledActionTooltip>;
}

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