Compare commits

...
64 Commits
Author SHA1 Message Date
zemion 0ca6568005 chore(release): load Campaign 0.1.21
Module Package Release / publish-packages (push) Successful in 12s
2026-08-22 00:54:00 +02:00
zemion af90db44c9 chore(webui): load Campaign collaboration release
Module Package Release / publish-packages (push) Successful in 12s
2026-08-22 00:18:06 +02:00
zemion 5de46e9c0e chore(webui): load Files folder sync release
Module Package Release / publish-packages (push) Successful in 12s
2026-08-21 22:50:48 +02:00
zemion 1d9b677c1b chore(webui): load IDM relationship administration release
Module Package Release / publish-packages (push) Successful in 12s
2026-08-21 22:06:29 +02:00
zemion 54178ee56c chore(webui): load Access credential help release
Module Package Release / publish-packages (push) Successful in 12s
2026-08-21 21:15:31 +02:00
zemion 10e7597612 feat(core): define datasource visibility policy contract
Module Package Release / publish-packages (push) Failing after 6s
2026-08-21 20:31:51 +02:00
zemion 142ccbc587 feat(core): define managed tabular file contract
Module Package Release / publish-packages (push) Successful in 12s
2026-08-21 19:29:48 +02:00
zemion f75ad48d78 test(core): isolate entitlement navigation fixture 2026-08-21 19:26:00 +02:00
zemion 5a9e8f79f9 test(webui): cover contextual help in the browser 2026-08-21 17:36:06 +02:00
zemion fbea74a74b feat(core): define durable datasource artifact contract 2026-08-21 17:36:05 +02:00
zemion 925dc33696 feat(docs): define semantic subject contract 2026-08-21 14:55:58 +02:00
zemion 4b0737e1cd feat: support autonomous campaign schedule results 2026-08-20 21:10:04 +02:00
zemion 4f4007aff1 feat: define policy impact subject contract 2026-08-20 20:27:17 +02:00
zemion 8e687c4420 feat: define bulk governance projection contract 2026-08-20 19:46:53 +02:00
zemion 604f20eed7 feat: classify audit evidence export scope 2026-08-20 18:00:18 +02:00
zemion 6a2da94e47 feat: load connector governance webui 2026-08-20 12:46:01 +02:00
zemion e121ca900e feat: load identity administration webui 2026-08-20 12:27:37 +02:00
zemion 79629c5a2c feat: define campaign archive encryption policy contract 2026-08-20 12:12:27 +02:00
zemion 026e451aa4 feat: configure scheduling self-enrollment limits 2026-08-20 11:45:07 +02:00
zemion f11c675d11 feat: govern accessible appearance overrides 2026-08-20 10:50:55 +02:00
zemion 0fae09ba3c feat: extend calendar scheduling reconciliation contract 2026-08-20 07:44:16 +02:00
zemion 8f642bd618 feat: govern effective appearance defaults 2026-08-20 07:03:28 +02:00
zemion 6643c8fc1e feat: add validated appearance palettes 2026-08-20 06:26:52 +02:00
zemion fd90b60430 feat: centralize access explanation subject selection 2026-08-20 06:16:43 +02:00
zemion 0aae6f0539 feat(postbox): schedule lifecycle notification reconciliation 2026-08-20 04:58:35 +02:00
zemion be7b79612c feat(postbox): expose governed grouping policy 2026-08-20 04:22:29 +02:00
zemion 557c77670b feat(postbox): expose configurable protection contracts 2026-08-20 03:42:58 +02:00
zemion d277218784 feat(webui): preserve state in shared widget launches 2026-08-20 02:39:41 +02:00
zemion cf16a7b27a feat(core): add layered side rail preferences 2026-08-20 01:47:04 +02:00
zemion 51bf14f376 docs(core): define collaboration connector strategy 2026-08-19 23:15:35 +02:00
zemion 8d9bcfd8b5 test(core): prove Views cannot grant access 2026-08-19 22:55:19 +02:00
zemion 3c7a593f63 feat(templates): carry content field requirements 2026-08-19 21:54:51 +02:00
zemion 9d1352ba30 feat(mail): define standard IMAP folder mappings 2026-08-19 21:27:42 +02:00
zemion 4cf2bfeb3e feat(operations): add runtime work status contract 2026-08-19 20:52:44 +02:00
zemion 94c94fefb4 test(webui): cover View-focused Quick Access catalogues 2026-08-19 20:14:02 +02:00
zemion d600bca374 test: make full discovery optional-state independent 2026-08-19 19:20:05 +02:00
zemion ffaab543d2 feat(webui): govern actions, quick access, and metrics
Refs #264, #285, #289
2026-08-19 18:47:46 +02:00
zemion 41db78c201 Define semantic page archetypes 2026-08-19 14:26:25 +02:00
zemion c042244da8 Define semantic page action layouts 2026-08-19 13:33:51 +02:00
zemion 5a2e99f496 feat: add status and payment capability contracts 2026-08-19 12:33:34 +02:00
zemion 8a925782ab feat: add UI conformance and launch context 2026-08-18 21:32:25 +02:00
zemion 7685a103e8 Centralize shared WebUI structural primitives 2026-08-18 13:17:31 +02:00
zemion ee5c881df9 Centralize metric and description layouts 2026-08-18 11:30:39 +02:00
zemion 6814a41ae4 Add shared WebUI layout primitives 2026-08-18 10:42:55 +02:00
zemion dd7ad4d9c7 feat: add shared workspace layouts 2026-08-18 02:17:13 +02:00
zemion 934db6d44b feat: centralize shared page layouts 2026-08-18 01:03:33 +02:00
zemion d307e29145 feat: add exact credential help contexts 2026-08-17 19:51:51 +02:00
zemion ff88142471 feat: add exact retention help contexts 2026-08-17 17:41:45 +02:00
zemion 887e9beb9e docs: update meta documentation links 2026-08-17 16:52:53 +02:00
zemion e6457b3f6b feat: add governed DSAR workflow 2026-08-07 14:53:07 +02:00
zemion eb0c01c5d2 feat: expose typed deployment capability receipts 2026-08-07 11:15:49 +02:00
zemion 40cc012124 feat(modules): validate catalog permission declarations 2026-08-07 01:59:24 +02:00
zemion 44196f5620 Validate module catalog provenance and availability 2026-08-06 22:42:08 +02:00
zemion 9ceb1b8c22 Add trusted public module catalog installs 2026-08-06 21:12:54 +02:00
zemion 32c234fbdb Add product presentation extension contracts 2026-08-06 19:02:53 +02:00
zemion d65d7a8e5f Add shared work-item provider contracts 2026-08-06 16:06:17 +02:00
zemion b5f5be15f6 Add governed form evidence contract 2026-08-06 12:42:20 +02:00
zemion f5949427cc feat(records): define archive transfer provider contract 2026-08-06 05:36:11 +02:00
zemion 5d1287735e feat(core): add provider-neutral records filing contract 2026-08-06 01:43:08 +02:00
zemion 7ea0cb8655 Fix responsive DataGrid contraction 2026-08-05 22:42:21 +02:00
zemion b553513c9f Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:08:17 +02:00
zemion b5a4eb177a Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:34:47 +02:00
zemion f1a5be2a93 Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:53:46 +02:00
zemion add7a99f6d feat: add temporal context and contextual help 2026-08-05 00:03:31 +02:00
239 changed files with 21737 additions and 1223 deletions
+2
View File
@@ -149,6 +149,8 @@ webui/.module-test-build/
webui/.policy-test-build/
webui/.template-preview-test-build/
webui/.import-test-build/
webui/dist-conformance/
webui/test-results/
# Security audit reports
audit-reports/
@@ -0,0 +1,23 @@
from __future__ import annotations
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
_path = (
Path(__file__).resolve().parents[1]
/ "versions"
/ "a36d8e4f9b12_german_reference_locale.py"
)
_spec = spec_from_file_location("govoplan_german_reference_locale_migration", _path)
if _spec is None or _spec.loader is None:
raise RuntimeError(f"Unable to load migration implementation from {_path}")
_module = module_from_spec(_spec)
_spec.loader.exec_module(_module)
revision = _module.revision
down_revision = _module.down_revision
branch_labels = _module.branch_labels
depends_on = _module.depends_on
upgrade = _module.upgrade
downgrade = _module.downgrade
@@ -0,0 +1,23 @@
from __future__ import annotations
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
_path = (
Path(__file__).resolve().parents[1]
/ "versions"
/ "b47e6f809a13_data_subject_requests.py"
)
_spec = spec_from_file_location("govoplan_data_subject_requests_migration", _path)
if _spec is None or _spec.loader is None:
raise RuntimeError(f"Unable to load migration implementation from {_path}")
_module = module_from_spec(_spec)
_spec.loader.exec_module(_module)
revision = _module.revision
down_revision = _module.down_revision
branch_labels = _module.branch_labels
depends_on = _module.depends_on
upgrade = _module.upgrade
downgrade = _module.downgrade
@@ -0,0 +1,44 @@
"""adopt German as the untouched system reference locale
Revision ID: a36d8e4f9b12
Revises: f25c9d3e7a01
Create Date: 2026-08-05 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "a36d8e4f9b12"
down_revision = "f25c9d3e7a01"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
if "core_system_settings" not in set(sa.inspect(bind).get_table_names()):
return
settings = sa.table(
"core_system_settings",
sa.column("id", sa.String),
sa.column("default_locale", sa.String),
sa.column("created_at", sa.DateTime(timezone=True)),
sa.column("updated_at", sa.DateTime(timezone=True)),
)
bind.execute(
settings.update()
.where(settings.c.id == "global")
.where(settings.c.default_locale == "en")
.where(settings.c.created_at == settings.c.updated_at)
.values(default_locale="de", updated_at=sa.func.now())
)
def downgrade() -> None:
# Locale selection is user-visible state. A downgrade must not overwrite a
# German value that may have been selected explicitly after this migration.
pass
@@ -0,0 +1,77 @@
"""add governed data-subject request workflow
Revision ID: b47e6f809a13
Revises: a36d8e4f9b12
Create Date: 2026-08-07 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "b47e6f809a13"
down_revision = "a36d8e4f9b12"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "core_data_subject_requests" in inspector.get_table_names():
return
op.create_table(
"core_data_subject_requests",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("reference", sa.String(length=120), nullable=False),
sa.Column("request_kind", sa.String(length=30), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("subject", sa.JSON(), nullable=False),
sa.Column("purpose", sa.String(length=1000), nullable=False),
sa.Column("legal_basis", sa.String(length=1000), nullable=True),
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("requested_by_account_id", sa.String(length=36), nullable=False),
sa.Column("search_result", sa.JSON(), nullable=False),
sa.Column("erasure_plan", sa.JSON(), nullable=False),
sa.Column("execution_result", sa.JSON(), nullable=False),
sa.Column("coverage", sa.JSON(), nullable=False),
sa.Column("evidence_sha256", sa.String(length=64), nullable=True),
sa.Column("resource_revision", sa.Integer(), nullable=False),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_data_subject_requests")),
)
op.create_index(
op.f("ix_core_data_subject_requests_tenant_id"),
"core_data_subject_requests",
["tenant_id"],
unique=False,
)
op.create_index(
op.f("ix_core_data_subject_requests_status"),
"core_data_subject_requests",
["status"],
unique=False,
)
op.create_index(
op.f("ix_core_data_subject_requests_due_at"),
"core_data_subject_requests",
["due_at"],
unique=False,
)
op.create_index(
"ix_core_data_subject_requests_tenant_status",
"core_data_subject_requests",
["tenant_id", "status"],
unique=False,
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "core_data_subject_requests" in inspector.get_table_names():
op.drop_table("core_data_subject_requests")
+30 -1
View File
@@ -159,7 +159,36 @@ The initial implementation includes provider-neutral orchestration helpers:
The first concrete provider is `govoplan_access.backend.configuration_provider`.
It supports access-owned `roles`, `groups`, and `group_role_assignments`
fragments and applies them idempotently.
fragments and applies them idempotently. Mail and Files also register providers
for deployment configuration: Mail owns receipt-bound SMTP profiles and Files
validates the deployment-owned managed-storage binding.
### Deployment capability receipt
The installer mounts a bounded, non-secret infrastructure receipt at the path
named by `GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH`. Core validates that document
once for configuration-package context and exposes typed capability and
post-install-task records to providers. Invalid receipts fail closed. Endpoint
metadata is sanitized, and secret fields may cross this boundary only as
`env:VARIABLE_NAME` references.
Feature providers remain responsible for their own semantics:
- Mail can derive host and port from `mail.smtp`, collect missing non-secret
transport fields, and bind an existing credential-envelope id. It never
accepts or exports a username, password, token, or decrypted credential.
- Files compares `files.storage` with the effective runtime backend, endpoint,
trust marker, bucket, and presence of referenced environment secrets. Storage
remains deployment-owned, so the provider reports `skip` when they agree and
blocks drift instead of rewriting process environment or storage credentials.
- A system-scoped Mail profile requires system configuration authority. Tenant
scope is the conservative default.
- Existing Mail configuration is preserved unless a reviewed fragment
explicitly selects `on_conflict: update`. Reapplying an unchanged fragment is
a no-op.
Ops projects the same Core-validated receipt. It must not maintain a second
parser with different validation or secret-handling rules.
The admin wizard backend starts with these routes:
+73
View File
@@ -0,0 +1,73 @@
# Contextual Help Contract
GovOPlaN exposes context-sensitive help through `F1` and the titlebar help
control. The shell resolves a stable help identity from the focused control,
its containing surface, and the current route. The Docs module then projects
the best visible user or administrator topic for that identity.
## Resolution Order
The WebUI resolves help in this order:
1. an explicit `helpContextId` or `data-help-context-id` on the focused item
2. the focused shared control's `interfaceId`, `helpTopicId`, and label key
3. a containing dialog, card, administration section, or page surface
4. the current registered route, including dynamic module routes
5. a stable route-derived fallback when no explicit identity is available
Focused field and action contexts retain the page context as
`fallback_context`. This lets Docs show a field-specific topic when one exists
and otherwise open the owning page or module documentation instead of a generic
help page.
## Documentation Lookup
Static `DocumentationTopic` contributions announce exact contexts through
`metadata.help_contexts`. Core publishes that catalogue with the enabled module
manifest, allowing the shell to link directly to an exact topic when possible.
Docs still performs the authoritative audience, permission, configured-state,
and documentation-type filtering.
Core also maps explicit route, navigation, settings, and View surface IDs to
the module's static user or administrator documentation baseline. This makes a
page association complete by default and gives every derived field/action
context a useful fallback. Exact `metadata.help_contexts` remain the preferred
authoring mechanism for consequential or unfamiliar controls.
When there is no exact topic, Docs resolves the page fallback and then the first
visible topic owned by the module. If Docs is unavailable, the shell opens the
hosted documentation with the same context parameters.
## Authoring Controls
Core shared controls expose stable help metadata. Prefer these props rather
than adding custom `F1` listeners:
- `interfaceId` identifies a durable UI surface or action.
- `helpContextId` identifies a documentation context when it differs from the
interface identity.
- `helpModuleId` identifies the documentation-owning module when a shared
control is embedded in another module's page.
- `helpTopicId` links directly to a module-owned documentation topic.
- translated label keys provide deterministic field identities for ordinary
`FormField`, `ToggleSwitch`, search, date/time, email, button, dialog, and card
controls.
- `TableActionGroup` action definitions carry the same identities so focused
row actions can resolve consequence-specific help.
- `PageLayout` owns the page help scope and documentation identity for ordinary
headed pages. `WorkspaceLayout` owns the full-canvas workspace scope and its
labelled primary/content panes; pages inside it use `PageLayout` in
`workspace` mode and retain their own route-level help identity.
Module routes, public routes, settings sections, and administration sections
may also declare `helpContextId` and `helpTopicId`. Each module must keep a
static user/admin documentation baseline and should list its important route,
workflow, setting, permission, and limitation identities in
`metadata.help_contexts`.
## Boundary
Help identities describe presentation context; they are not authorization
claims. Opening help never bypasses route or documentation permissions. Docs
owns documentation projection, feature modules own their content, and Core owns
focus capture, context resolution, and fallback routing.
+10 -3
View File
@@ -36,7 +36,8 @@ the column remains stopped until the pointer crosses the same boundary again.
## Persistence
Only the pixel layout resulting from an explicit user resize is persisted.
Only the pixel layout resulting from an explicit user resize is persisted,
together with the container width at which the user selected it.
Persisted widths are keyed by a signature containing column IDs, declared
widths and bounds, resize affordances, sticky placement, initial fit, and resize
behavior. A changed signature discards the old override and recomputes the
@@ -44,8 +45,13 @@ declared layout.
Container reconciliation is suspended while a pointer drag is active. On
release, the already-rendered pixel layout becomes the persisted preference.
Reconciliation may grow it to prevent underflow, but never shrinks intentional
user overflow, so there is no drag-end snap.
Reconciliation at that same container width never shrinks intentional user
overflow, so there is no drag-end snap. If the surrounding layout later
contracts, persisted tracks may shrink toward their hard minima. The layout
retains only the amount of horizontal overflow deliberately created by the
user; an exact-cover layout therefore remains exact-cover at narrower widths.
Legacy snapshots from the former hard-pixel persistence contract are discarded
once and recomputed from the declared column layout.
## Regression Matrix
@@ -56,6 +62,7 @@ user overflow, so there is no drag-end snap.
- hard-minimum horizontal overflow;
- fixed-only cover grids;
- persisted overrides under growth and viewport pressure;
- responsive contraction of persisted layouts without losing deliberate overflow;
- stale layout signatures;
- first and middle-column right-side compensation;
- last-resizable-column overflow, underflow stop, and reverse-pointer boundary;
+91
View File
@@ -0,0 +1,91 @@
# Data-Subject Request Contract
This document defines the provider-neutral workflow for access and erasure
requests. It is an operational control and evidence mechanism. It does not
replace legal review, identity verification, retention policy, or the
institution's statutory response process.
## Ownership
Core owns the request aggregate, lifecycle API, optimistic concurrency,
provider discovery, export manifest, execution orchestration, and audit event
names. Modules that store subject-related data own their search, explanation,
retention, and mutation behavior through a `privacy.dsar.<module>` capability.
Core never scans module tables or guesses how a foreign resource may be
erased.
Access owns the first provider. It finds tenant memberships plus safe account,
identity, assignment, API-key, and session metadata. It does not export secret
hashes, session tokens, IP addresses, or browser fingerprints. Tenant-local
membership data can be anonymized and authentication material can be revoked.
Global accounts and identities require manual system-level review because they
may serve more than one tenant.
## Lifecycle
1. A privacy officer records a verified selector, purpose, legal basis, due
date, and internal reference.
2. Search invokes every available tenant capability independently. A provider
failure is isolated and recorded; it cannot turn an incomplete search into
a successful one.
3. The JSON export contains the request, records, provider runs, coverage,
retention reasons, execution evidence, and a SHA-256 manifest digest.
4. An erasure request produces stable provider-owned actions. Immutable
evidence generates an explicit non-executable `retain` decision.
5. Execution accepts only selected executable actions from the current plan.
It requires `If-Match`, the current resource revision, the dedicated erase
permission, and the exact `ERASE <request-id>` confirmation phrase.
6. Provider execution is idempotent. Completed or unchanged effects remain
durable in the request's execution evidence.
The API is rooted at
`/api/v1/admin/privacy/data-subject-requests`. Access exposes the independent
permissions `access:privacy:read`, `access:privacy:manage`,
`access:privacy:export`, and `access:privacy:erase`; the built-in privacy
officer role contains all four.
## Provider Rules
A provider must:
- enforce tenant ownership for every record and action;
- return stable, unique resource and action identities;
- avoid credentials, hashes, tokens, unnecessary telemetry, and unrelated
third-party data;
- distinguish mutable personal data from immutable institutional evidence;
- state a retention reason for immutable evidence;
- propose manual review instead of an automatic action when authority is
ambiguous or a resource spans tenants;
- return exactly one execution result per requested action;
- make execution idempotent and avoid committing the caller's transaction;
- keep all actual mutations inside the owning module.
Each active module without a DSAR provider is listed in coverage. This is a
deliberate fail-visible state, not proof that the module stores personal data.
An institution may call an export complete only after it has reviewed both the
provider runs and that coverage list.
## Retention And Evidence
Erasure and retention are separate decisions. Stable object IDs, authorization
history, function incumbency, formal decisions, delivery evidence, and audit
records may remain necessary for accountability. Providers expose those items
with a concrete reason and Core prevents them from being selected as executable
actions. Policy may further restrict an action, but it must never silently
loosen a provider's retention decision.
All lifecycle mutations and exports produce tenant audit events. The request
stores an evidence digest after every revision. This digest detects accidental
or unauthorized mutation of the aggregate; it is not a digital signature or a
substitute for signed recovery evidence.
## Current Limits
- Access is the first native provider. Other enabled modules appear in the
coverage list until they add a provider or an explicit no-subject-data
declaration is standardized.
- Verification of the requester's identity and statutory deadline escalation
remain institutional workflows outside this API.
- Global account or identity erasure is deliberately manual.
- Exports are JSON. A human-readable signed response package remains a later
Reporting/Templates integration.
+15 -2
View File
@@ -7,6 +7,16 @@ files.
## Runtime Configuration Contract
Worker and queue observability is provider-neutral. Runtime modules register a
bounded `RuntimeWorkStatusProviderRegistration` with Core; the Ops module
projects its sanitized status without importing Celery, Redis, or module job
implementations. Providers must use explicit `null` values for unsupported
queue depth, active/reserved work, failure count, and heartbeat evidence. An
unavailable metric must never be interpreted as zero or as proof of health.
The standard Core adapter reports the configured Celery/Redis runtime and
combines its bounded inspection result with registered worker heartbeat and
stale-threshold evidence.
Self-hosted installability follows the staged approach documented in
`SELF_HOSTED_INSTALLABILITY.md`: generate an explicit env template, validate it,
run production-like rehearsal with Compose-backed dependencies, then use the
@@ -278,12 +288,15 @@ through the same trusted address range.
| --- | --- |
| `GOVOPLAN_MODULE_PACKAGE_CATALOG_URL` or `GOVOPLAN_MODULE_PACKAGE_CATALOG` | Module package catalog source. |
| `GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE` | Preferred production keyring path. |
| `GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL` | Approved catalog channel, for example `stable`. |
| `GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS` | Comma-separated approved catalog channels, for example `stable`. The legacy singular name remains readable during migration. |
| `GOVOPLAN_LICENSE_TRUSTED_KEYS_FILE` | Trusted license issuer keyring path. |
| `GOVOPLAN_LICENSE_ENFORCEMENT` | Enables license enforcement when set to `true`. |
Trust roots are deployment-managed and should not be editable through the
running WebUI.
running WebUI. When no catalog override is configured, the Admin package
directory uses GovOPlaN's public stable catalog and the trust anchor bundled
with the installed Core release. Production operators may still pin a newer or
institution-specific catalog/keyring explicitly with the settings above.
### Mail Test Credentials
+10 -1
View File
@@ -17,6 +17,13 @@ operator, and roadmap pages.
| Action/effect automation layer | `ACTION_EFFECT_AUTOMATION_LAYER.md` | Action/effect contracts, consequence preview, runner semantics, and module boundary for automation. |
| External references and integration maturity | `EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md` | Stable external identity and cumulative connector maturity; configured source authority is defined by the meta target architecture. |
| Institutional context and governed references | `INSTITUTIONAL_CONTEXT_CONTRACT.md` | Shared temporal, actor/representation, institution, mandate, service, party, decision, evidence, legal-basis, information-governance, presentation, and geo DTO/provider contracts. |
| Provider-neutral record filing | `RECORDS_FILING_CONTRACT.md` | Exact source-revision identity, current source authorization, idempotent filing, capability discovery, and ownership boundary. |
| Temporal data read context | `TEMPORAL_DATA_CONTEXT.md` | Valid-time and recorded-time titlebar selection, HTTP/cache contract, security boundary, and module-adoption rule. |
| Cross-module information governance adoption | `INFORMATION_GOVERNANCE_ADOPTION.md` | Manifest evidence and enforcement rules for temporal browsing, purpose-aware access, retention, and institutional context. |
| Data-subject access and erasure requests | `DATA_SUBJECT_REQUESTS.md` | Provider-owned search and mutation, explicit coverage, governed export, retained evidence, permissions, and idempotent execution. |
| Context-sensitive F1 help | `CONTEXTUAL_HELP_CONTRACT.md` | Focus, route, module-manifest documentation contexts, Docs projection, and hosted fallback. |
| Semantic documentation subjects | `SEMANTIC_DOCUMENTATION_SUBJECTS.md` | Stable configured-artifact identity, safe provider discovery, revision review, authorization, and lifecycle semantics. |
| German localization and help quality gate | `LOCALIZATION_AND_HELP_QUALITY.md` | German reference locale, new-installation default, catalog completeness, automatic page associations, and explicit-help review priorities. |
| Postbox E2EE target architecture | `POSTBOX_E2EE_ARCHITECTURE.md` | Strategic encrypted postbox/mailbox model, key ownership, role mailbox semantics, and retraction limits. |
| Shared state, runtime coordination, and recovery | `STATE_AND_RECOVERY_CONTRACT.md` | State profiles, object storage, node registration/drain, fenced leases, migration ordering, and recovery evidence. |
| Module lifecycle recovery | `MODULE_LIFECYCLE_RECOVERY.md` | Installer/live-graph recovery modes, deployment fence, evidence, retry blocking, and operator reconciliation. |
@@ -37,7 +44,9 @@ operator, and roadmap pages.
| Topic | Canonical document | Notes |
| --- | --- | --- |
| Product roadmap and module routing | `GOVOPLAN_MASTER_ROADMAP.md` | Product-level sequencing, implementation gates, issue routing, and missing-module decisions. |
| Institutional governance target | `govoplan/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md` | Cross-product semantic layers, source-authority modes, candidate Mandates/Services/Parties/Decisions boundaries, and migration sequence. |
| Stable platform ideas | `govoplan/docs/strategy/PLATFORM_CORE_IDEAS.md` | Cross-product thesis, canonical distinctions, product experience rule, maturity rule, and decision test. |
| Current cross-product reconciliation | `govoplan/docs/strategy/STRATEGY_STATUS.md` | The only current prose status source; generated evidence and Gitea remain authoritative inputs. |
| Institutional governance target | `govoplan/docs/architecture/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md` | Cross-product semantic layers, source-authority modes, candidate Mandates/Services/Parties/Decisions boundaries, and migration sequence. |
| UI/UX decisions | `UI_UX_DECISION_LEDGER.md` | Binding guided-UI decisions, open decisions, impact index, and review checklist. |
| Core interface migration | `INTERFACE_PATTERN_MIGRATION.md` | Core-owned settings, credential, retention, lifecycle, and shared-component evidence for the product pattern language. |
| Interface ethics and design doctrine | `INTERFACE_ETHICS_AND_DESIGN_DOCTRINE.md` | Product-level doctrine for context, decision, consequence, contestability, responsibility, and traceability. |
@@ -40,7 +40,7 @@ must not imply that GovOPlaN holds an authoritative copy.
Integration maturity states what an adapter is capable of doing. It does not
decide which system owns truth for a configured object or field group. A
binding separately selects one of the source-authority modes defined by the
[institutional governance target architecture](../../govoplan/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md):
[institutional governance target architecture](../../govoplan/docs/architecture/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md):
- `native_authoritative`
- `external_authoritative`
+1 -1
View File
@@ -13,4 +13,4 @@ tools/gitea/gitea-sync-wiki.py --help
Canonical documentation:
- `/mnt/DATA/git/govoplan/docs/GITEA_ISSUES.md`
- `/mnt/DATA/git/govoplan/docs/project/GITEA_ISSUES.md`
+2 -1
View File
@@ -221,7 +221,8 @@ Admin lists use bounded container grids:
- recipient import with column mapping;
- session/device revocation UI;
- backup/restore, monitoring, and update procedures;
- DSAR workflows and evidence bundle verifier;
- additional module providers and signed human-readable response packages for
the implemented DSAR workflow described in `DATA_SUBJECT_REQUESTS.md`;
- campaign ownership transfer workflow;
- policy impact analysis before delete/disable/unshare/change;
- LDAP/OIDC/SAML provisioning;
+3 -3
View File
@@ -10,15 +10,15 @@ gates. Issues are the active backlog; this document is durable architecture
planning context and should be mirrored to the Gitea wiki.
The meta repository's
[Connected Governance Platform Roadmap](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
[GovOPlaN Roadmap](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/strategy/ROADMAP.md)
describes the corresponding cross-product stakeholder visions, configurable
service and operating configurations, connected outcome stories, and
capability horizons. The selected five-stage delivery sequence and its gates
are in the meta repository's
[Reference Journey Program](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/REFERENCE_JOURNEY_PROGRAM.md).
[Reference Journey Program](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/strategy/REFERENCE_JOURNEY_PROGRAM.md).
The semantic target, source-authority modes, and reconciliation with the
implemented platform are in the meta repository's
[Institutional Governance Target Architecture](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
[Institutional Governance Target Architecture](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/architecture/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
Those product documents are canonical; this Core roadmap remains their
technical sequencing and module-routing companion.
+131
View File
@@ -0,0 +1,131 @@
# Information Governance Adoption
## Platform Rule
Temporal browsing, purpose-aware access, retention, and institutional context
are platform-wide information-governance dimensions. Every module receives the
same contract by default. A module may claim `partial` or `enforced` only with
repository-owned object scope, evidence, and limitations; it may claim
`not_applicable` only when the dimension genuinely does not apply.
Historical business data is always authorized under the current security
state. No module may use a historical permission, membership, role, function
assignment, or policy projection to weaken present-day access.
Platform-wide adoption is tracked in
[GovOPlaN #40](https://git.add-ideas.de/GovOPlaN/govoplan/issues/40), with
temporal reads detailed in
[GovOPlaN #39](https://git.add-ideas.de/GovOPlaN/govoplan/issues/39).
## Manifest Declaration
`ModuleManifest.information_governance` publishes four dimensions:
- `temporal_browsing`;
- `purpose_aware_access`;
- `retention`;
- `institutional_context`.
Each dimension declares:
- adoption: `not_applicable`, `contract_only`, `partial`, or `enforced`;
- object types covered;
- repository-local test/documentation evidence;
- the remaining limitation for `contract_only` or `partial`.
The default is intentionally `contract_only`. It applies the platform rule
without pretending that existing domain queries and effects already enforce
it. `reference_ready`, `supported`, and `lts` modules cannot retain an
applicable dimension below `enforced`.
## Read Contract
For every persistent domain object, the owner classifies the read:
1. **Current-only:** historical semantics do not exist and the API says so.
2. **Valid-time:** select facts effective now or at the requested instant.
3. **Bitemporal:** additionally select only revisions known by `recorded_at`.
4. **All-validity:** return effective revisions in a bounded history view.
The Core temporal middleware supplies the request context. Owners apply it in
repositories or query helpers, include it in cache keys, return evaluated
context, and test current/at/all plus recorded-time boundaries. Search,
reporting, exports, selectors, counts, and drill-through must use the same
projection as the owning list/detail API.
## Purpose-Aware Access Contract
Permission establishes a technical action ceiling. Purpose-aware access asks
whether this actor, represented capacity, case/work item, legal basis, and
declared use may access this object now.
- A client-supplied purpose is an assertion, never authority by itself.
- The owner or Policy capability validates the purpose and returns explainable
provenance.
- Sensitive access can require case assignment, mandate, reason capture,
approval, or break-glass evidence.
- Search, selectors, reporting, exports, background jobs, and connectors apply
the same decision.
- Audit records the validated purpose identifier and decision reference, not
unnecessary content.
## Retention Contract
Every persistent object declares an owner, retention class or policy reference,
trigger, start instant, hold behavior, review/disposition action, and evidence.
Retention is not a generic timestamp deletion job.
- Domain owners enumerate and execute their own effects through a typed
retention provider.
- Policy resolves inherited ceilings and simulation.
- Records owns record disposition; Files owns byte/object effects; Audit owns
audit-detail behavior; external providers declare their own effect and
recovery semantics.
- Dry-run, legal hold, exact revision, idempotency, outcome unknown,
reconciliation, correction, and destruction evidence are mandatory for
consequential removal.
## Institutional Context Contract
Consequential objects and effects carry the relevant tenant, institution,
organization unit, function, mandate/jurisdiction, service/case/work item,
party/representation, decision, and record references. Context is minimized to
what the operation needs. Organizational membership is not itself permission
or mandate.
Events, automation intents, audit evidence, records, and external effects retain
the same governed context envelope or an exact reference to it. Consumers must
not reconstruct authority later from mutable current structures.
## Adoption Order
1. Inventory every domain list/detail/search/export/effect and classify all
four dimensions.
2. Migrate institutional owners first: Access, IDM, Organizations, Mandates,
Services, Parties, Cases, Approvals, Committee, Decisions, Voting, and
Records.
3. Migrate communication and content: Addresses, Distribution Lists, Campaign,
Postbox, Mail, Calendar, Files, Templates, and Forms Runtime.
4. Migrate data projections: Connectors, Datasources, Dataflow, Reporting,
Search, Risk Compliance, and Dashboard.
5. Migrate workflow/task/background/provider operations and prove that no
asynchronous path drops context.
6. Advance manifest claims only after owner tests and browser/reference-journey
evidence pass.
The generated platform inventory reports adoption counts and module details.
Gitea tracks individual migrations; the declaration is evidence and a maturity
gate, not a substitute for implementation.
## Definition Of Enforced
A dimension is `enforced` only when:
- all declared object types and public reads/effects use it;
- list/detail/count/search/export/worker behavior is consistent;
- cache and pagination semantics cannot cross contexts;
- absence, invalid values, and inaccessible referenced context fail safely;
- tests cover current, historical, unauthorized, replay, and module-absence
combinations appropriate to the dimension;
- user/admin documentation explains behavior and limitations;
- the manifest cites those tests and docs.
+3
View File
@@ -8,6 +8,9 @@ The shared contract lives in `govoplan_core.core.institutional`.
Core owns reference shapes and provider protocols only. It does not own shared
Mandate, Service, Party, Decision, evidence, or geography tables. Domain modules
own persistence and authorization; optional capabilities resolve the references.
Interactive reads use the separate platform temporal-data context documented in
`TEMPORAL_DATA_CONTEXT.md`; it never changes current authorization or supplies
mutation dates.
## Envelope
+26 -1
View File
@@ -13,7 +13,20 @@ domain modules own their compositions.
| Reusable credentials | Repeated administration with an adaptive create/edit dialog, optional password generator, and destructive confirmation | Secret values are write-only; generated candidates use the browser cryptographic API without a weak fallback and do not replace the field until explicitly confirmed; scope/permission blockers name the required action, responsible actor, and destination; unavailable row actions remain keyboard-explainable | `CredentialEnvelopeManager.tsx`, shared `PasswordField`, `PasswordGeneratorDialog`, `ActionBlockerHint`, `Button`, `TableActionGroup`, and `ConfirmDialog` |
| Retention policy | Effective-policy editor with inherited source paths and typed, narrowing-only controls | Parent locks and missing write authority are explicit; the save action distinguishes locks, missing target, loading, clean draft, and active save | `RetentionPolicyManagement.tsx`, policy logic tests, `test-core-interface-patterns.mjs` |
| Module lifecycle | Guided operator projection over durable installer-queue evidence | Preflight, handoff, progress, stale evidence, recovery, and rollback consequences remain visible | Admin module lifecycle tests and the Core installer-queue contract |
| Shared configuration primitives | Cross-module component contract | Dialog focus, blocker structure, disabled-action focus, contextual help, unsaved changes, confirmation, loading, alerts, problem lists, and policy provenance are centralized | Core component tests and module-permutation build |
| Shared page frame | Domain-neutral headed page layout used by Core and optional modules | Standalone, workspace, and embedded modes make inset and scroll ownership explicit; sticky heading, rich descriptions, route actions, notices, loading, narrow-layout collapse, and contextual-help identity are centralized; composite administration workspaces may delegate the visible heading to their contributed panel while retaining the same frame; `AdminPageLayout` composes the contract | `PageLayout.tsx`, `page-layout.test.tsx`, Core Settings, Access administration, Docs, Mail bounce processing, Dashboard, Ops, Campaign, and `check-shared-webui-layouts.py` |
| Full-canvas workspace | Navigation/content and list/detail canvases that own pane geometry and scrolling | Navigation and split variants, primary-pane width, pane-owned or contained scrolling, responsive stacking or navigation collapse, pane labels, and contextual-help identity are centralized without encoding domain navigation | `WorkspaceLayout.tsx`, `workspace-layout.test.tsx`, Core Settings, Access administration, Docs, Organizations, Campaign, Templates, Approvals, and `check-shared-webui-layouts.py`; the raw-workspace exception baseline is empty |
| Full-height module frame | Outer module landmark and viewport/container sizing | `WorkspaceFrame` centralizes surface, overflow, box sizing, accessible naming, help identity, and application-viewport height so modules do not copy the `100vh - shell` frame | `WorkspaceFrame.tsx`, `layout-primitives.test.tsx`, Dataflow, Workflow, Datasources, Distribution Lists, Notifications, Tasks, Scheduling, Forms, Portal, Projects, Records, and Reporting |
| Responsive action toolbar | Domain-neutral action and filter grouping for pages, workspaces, editors, and overlays | Density, surface, grouping, flexible space, accessible naming, toolbar help identity, and responsive wrapping are centralized while modules retain action wording, authority, and consequence | `ActionToolbar.tsx`, `layout-primitives.test.tsx`, WYSIWYG, Calendar, Files, Forms, Templates, and the product-wide primitive check |
| Semantic page and pane action bars | Overview, collection, detail, editor, and workspace intent declared independently from frame geometry; full-canvas panes add workspace/collection/detail/editor scope | Core renders leading Reload from a guarded descriptor; editor persistence owns clean, dirty, invalid, saving, failed, and conflict feedback plus guarded Discard and far-right Save; destructive actions occupy an explicit named boundary; read-only surfaces do not invent Save | `PageActionBar.tsx`, `WorkspaceActionBar.tsx`, `PAGE_LAYOUT_USAGE_GUIDELINES.md`, component and browser conformance, every headed page and full-canvas workspace, and the discovery-based `check-shared-webui-layouts.py` |
| Catalogue and state composition | Search/filter bars, selectable navigation lists, count badges, and empty/blocked/error panels | Width, surface, wrap, selection geometry, title/description truncation, numeric emphasis, state sizing, tone and action placement are centralized; modules retain query behavior, object state and consequences | `FilterBar.tsx`, `SelectionList.tsx`, `CountBadge.tsx`, `StatePanel.tsx`, `layout-primitives.test.tsx`, and list/detail modules across Cases, Committee, Dataflow, Forms, Notifications, Portal, Postbox, Projects, Records, Reporting, Tasks, Templates, and Workflow |
| Content and form grids | Equal-column content, field, and native-form geometry | Explicit 14 columns, standard gaps, item spans, alignment, and named narrow/workspace/standard/wide collapse points replace generic and module-prefixed copies; unequal domain tracks remain local | `ContentGrid.tsx`, `layout-primitives.test.tsx`, Core dashboard/settings/mail, Calendar dialogs, Forms editor, Datasources, Postbox, Campaign, administration surfaces, and the product-wide primitive check |
| Content sections | Repeated editor/detail section surfaces | Border, surface, compact/default density, stacked flow and block rhythm are centralized without encoding section contents | `ContentSection.tsx`, `layout-primitives.test.tsx`, Datasources, Distribution Lists, Templates, Dataflow, and Workflow |
| Form sections | Reusable heading/description/action/content grouping inside forms | Plain, separated, and panel variants centralize hierarchy and narrow action placement without moving validation, permissions, values, or domain wording into Core | `FormSection.tsx`, `layout-primitives.test.tsx`, Addresses contact editing, and Quick Access preferences |
| Metric groups and drill-downs | Reusable responsive grouping around metric cards with an explicit optional detail affordance | Fixed one-to-five and auto-fit columns, minimum card widths, density, block/inset/zero spacing, and named collapse points replace the product-wide `metric-grid` class and cross-module dashboard overrides; typed link or in-page drill-downs name their destination while summary-only, non-enumerable, derived, or privacy-suppressed values remain inert | `MetricGrid.tsx`, `MetricCard.tsx`, `metric-card.test.tsx`, `layout-primitives.test.tsx`, Core and Dashboard summaries, administration, Campaign, Ops, Files, Search, and dashboard widgets |
| Description lists | Semantic property and fact presentation | Stacked and inline variants, one-to-five list columns, density, term width, wrapping, and responsive collapse replace both `admin-details-grid` and `detail-list`; `DescriptionItem` preserves native `dt`/`dd` anatomy | `DescriptionList.tsx`, `layout-primitives.test.tsx`, Access and Tenancy administration, Audit, Policy, Campaign reports/imports, Docs, Settings, Ops, and Reporting |
| Dialog anatomy | Shared outer dialog plus composable body and footer regions | Size and administration variants, body padding, descriptions, notices, fixed action wrapping, native form flow, and section grouping are centralized; focus trapping and stack lifecycle remain unchanged | `Dialog.tsx`, `DialogAnatomy.tsx`, `dialog-focus.test.tsx`, `layout-primitives.test.tsx`, Addresses, Calendar, Records, Datasources, Distribution Lists, Files, and Templates |
| Definition-editor visuals | Reusable graph palette, canvas chrome, node icon/port geometry, empty overlay and floating activity state | Core owns visual and responsive anatomy while node/edge types, validation, execution, provenance and workflow semantics remain in Dataflow or Workflow | `DefinitionPalette.tsx`, `DefinitionNodeIcon.tsx`, `FloatingStatus.tsx`, shared definition styles, Dataflow and Workflow structure/build checks |
| Shared configuration primitives | Cross-module component contract | Dialog focus, blocker structure, disabled-action focus, route/page/field/action F1 help, unsaved changes, confirmation, loading, alerts, problem lists, and policy provenance are centralized | Core component tests, `CONTEXTUAL_HELP_CONTRACT.md`, and module-permutation build |
## Boundary
@@ -26,3 +39,15 @@ they are not reasons to add sibling-private behavior to Core.
Raw JSON remains permitted only for diagnostics, expert inspection,
interchange, or conflict evidence. It is not a primary Core configuration
editor.
New headed pages use `PageLayout`; full-canvas modules use `WorkspaceFrame`
and, where applicable, `WorkspaceLayout`, so Core owns the frame and pane
scrolling. Module CSS continues to own unequal domain content layout, never the
shared page, workspace, toolbar, state, list, filter, metric, section, or graph
chrome. Retired copies and module-local component definitions are rejected by
`check-shared-webui-primitives.py`. That check also requires standard dialog
widths to use `Dialog size` and keeps every remaining domain-specific width in
a reviewed, decrease-only exception baseline. The companion layout check now
has zero raw page-frame and zero raw workspace exceptions, discovers semantic
consumers without a hand-maintained route list, requires semantic action bars
on `WorkspaceFrame` routes, and rejects ad-hoc panel-header toolbars.
+97
View File
@@ -0,0 +1,97 @@
# Localization And Contextual Help Quality
## Reference Language
German (`de`) is GovOPlaN's first-class reference target. Every translation key
used by a shipped WebUI must exist in German and English. German completeness is
a release gate; English remains the source-code fallback language so existing
literal labels and external developer APIs do not change semantics.
New installations and tenants default to German. Existing system, tenant, and
user preferences are preserved. The available-language and policy model can
still select another default or disable a package at the relevant scope.
Explicit high-risk help content and browser acceptance are tracked in
[Core #284](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/284).
The platform inventory recognizes both inline locale objects and generated
catalogs declared as `const de` / `const en`. Its strict mode requires both
locales and reports `de` explicitly as the reference locale.
## Help Resolution
Every focusable field and action receives a stable derived F1 identity from the
shared shell, even when the component has no dedicated help text. Resolution
falls back from field/action to dialog or page and then to the module's visible
documentation baseline.
Backend manifests publish explicit topic associations first. Core additionally
associates declared route, navigation, settings, and View surface IDs with the
module's static user or administrator documentation baseline. Feature modules
should still add exact `metadata.help_contexts` entries for consequential,
unfamiliar, policy-controlled, destructive, security-sensitive, or legally
meaningful fields and actions.
The shared retention-policy editor exposes explicit contexts for each stored
data category, audit-detail control, lower-level override switch, target
selector, reload, and save action. The Policy module owns the matching German
administrator guidance. Retention execution surfaces use separate contexts for
dry-run, destructive apply, confirmation, and outcome review so F1 opens the
consequence and recovery guidance closest to the focused control.
Shared controls may set `helpModuleId` when their documentation owner differs
from the containing page; the retention editor uses this to resolve Policy help
from both administration and Campaign surfaces.
The shared reusable-credential manager keeps Access as its documentation owner
and publishes exact contexts for credential kind, secret replacement/removal,
module and server restrictions, lower-scope visibility, activation, save, and
irreversible deletion. This ensures F1 explains secret custody and the effect on
dependent connections from system, tenant, group, user, and personal surfaces.
The source inventory treats literal `helpContextId` and
`data-help-context-id` declarations as authored help associations, including a
native control nested in `FormField`. Dynamic context expressions remain
separate evidence and generic derived fallbacks remain in the richer-help
candidate queue.
The generated `help_review_candidates` list is therefore a content-depth queue,
not a list of controls on which F1 cannot work. It should prioritize:
1. effect, deletion, delivery, retention, disclosure, encryption, and recovery;
2. identity, representation, mandate, institutional context, and purpose;
3. valid-time versus recorded-time selection;
4. provider authority, synchronization, conflict, and outcome unknown;
5. fields whose consequences are not evident from their label.
The shared browser conformance journey mounts the production Help menu and
resolver. It proves that F1 uses the focused control rather than only the page,
maps an exact retention action to Policy-owned administrator documentation,
retains the page context as fallback for derived actions, exposes an accessible
modal at narrow widths, closes with Escape, and restores focus to the triggering
control. Module journeys should add their own exact high-risk mappings; they do
not need to reimplement the keyboard or dialog mechanics.
## Verification
```bash
cd /mnt/DATA/git/govoplan
/mnt/DATA/git/govoplan/.venv/bin/python \
tools/inventory/platform-interface-inventory.py \
--strict --strict-declarations --strict-endpoints
```
The check must report:
- reference locale `de` present and complete;
- no used key missing from `de` or `en`;
- every field has a resolvable F1 context;
- no duplicate stable IDs;
- no undeclared public WebUI surface;
- no stale runtime route or endpoint declaration.
Browser acceptance is part of the focused workspace gate and can be run alone:
```bash
cd /mnt/DATA/git/govoplan-core/webui
npm run test:conformance
```
+43 -3
View File
@@ -16,7 +16,7 @@ The experimental remote WebUI bundle loading design is tracked in
The cross-product semantic layers, source-authority modes, and candidate
Mandates, Services, Parties, and Decisions boundaries are canonical in the
meta repository's
[`INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md`](../../govoplan/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
[`INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md`](../../govoplan/docs/architecture/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
## Layer Model
@@ -213,8 +213,29 @@ Other stable runtime capabilities currently include:
`calendar.externalProfiles`
- `poll.scheduling`
- `notifications.dispatch`
- `application_status.projection`
- `payments.requests`
- `workflow.definitionContributions` and `workflow.runtimeWorker`
`calendar.scheduling` keeps workflow modules independent of Calendar-owned
models and transport adapters. Consumers may create tentative events, promote
the selected event in place, and release unused events idempotently. The
provider returns bounded external-delivery and outbox references so consumers
can retain retry state without copying Calendar's synchronization internals.
`application_status.projection` lets a presentation module resolve the tenant
and display or request access to an owner-supplied, deliberately bounded
applicant-status view. The provider retains policy, authorization, token, and
record ownership; consumers must not query provider tables or enlarge the
projection.
`payments.requests` carries replay-safe payment obligations and evidence-bound
manual reconciliation across module boundaries. Procedure modules identify the
source Case or Workflow in the command and retain the returned payment ID;
Payments remains authoritative for amount, currency, state, transaction
reference, and reconciliation evidence. Ledger, invoice, and external payment
providers remain separate follow-on contracts.
The provider-neutral `idm.relationships` contract carries tenant-scoped typed
groups, effective-dated identity relationships, and explicit membership
decisions. It deliberately does not expose IDM persistence models or imply an
@@ -305,6 +326,8 @@ contract checks, are:
- `files.access`, `files.campaign_attachments`
- `mail.campaign_delivery`
- `notifications.dispatch`
- `application_status.projection`
- `payments.requests`
- `poll.availability_matrix`, `poll.option_selection`,
`poll.response_collection`, `poll.signed_participation`,
`poll.workflow_context`
@@ -1377,8 +1400,10 @@ The package install-plan API records operator intent only:
- `GET /api/v1/admin/system/modules/package-catalog` reads approved package
references from `GOVOPLAN_MODULE_PACKAGE_CATALOG` so operators can add known
module refs to the install plan without typing them manually. The endpoint
also reports catalog validity, channel, signature, trust state, and the
configured path.
also reports catalog validity, channel, signature, trust state, source and
artifact provenance, release availability, configuration requirements, and
per-entry compatibility/blocker state. Withdrawn entries are visible for
diagnosis but cannot be planned.
- `POST /api/v1/admin/system/modules/install-plan/catalog/{module_id}` saves
a planned install or update row from a validated catalog entry. Installed
modules are planned as updates. Catalog signature and approved-channel policy
@@ -1476,6 +1501,10 @@ The installer preflight is intentionally conservative:
- the `shared` state profile blocks in-place package mutation; clustered
installations must roll one verified immutable module composition across all
replicas;
- official runtime images carry the full verified package profile, while the
desired module graph controls activation and tenant/View/Policy contracts
control availability and presentation; package lifecycle must not be reused
as a tenant or user visibility switch;
- installed module manifests must be compatible with the supported manifest
contract and current core version;
- uninstalling `tenancy`, `access`, or `admin` is blocked;
@@ -1592,6 +1621,17 @@ URLs never contain credentials; only credential-envelope references cross the
contract. Provider-specific details belong in sanitized provenance rather than
in a shared domain schema.
## Semantic Documentation Subject Contract
Optional modules expose configured artifacts that can be documented through
the module-scoped `documentation.semantic_subjects.<module_id>` capability.
Core supplies stable tenant-scoped references, typed nested anchors, safe
localized descriptors, revision/fingerprint review signals, and explicit
availability states. Providers remain responsible for authorization and do not
expose configuration payloads or credentials. Docs discovers the capability
and owns authored content; it does not import feature internals. See
`SEMANTIC_DOCUMENTATION_SUBJECTS.md` for the contract and adoption rules.
## Build And Verification
Backend verification from core:
+158
View File
@@ -0,0 +1,158 @@
# Page Layout and Action Guidelines
This document defines the binding composition grammar for headed GovOPlaN
pages. Core owns the reusable anatomy; each module owns its domain actions,
wording, authorization, consequences, and data state.
## Required Page Frame
- Use `PageLayout` for every headed standalone, workspace, or embedded page.
- Declare exactly one semantic `archetype`; do not infer page intent from the
`mode`, which controls geometry and scroll ownership only.
- Use `WorkspaceFrame` for a full-height module surface and
`WorkspaceLayout` only where navigation/content or list/detail panes are
genuinely part of the interaction.
- Put page-wide feedback in `PageLayout` notices. Use `DismissibleAlert` for a
recoverable warning or failure and `StatePanel` when the entire surface is
loading, empty, unavailable, or blocked.
- Do not reproduce shared page padding, heading, toolbar, form-grid, section,
table, dialog, or breakpoint CSS in a module.
## Product Side Rail
Module manifests contribute stable navigation surface identifiers, labels,
paths, icons, and default order. Core owns the side-rail composition and the
shared `NavigationPreferenceEditor`; modules must not fork this editor or
persist their own rail ordering.
Navigation preferences are layered in this order: module defaults, system,
tenant, then user. Each higher layer may reorder or change visibility. System
and tenant administrators may lock an entry visible; a lower layer can still
move that entry, but cannot hide it. Personal preferences cannot create locks.
An unset preference inherits the complete lower layer, while “Use inherited
order” removes the current layer rather than copying its values. Unknown item
identifiers remain harmless so uninstalling, disabling, or later reinstalling
a module does not corrupt the rail.
The platform module response projects module, system, and tenant layer states
alongside the effective user state. Editors must initialize from the layer
immediately below the scope they edit, so a system or tenant administrator's
personal preference is never promoted accidentally. Preference saves refresh
the platform module projection. View policy, permissions, and tenant module
entitlements remain independent final visibility gates; changing rail
preferences never grants access.
## Semantic Page Archetypes
| Archetype | Use when |
| --- | --- |
| `overview` | The page summarizes health, metrics, or several peer areas without owning one primary collection or draft. |
| `collection` | The primary object is a searchable/listable collection and Create, when available, applies to that collection. |
| `detail` | The page primarily presents one record, report, or immutable projection. |
| `editor` | The page owns one explicit draft with Save and Discard behavior. |
| `workspace` | The page coordinates several panes, stages, or task-local operations that cannot honestly be reduced to one record or draft. |
The archetype remains stable for the current interaction. A page may switch
from `overview` to `editor` when the user explicitly enters configuration
mode. It must not call a page an editor merely because a dialog or an inline
filter is editable.
## Page Action Rules
Pass one `PageActionBar` to the `PageLayout` `actions` slot. Full-canvas
workspaces use the same contract through `WorkspaceActionBar`, with an explicit
`workspace`, `collection-pane`, `detail-pane`, or `editor-pane` scope. The
variant makes the surface's intent inspectable and preserves the same keyboard
and visual order across modules. `ActionToolbar` remains the lower-level
component for section-local controls; it is not a substitute for a semantic
page or pane action bar.
| Page kind | Leading group | Trailing group |
| --- | --- | --- |
| Overview | Reload when refreshable, then context | Help, then ordinary primary actions |
| Collection | Reload when refreshable, then collection context such as export | Help, then Create at the far right |
| Detail | Reload when refreshable, then object context | Help, ordinary primary actions, then a separated destructive group |
| Editor | Reload only when refresh is a distinct safe operation, then context | Dirty state, Help, ordinary primary actions, separated destructive actions, Discard, then Save at the far right |
| Workspace | Reload when the coordinated projection can become stale, then task context | Help, ordinary primary actions, then a separated destructive group |
Reload means re-fetch or re-evaluate the current surface. A page declaring
`refreshable` must provide it, and a non-refreshable page must not use Reload as
a synonym for Cancel, Reset, or Discard. Reload never silently destroys a dirty
draft. Create is a collection-wide action and is not duplicated in a
persistent side panel. Save is present only where the page owns an editable
draft; a read-only detail page must not display a disabled or inert Save merely
to fill the slot.
Editor bars always keep Discard and Save visible. Their required `state`
projection is one of `clean`, `dirty`, `invalid`, `saving`, `save-failed`, or
`conflict`, and the central component announces it through a live status label.
Clean and saving states disable both persistence actions; invalid disables Save
while retaining Discard. Failed saves and conflicts keep the draft recoverable
and allow an authorized retry after the module has shown the owning error or
conflict evidence. A module may add a more specific validation, policy, or
permission blocker. The editor must register its draft with
`useUnsavedDraftGuard` (or a shared hook that uses the same registration
contract), so browser unload, route navigation, section changes, Reload, and
the explicit Discard path cannot silently lose work.
Reload is rendered by Core from a descriptor rather than passed as arbitrary
button markup. It can project `current`, `stale`, `reloading`, or
`reload-failed`; `loading` is the shorthand for `reloading`. A failed refresh
must preserve usable loaded data, expose its stale/failure state, and leave
Reload available for recovery. Reload goes through the same unsaved-navigation
guard as route changes.
Destructive page actions use `destructiveActions`; never put a danger action in
`contextActions` or the ordinary primary group. Core renders a persistent
visual and semantic boundary before this group. In an editor it precedes the
Discard/Save pair, keeping Save in the final keyboard and visual position.
`PageActionBar` controls non-editor placement and owns the standard editor
persistence buttons. Other actions continue to use central
`Button`, `IconButton`, or `TableActionGroup` components. When an action is
visible but unavailable because of permission, target, policy, state, or
validation, keep it in its stable slot and supply `disabledReason`. Do not
silently hide a normally applicable action.
## Forms and Dialogs
- Compose forms from `FormLayout`/`FormGrid`, `FormSection`, and `FormField`.
- Use `FieldLabel` through `FormField` for every field that is not genuinely
self-explanatory; record justified omissions in the owning UI ledger.
- Use `Dialog`, `DialogForm`, `DialogSection`, and `DialogActions` for modal
work. A dialog can be domain-specific while its anatomy remains central.
- Use `useUnsavedDraftGuard` for explicit Discard and guarded navigation on an
editable page or dialog.
- Explain irreversible or operationally consequential actions before the
commit button, including reversibility and durable evidence.
## Collections and Details
- Use `FilterBar` for collection query controls and `DataGrid` for tabular
collections. Keep a single ordered `TableActionGroup` action set per table.
- Use `MetricGrid`/`MetricCard` for summary measures, `Card` or
`ContentSection` for logical sections, and `DescriptionList` for labelled
facts.
- Add a `MetricCard.drilldown` only when the displayed measure has a useful,
authorized underlying collection or detail. Name the destination explicitly
(for example, “Review failed deliveries”) and preserve the current scope and
filters in its `href` or action. The card itself remains non-interactive so
the action is visible and keyboard-predictable. Derived, privacy-suppressed,
non-enumerable, or purely informational aggregates remain plain metrics;
when an ordinarily available drill-down is temporarily blocked, keep its
action and provide `disabledReason`.
- Preserve loaded data after a refresh failure and mark it stale; offer Reload
as the recovery action. Distinguish initial loading, empty, unavailable,
permission-blocked, conflict, success, and retry states.
## Review Evidence
Every new or changed page or workspace pane must have structural evidence for
its frame, semantic archetype/scope and slot order, refresh declaration, shared
component usage, stable disabled actions, dirty guard, destructive boundary,
and module-owned help identity. Type checks enforce conditional Reload and
editor persistence props. The product check discovers all consumers, rejects
undeclared archetypes and `ActionToolbar` panel-header copies, and requires
semantic actions for every `WorkspaceFrame` route. Browser conformance confirms
keyboard order, lifecycle changes, accessibility, destructive separation,
narrow wrapping, and screenshot geometry.
+24
View File
@@ -126,6 +126,27 @@ When the capability is absent, modules must not silently emulate cross-scope
inheritance. Their conservative fallback is limited to local tenant
definitions and disables reuse, derivation, and automation.
## Bounded Impact-Subject Providers
Policy impact previews discover optional subject providers through capability
names beginning with `policy.impactSubjects.`. The suffix is the stable
provider ID; for example, Views contributes `policy.impactSubjects.views`.
Providers implement `PolicyImpactSubjectProvider` and receive a
`PolicyImpactPopulationRequest` containing the active tenant, policy family,
an explicit selector, actor scopes, detail-disclosure decision, and a limit of
at most 500. They return `PolicyImpactSubjectBatch` with unique opaque subject
references and an explicit `complete`, `sampled`, `truncated`, or `unavailable`
state. An unavailable batch must explain the gap, and a total may never be
smaller than the returned subject count.
Core does not scan module data or evaluate domain policy. The owning module
selects and permission-filters its candidates; Policy compares the current and
proposed decisions and controls response disclosure. A caller must select one
or more provider populations explicitly. This preserves optional-module
boundaries and prevents a seemingly harmless preview from becoming an
unbounded platform query. Providers must not include credentials, secrets, or
unfiltered cross-tenant labels in subject attributes.
## Frontend Contract
Policy UIs must:
@@ -138,6 +159,9 @@ Policy UIs must:
lower-level limit to `false`
- avoid sending locked fields or re-enable attempts in save payloads
- show inherited values separately from local overrides
- require a current impact preview before enabling a governed high-impact save,
preserve its proposal hash on commit, and explain incomplete population
coverage rather than presenting unavailable providers as zero impact
The core WebUI helper `privacyRetentionParentAllowsField()` centralizes the
field-lock decision used by the retention editor and its lightweight module
+55 -4
View File
@@ -1,9 +1,12 @@
# Postbox End-To-End Encryption Architecture
This document records the strategic encryption target for GovOPlaN postboxes.
It does not require the first postbox implementation to ship full E2EE, but it
defines the architecture so early data models and APIs do not make the stronger
model impossible.
This document records the encryption boundary for GovOPlaN postboxes. Postbox
now implements the server-side contracts for three selectable profiles:
unencrypted content, institution-managed server envelopes, and externally
produced E2EE envelopes. The E2EE contract is operational—the server rejects
plaintext and retains ciphertext, signed manifests, wrapped keys, and digest
evidence—but a reviewed browser/device client and private-key custody provider
remain separately deployed responsibilities.
The core principle is that a postbox can become a trusted administrative
communication channel without requiring the server to see plaintext content.
@@ -35,6 +38,54 @@ Algorithm choices should remain replaceable behind a crypto profile. The first
profile should prefer standard, reviewed primitives such as HPKE for key
wrapping and AEAD encryption for content.
## Product Profiles And Default
The content-protection policy is configurable per exact Postbox or immutable
template revision:
- `server_envelope_v1` is the recommended default. An institution-selected
Encryption vault controls server-readable envelopes and their migration
evidence. It is not end-to-end encryption.
- `external_e2ee_v1` is server-blind. An approved client or producer supplies
the ciphertext reference, signed manifest, wrapped recipient keys, key epoch,
and SHA-256 plaintext digest. GovOPlaN has no private key that can decrypt it.
- `plaintext_v1` stores clear content for institutions that explicitly choose
that boundary.
Operational metadata—including subject, routing, participants,
classifications, timestamps, attachment references, receipts, and retention
state—remains visible under every profile. Administrators therefore choose a
content-protection boundary, not a metadata-anonymity profile.
The standard policy grants new incumbents history since assignment, uses key
rewrapping for ordinary rotation and content re-encryption after compromise,
requires two-person institutional recovery and dual-control hand-over,
emergency, export, and destruction, requires strong external identity, and
limits vacancy escalation to metadata. Deployments may select other policy
values rather than inheriting a decision from GovOPlaN.
## Governed Profile Changes
A profile transition applies to new messages immediately and increments the
Postbox key epoch. Retained history can remain under the previous profile or be
migrated. The transition ledger records source and target profiles/vaults,
authority route, consent and key-holder evidence, quorum, reason, immutable
configuration snapshot, per-message source and target digest, and outcome.
Plaintext and managed-envelope migrations can use the server-side Encryption
capability. Managed decrypt, export, and re-encryption operations also create
Encryption migration records so old envelopes are disposed of through the
governed provider contract. Any transition to or from E2EE pauses each retained
message for an approved client transform. The client must return plaintext or
ciphertext as appropriate, plus evidence and the original content digest;
Postbox verifies digest continuity before changing the stored representation.
Leaving E2EE requires user-consent evidence, while changing managed history
requires institutional key-holder evidence. Dual control can require both.
This transition mechanism cannot revoke plaintext already decrypted, copied,
printed, or exported. Administrators must explicitly acknowledge that residual
disclosure before a transition is accepted.
## Identity And Device Keys
The platform should distinguish:
@@ -141,6 +141,73 @@ connector or module issue.
- Owner/priority: `govoplan-mail`, `govoplan-calendar`,
`govoplan-connectors`, Wave 1/2.
#### Collaboration-suite boundary and hand-offs
Collaboration remains connector-first. The product named below never changes
which GovOPlaN module owns the administrative meaning of the work:
| External family | Initial posture | GovOPlaN semantic owner | Connector-owned boundary |
| --- | --- | --- | --- |
| Collabora Online, OnlyOffice, Nextcloud Office | Link an externally edited document and its editing session; import a governed rendition only when required | DMS owns document/version, lock, review, approval, retention, and collaboration-session evidence; Files owns stored bytes | Discovery, endpoint health, WOPI/vendor session exchange, callbacks, and provider object references |
| Matrix, Mattermost, Rocket.Chat, Nextcloud Talk | Create or link a room/thread for a governed work context; do not mirror all conversation history by default | The initiating Case, Workflow, or Task owns the work-context link and disposition; DMS/Records own retained evidence deliberately captured from it | Room/thread creation, membership synchronization, webhook/event normalization, and stable external links |
| Jitsi and BigBlueButton | Provision or link a conference for an existing appointment/event | Appointments owns booking intent; Calendar owns event, attendee, invitation, and time semantics | Conference provisioning, join/moderator references, provider lifecycle, and bounded attendance/result callbacks |
| OpenProject and comparable project suites | Link first, then publish or synchronize selected work packages | Tasks owns GovOPlaN task state; Workflow owns orchestration; Cases own case state and evidence references | Project/work-package lookup, publish/synchronize transport, webhooks, version tokens, and external URLs |
| Cross-suite activity streams | Consume normalized, bounded events only for an authorized work context | The receiving module decides whether an event changes state or becomes evidence; Audit records the GovOPlaN operation | Provider subscriptions, cursor/checkpoint handling, signature validation, event normalization, and replay protection |
Native collaboration behavior is justified only when GovOPlaN must own the
semantic state, authorization decision, audit evidence, retention/legal-hold
rule, or configuration-package fragment. Endpoint profiles, tokens, health,
protocol clients, provider IDs, retries, and webhook transport remain in
Connectors (or the owning protocol connector). A feature module consumes a
Core capability/DTO and must still start and fail explicitly when that optional
connector is absent; it never imports a provider client.
The minimum hand-off sequences are:
1. **Appointment to conference:** Appointments confirms the booking intent;
Calendar creates or updates the event and invitations; an optional
conference connector provisions the room idempotently and returns an
opaque join reference. Calendar stores that reference with the event, not
the provider credential.
2. **Case or Workflow to collaborative document:** the initiating module asks
DMS for a governed document/session; DMS requests an optional office-suite
connector session and retains version, lock, approval, and callback
evidence. The Case/Workflow keeps only the DMS reference.
3. **Case, Workflow, or Task to chat:** the semantic owner requests a room or
thread with an idempotency key and bounded membership intent. The connector
returns an external reference; capturing messages as evidence requires an
explicit DMS/Records action and policy decision.
4. **Task or Workflow to project suite:** Tasks supplies the task payload and
Workflow supplies correlation; the OpenProject connector publishes or
reconciles the work package and returns versioned external-reference and
retry/conflict evidence. Neither consumer writes connector tables.
Every executable collaboration connector must pass the common connector
contract checks plus a provider-focused minimum proof:
- optional-module startup and partial compositions work without the provider;
- profile health uses secret references and redacts credentials and remote
response bodies;
- tenant/resource authorization is checked before discovery, provisioning,
lookup, synchronization, or evidence capture;
- dry-run/simulation performs no remote mutation and explains unsupported
operations;
- create/publish calls are idempotent, retries preserve the same external
reference, and outcome-unknown or version conflicts remain reconcilable;
- callbacks/webhooks verify authenticity, tenant/profile binding, replay
protection, and bounded payloads;
- disable/retire behavior revokes new use while preserving non-secret audit and
external-reference evidence;
- Collabora/OnlyOffice prove discovery plus one non-production editing-session
round trip; Matrix/Mattermost/Rocket.Chat prove room lookup/create plus one
authenticated bounded event; Jitsi/BigBlueButton prove conference
provision/cancel; OpenProject proves project/work-package lookup, idempotent
publish, and conflict handling.
These are connector acceptance tests, not a claim that those connectors are
already implemented. Their implementation state remains in the owning
connector issues and catalogue.
### Payment And Public Cashier Systems
- Strategy: integrate/export/import; keep the payment provider or cashier as
+94
View File
@@ -0,0 +1,94 @@
# Records Filing Contract
Core exposes a small provider-neutral contract for filing exact source
revisions into an institutional record. Core does not own records semantics,
source-object authorization, or source bytes. `govoplan-records` owns filing
orchestration and chronology; each source module owns resolution of its exact
revision.
## Capability Names
- `records.filing` is supplied by the enabled Records module.
- `records.source.<module>` is supplied by an enabled source module, for
example `records.source.files` or `records.source.cases`.
- `records.archive.<provider>` is supplied by an enabled archive-transfer
adapter. Discovery does not imply conformance or current health.
Callers discover capabilities through the module registry. They must not
import optional source-module internals.
## Exact Source Identity
`RecordSourceLocator` identifies one tenant, source module, resource type,
resource ID, and immutable source revision. A source provider must:
1. reject cross-tenant resolution;
2. require a non-empty purpose;
3. re-evaluate the caller's current module and object authorization;
4. resolve exactly the requested revision, never a mutable "current" alias;
5. return safe display/provenance metadata and a SHA-256 digest when the source
has stable bytes or a canonical snapshot;
6. fail closed when the revision is missing, quarantined, corrupt, or no longer
authorized.
Historical Records browsing never revives historical access rights. The
source's current authorization decision remains authoritative when filing.
## Filing Semantics
`RecordFilingRequest` binds the exact source to a record, purpose, filing
reason, relationship, institutional context, and idempotency key. Records must
persist source identity and resolution evidence together with the filing actor,
represented capacity, valid time, recorded time, and immutable chronology.
An idempotency key may replay only an identical request. A conflicting reuse
must fail. Filing does not transfer ownership of source content and must not
silently copy mutable source state.
## Versioning
The Python DTOs and protocols live in `govoplan_core.core.records`. The
manifest interface `records.filing` starts at `1.0.0`. Incompatible DTO or
behavior changes require a new interface version and release impact analysis;
additional optional metadata remains backward compatible.
## Initial Providers
- Files resolves an exact managed `FileVersion`, verifies current Files access
and blob integrity, and returns its stored content digest.
- Cases resolves an exact immutable case revision after current case access and
returns a digest of the canonical revision snapshot.
Provider-specific selection UI belongs to the source module. The generic
Records dialog remains a diagnostic/manual fallback for exact identifiers.
## Archive Transfer Boundary
`RecordTransferPackage` binds a stable package ID, record revision, provider
profile, canonical manifest, and manifest SHA-256. An archive provider exposes
`RecordArchiveProviderState` before dispatch and accepts only a
`RecordArchiveTransferRequest` for a declared healthy profile. Its receipt must
identify the same package and provider and return one bounded outcome:
`accepted`, `rejected`, or `outcome_unknown`.
An unknown outcome is never retry-safe. Callers must retain the intent and
reconcile it against the provider before another effect. Provider state also
declares authority mode, freshness, limitations, and whether the provider is a
simulation. Credentials, transport configuration, archive-specific package
schemas, and custody semantics remain provider-owned.
Records includes `records.archive.simulation` to prove package and receipt
handling. The simulation is explicitly non-conformant, transfers no custody,
and cannot be used as evidence of an archive handoff. A real provider requires
a selected target/profile, provider-specific recovery declaration, and target
test evidence.
## Form Evidence Boundary
Form attachments use the separate provider-neutral contract in
`govoplan_core.core.form_evidence`. Forms Runtime requests short-lived,
purpose-bound upload grants and re-inspects the exact provider-owned evidence
before final submission. The provider keeps byte storage, quarantine,
classification, and retention ownership; Forms Runtime stores only immutable
evidence references and bounded verification results. This contract is not an
alternative path for Records filing or archive custody.
+32
View File
@@ -197,6 +197,13 @@ If both file and URL are set, the URL wins. The cache is used when a remote
fetch fails, so an operator can still inspect the last known catalog. A cached
catalog must still pass signature, freshness, channel, and replay validation.
If neither source is configured, the Admin package directory discovers the
official public stable catalog at
`https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json`. Core verifies
that fallback against the public key pinned in the installed Core package. An
explicit deployment catalog always takes precedence; a configured source that
is unavailable or invalid fails closed instead of silently falling back.
An official catalog is a JSON object with:
- `catalog_version`
@@ -212,6 +219,14 @@ Each module entry can declare:
- backend package name and pinned install reference
- WebUI package name and pinned install reference
- `artifact_integrity` for each package, including the HTTPS registry URL,
filename, byte size, SHA-256, package identity, source tag, and source commit
- `source`, binding the repository and immutable tag/commit identity, with
optional HTTPS repository and revision links
- `availability`, either `available` or `withdrawn`; a withdrawn entry must
carry an operator-readable `availability_reason` and cannot be planned
- `configuration_requirements` and an optional HTTPS `release_notes_url` for
prerequisites and release-specific operator guidance
- display metadata and tags
- `license_features`, the feature entitlements required to plan that install
- `dependencies` and `optional_dependencies`, the module ids expected in the
@@ -239,6 +254,12 @@ Each module entry can declare:
- `requires_interfaces`, named interface contracts and version ranges required
by this module
Core validates these fields before exposing the directory. Admin derives a
read-only catalog state from the installed package set, catalog dependency
closure, named-interface providers, current-version window, availability, and
generic license policy. This is an early operator diagnostic; trusted installer
preflight remains the authoritative mutation gate.
The signature is Ed25519 over canonical JSON with both `signature` and
`signatures` removed. Core accepts the legacy single `signature` field and the
new `signatures` array.
@@ -302,6 +323,12 @@ Catalog provenance changes preflight severity:
plans, so operators can still use offline or emergency package refs
- valid-catalog warnings, such as intentionally unsigned local catalogs when
signature enforcement is disabled, remain warnings
- a saved catalog plan must match the currently validated entry exactly;
altered package refs, artifact identities, channel, sequence, trust state, or
signing-key identity block the run and require replanning
- a trusted remote artifact is downloaded before mutation into a private
SHA-256-addressed installer cache, checked for exact size and digest, and
passed to `pip` or npm only as that verified local file
- selected catalog entries with unsatisfied non-optional named interface ranges
block activation before the installer runs
- selected catalog entries whose target dependencies are neither installed nor
@@ -519,6 +546,11 @@ Catalog entries can require license features:
Core checks those requirements against an offline license file before allowing
the entry into the install plan.
Official open-source GovOPlaN entries do not declare license features. The
license contract remains generic for external catalogs, deployment presets,
configuration/package directories, and support offerings; it gates only an
entry that explicitly asks for a feature.
```bash
GOVOPLAN_LICENSE_FILE=/srv/govoplan/license.json
GOVOPLAN_LICENSE_ENFORCEMENT=true
+1 -1
View File
@@ -12,4 +12,4 @@ tools/checks/security-audit/run.sh --mode full --scope govoplan
Canonical documentation:
- `/mnt/DATA/git/govoplan/docs/SECURITY_AUDIT.md`
- `/mnt/DATA/git/govoplan/docs/operations/SECURITY_AUDIT.md`
+118
View File
@@ -0,0 +1,118 @@
# Semantic Documentation Subjects
## Purpose And Ownership
The semantic-documentation subject contract lets an optional module expose the
configured artifacts that administrators may document: for example a form, a
form field, a workflow, or a workflow state. It is a discovery and resolution
contract, not a second configuration API.
The module that owns an artifact also owns its subject provider, authorization,
identity, revision, route, and lifecycle semantics. Docs may discover those
providers through Core and attach authored documentation to their stable
references. Docs must not import the feature module, read its tables, or copy
configuration content into a generic index.
This contract is additive to manifest `DocumentationTopic` contributions and
configured-state `documentation_providers`. Every providing module must retain
static user and administrator documentation baselines. The baselines explain
the feature even when the provider is disabled, unavailable, or has no
configured subjects.
## Identity And Versioning
`SemanticDocumentationSubjectReference` identifies a subject with:
- owning module and tenant;
- a module-defined subject kind and stable identifier;
- an optional typed nested anchor, such as `field/registration-number`;
- the revision and canonical fingerprint observed when documentation was
authored or reviewed.
The `stable_key` derives only from identity. A rename or configuration revision
therefore does not detach existing documentation. A nested anchor has its own
identity so a field can be documented independently from its form.
Providers must resolve an old reference as one of:
- `available`: the observed revision/fingerprint is still current;
- `changed`: the same stable subject has changed and may need review;
- `superseded`: another stable reference replaced it;
- `missing`: the subject was removed or is no longer resolvable;
- `temporarily_unavailable`: the provider cannot currently determine state.
Absence is not authorization. A provider returns `None` when the principal may
not learn whether a subject exists. Core also rejects cross-tenant list and
resolution requests before calling a provider.
## Safe Projection
Descriptors contain only bounded, explicit presentation fields: localized
labels and descriptions, breadcrumbs, a local route, audience,
classification, and required scopes. They must not contain credentials,
personal data, arbitrary provider metadata, configuration payloads, or the
authored documentation itself. Routes are application-local and are still
subject to normal route authorization.
The fingerprint is a review signal, not a concurrency token or a content hash
that callers may use to reconstruct configuration. Providers should calculate
it from the smallest canonical JSON projection whose semantic changes require
documentation review. Volatile timestamps and secrets must be excluded.
## Provider Registration
A provider is registered under its exact module-scoped capability name:
```python
from govoplan_core.core.modules import CapabilityDocumentation
from govoplan_core.core.semantic_documentation import (
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
semantic_documentation_subject_capability,
)
capability = semantic_documentation_subject_capability("forms")
manifest = ModuleManifest(
id="forms",
# ...
capability_factories={capability: build_semantic_subject_provider},
capability_documentation={
capability: CapabilityDocumentation(
label="Form semantic subjects",
summary="Lists authorized configured forms and fields for Docs.",
contract_version=SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
documentation_types=("admin", "user"),
)
},
documentation=(admin_baseline, user_baseline),
)
```
The capability is `documentation.semantic_subjects.<module_id>`. Registry
validation rejects a mismatched owner, missing capability documentation, a
wrong contract version, or missing static baselines.
`list_semantic_documentation_subjects` performs authorized, paginated discovery
across installed providers. `resolve_semantic_documentation_subject` targets
one owner without loading another feature module. Providers must apply the
current tenant and principal on every call and must not infer visibility from a
previous list result.
## Lifecycle And Integration Rules
- Keep subject and anchor identifiers stable across display-name and route
changes.
- Return `superseded` only with the replacement reference; do not silently
rewrite stored references.
- Return a reason code for missing or temporarily unavailable subjects without
exposing sensitive detail.
- Reauthorize both discovery and resolution. Stored documentation references
confer no access to a live artifact.
- Treat a changed fingerprint as a request for editorial review. It does not
automatically invalidate or publish authored documentation.
- Removing a feature module leaves references resolvable as provider
unavailable. Docs can preserve history without importing the module.
Forms, Workflow, and later modules should implement their subject providers in
their own repositories. Docs owns the authored semantic-documentation records,
review workflow, and projection UI.
+10 -3
View File
@@ -93,10 +93,17 @@ Alembic, and post-migration tasks. The lock is session-scoped and therefore
released if the migration process dies.
Runtime roles run `govoplan_core.commands.wait_for_database`. It waits until
the database has exactly the configured Core/module Alembic heads and never
upgrades schema. This permits a migration Job and runtime Deployments to be
the database has exactly the configured, dependency-resolved Core/module
Alembic heads and never upgrades schema. Cross-module `depends_on` revisions
therefore do not leave runtime roles waiting for a branch marker Alembic has
correctly consumed. This permits a migration Job and runtime Deployments to be
submitted together while keeping startup fail-closed.
Runtime coordination records the installed `govoplan-core` distribution
version for API, worker and scheduler roles. FastAPI/OpenAPI metadata versions
are presentation metadata and must not be used as deployable software identity;
mixing the two would create a false version-skew readiness failure.
## Recovery Ledger
`govoplan_core.core.recovery` provides a durable operation and evidence
@@ -133,7 +140,7 @@ Core does not create production database backups. The deployment owner must
provide backup, retention, encryption, restore verification, and recovery-point
coordination for PostgreSQL, object storage, and encryption keys. The canonical
operator procedure is documented in
`govoplan/docs/RECOVERY_AND_ROLLBACK_GUARANTEES.md`.
`govoplan/docs/operations/RECOVERY_AND_ROLLBACK_GUARANTEES.md`.
## Verification
+68
View File
@@ -0,0 +1,68 @@
# Temporal Data Context
GovOPlaN exposes one read context for data validity and system knowledge. The
calendar control in the authenticated titlebar applies that context to
supported list and detail reads for the current account and tenant.
## Two Independent Axes
- **Valid time** answers when a fact applied in the represented domain.
- **Recorded time** answers what the system had recorded by a particular
instant.
The default is data valid now under the latest recorded state. `At time`
selects a valid-time instant. `All` removes the valid-time interval filter but
still uses the selected recorded state. The optional recorded-state cutoff can
be combined with any valid-time mode, which keeps correction history distinct
from changes in real-world validity.
An interval is half open: `valid_from <= instant < valid_to`. A revision belongs
to a recorded-state snapshot when `recorded_at <= cutoff` and it was not
superseded at or before that cutoff.
## Security And Mutation Rules
The temporal data context is a read projection, not an authorization context.
Authentication, permissions, active delegations, tenant boundaries, module
policy, and maintenance controls are always evaluated under current security
state. A historical projection never restores an expired permission.
The context also does not supply mutation dates. Writes continue to target the
current lifecycle revision and must carry their explicit valid/effective dates,
expected revision, reason, and evidence where the owning contract requires
them. A screen showing historical data must not silently turn a normal edit
into a historical correction.
## HTTP Contract
Core accepts these request headers:
| Header | Meaning |
| --- | --- |
| `X-Govoplan-Validity-Mode` | `current`, `at`, or `all` |
| `X-Govoplan-Valid-At` | Timezone-aware ISO 8601 instant required by `at` |
| `X-Govoplan-Recorded-At` | Optional timezone-aware system-knowledge cutoff |
Invalid or naive timestamps fail with HTTP 400. Responses expose the resolved
mode and evaluated instant. Conditional JSON responses vary by all three
request headers, and the shared WebUI API client includes them in request
deduplication and conditional-cache keys.
## Module Adoption
Revision-owning modules apply
`govoplan_core.db.temporal.apply_temporal_revision_filter` only to read queries
that are meant to follow the platform context. Explicit version references and
explicit resolver `effective_at` arguments take precedence. Current-row
lookups used for optimistic concurrency, authorization, routing, effects, or
other mutations must remain explicit and context-independent.
The initial bitemporal adoption covers Decisions, Mandates, Parties, and
Services. Their immutable revisions have indexed valid, recorded, and
superseded timestamps. Modules with effective-dated security records or
recorded-only revision histories require separate display-query adoption so
the global selector cannot affect current authorization or execution.
The WebUI selection is stored in session storage per account and tenant. A
change remounts the active module route so existing page loaders issue a fresh
request. Returning both axes to their defaults removes the stored selection.
+34 -6
View File
@@ -4,6 +4,9 @@ GovOPlaN supports `system`, `light`, and `dark` as persisted user preferences.
`system` follows `prefers-color-scheme` live; it is not resolved permanently at
save time. Core applies the resolved mode through `data-theme` on the document
root and exposes the selected preference through `data-theme-preference`.
Each user may also choose a validated `default`, `civic_blue`, `forest`, or
`plum` accent palette. Core applies it through `data-palette`; every module
inherits the result through semantic tokens without module-specific CSS.
## Ownership
@@ -12,17 +15,42 @@ root and exposes the selected preference through `data-theme-preference`.
- Modules consume semantic tokens such as `--surface`, `--text`, `--line`, and
the status token families. They may define domain aliases whose values resolve
to shared tokens.
- User preference selects the mode. Tenant and system policy may provide a
future default, but must not silently replace an explicit user choice.
- Tenant branding is a separate policy surface and must preserve contrast and
status semantics in both modes.
- Palette defaults form a provenance chain: system, tenant, then an explicit
user choice. Invalid stored values are ignored. Reset means inheritance and
does not copy the current parent value into the child scope.
- A policy lock is separate from the default. A system lock wins over every
child scope; otherwise a tenant lock suppresses a personal override. The
authenticated profile reports the effective palette, source, inherited
palette, and lock state.
- Advanced personal overrides are a separately governed surface. The system
must opt in, a tenant may inherit or block that decision, and palette locks
always suppress overrides. Changing either policy requires
`admin:policies:write` in addition to the owning settings permission.
## Palette safety and scope
The Settings preview shows the chosen or inherited accent in every applicable
light/dark preview before Save. Presets are checked for WCAG AA contrast in the
theme contract. When policy permits, the shared advanced editor can atomically
override accent, surface, and semantic status pairs for both modes. Every
foreground/background pair must meet WCAG AA contrast, and success,
information, warning, and danger colors must remain distinct. Invalid stored
documents fail closed and are not partially applied.
Import and export use the exact versioned JSON schema `schema_version: "1"`.
Both `light` and `dark` must contain every supported token exactly once as a
six-digit hex value. Import changes only the local draft; Save persists the
whole document. Removing overrides returns to palette and policy inheritance.
The system default is disabled so upgrades do not unexpectedly admit arbitrary
branding. Tenant `null` means inherit, `false` blocks, and `true` is accepted
only while the system permits overrides.
Do not introduce fixed foreground/background colors in a module merely to make
one mode look correct. Add or reuse a semantic Core token, then define both
light and dark values. Bitmap content and externally authored HTML are exempt,
but their surrounding controls must still use the shared tokens.
`npm run test:theme-contract` verifies the root behavior and representative
`npm run test:theme-contract` verifies root mode/palette behavior, preset and
custom-override validation/application, and representative
Campaign, Calendar, Files, and Mail token consumption. The check runs before a
production WebUI build.
+14 -5
View File
@@ -51,10 +51,14 @@ contestability, responsibility, and traceability at the point of action.
| 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 |
| UX-027 | The platform icon rail keeps its brand header and utility footer visible. Only the module-navigation region scrolls when installed and permitted modules exceed the available viewport height. | Accepted | Core WebUI shell |
| UX-028 | Maintenance and offline state change the titlebar surface and repeat a quiet status label behind its controls. They must not replace, cover, or intercept the centered global-search surface; an accessible status control remains in the leading titlebar area. | Accepted | Core WebUI shell |
| UX-028 | Maintenance and offline states use their established textual warning banners centered in the titlebar. They must not recolor the shell or add decorative status icons. Global search is a right-side command, so warnings do not replace its trigger or overlay. | Accepted | Core WebUI shell |
| UX-029 | Recoverable page and module errors use the central compact `DismissibleAlert` presentation with an explicit recovery action where one exists. Full-height workspaces must overlay page feedback instead of allowing an alert to become a stretched workspace row. | Accepted | Core and module WebUIs |
| UX-030 | At narrow widths, the titlebar uses separate context and command rows. Context selectors remain horizontally reachable, search retains its compact trigger, and language/help/notification/account commands remain fixed icon controls without overlap. Shared content padding contracts so domain workspaces retain usable width. | Accepted | Core WebUI shell and all module workspaces |
| UX-031 | Public controls and extension contributions use stable, module-namespaced interface identities. Shared controls expose `interfaceId` and `helpTopicId`; generated source anchors are inventory evidence, not a substitute for an explicit ID when documentation, policy, or automation refers to the control. | Accepted | Core and module WebUIs |
| UX-032 | `F1` resolves help from the focused field or action, then its dialog/section/page and registered route. Focused contexts retain the page fallback; Docs applies audience and permission filtering and falls back to visible module documentation. | Accepted | Core shell, Docs, and all module WebUIs |
| UX-033 | Global search is the left-most titlebar command, immediately before language selection. Its icon, `F3`, and `Ctrl`/`Cmd`+`K` all open the same permission-aware search overlay; the titlebar does not reserve a persistent query field. | Accepted | Core shell and Search WebUI |
| UX-034 | Every headed `PageLayout` declares one of `overview`, `collection`, `detail`, `editor`, or `workspace` independently from its standalone/workspace/embedded geometry. Its actions use the matching semantic `PageActionBar`: a refreshable page must provide Reload in the leading slot; collections keep Create far right; read-only pages do not invent Save. | Accepted | Core and all module WebUIs |
| UX-035 | Editor action bars expose clean, dirty, and saving state; always retain Discard immediately before the far-right Save; centrally disable both while clean or saving; and participate in the unsaved-change navigation guard. Danger actions occupy the explicit separated destructive group after ordinary actions and before editor persistence. | Accepted | Core and all module WebUIs |
## Confirmed Implementation Decisions
@@ -161,14 +165,19 @@ 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 applies validated user accent presets through `data-palette`; palette
values change semantic tokens globally and never require module CSS changes.
- 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.
- Appearance controls live in user settings. A personal palette wins over
unlocked tenant and system defaults; system and tenant locks take precedence.
Advanced personal token overrides additionally require system opt-in and may
be narrowed by tenant policy. Their versioned import/export document is
validated and applied all-or-nothing in both light and dark modes.
- Visual preview in settings is illustrative; it must reflect token families,
not become a second theme implementation.
@@ -319,7 +328,7 @@ converted or reviewed.
| Configuration packages | `govoplan-admin` | Catalog/import work exists, but package editing can still drift toward technical fields. | Add guided import/review/problem-list flow. |
| Retention and privacy | `govoplan-core` | Typed effective-policy editor exposes source paths, narrowing semantics, platform locks, permission/target blockers, and explicit clean/loading/save states. | Broader governed-change review remains module-owned where a policy change requires approval. |
| API keys | `govoplan-access` / admin UI | Security-sensitive creation needs least-privilege guidance. | Add scoped creation wizard with expiry/owner review. |
| User settings | `govoplan-core` | Simple typed sections use unsaved-change guards, quiet result feedback, contextual help, and explicit busy/clean disabled-action reasons. | Keep bounded; new contributed sections must satisfy the checklist. |
| User settings | `govoplan-core` | Simple typed sections use unsaved-change guards, quiet result feedback, contextual help, explicit busy/clean disabled-action reasons, and an effective appearance source. Palette selection and light/dark preview are shared with system and tenant administration. | Keep bounded; new contributed sections must satisfy the checklist. |
## Impact Index
@@ -335,7 +344,7 @@ converted or reviewed.
| Automation/workflow commands | Hidden side effects would undermine accountability. | Action/effect preview, system-actor display, command record, retry/quarantine/manual states, and audit links. |
| Postbox and encrypted communication | Retraction and access can be misunderstood. | Honest key-fetch/decryption state, expiry limits, recipient/device access provenance, and delivery evidence. |
| API keys | Security-sensitive creation and scope selection. | Scoped creation wizard, least-privilege suggestions, clear expiry/owner explanation. |
| User settings | Needs clarity and persistence across profile/interface/preferences. | Simple settings sections with immediate feedback and no double-click navigation traps. |
| User settings | Needs clarity and persistence across profile/interface/preferences. | Simple settings sections with immediate feedback, explicit inherit/reset semantics, effective-source provenance, and no double-click navigation traps. |
## Review Checklist
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-core"
version = "0.1.15"
version = "0.1.25"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md"
requires-python = ">=3.12"
@@ -15,7 +15,7 @@ dependencies = [
"fastapi>=0.139,<1",
"pydantic>=2,<3",
"pydantic-settings>=2,<3",
"cryptography>=48.0.1,<50",
"cryptography>=50.0.0,<51",
"celery>=5,<6",
"redis>=5,<6",
"alembic>=1,<2",
@@ -26,7 +26,7 @@ dependencies = [
where = ["src"]
[tool.setuptools.package-data]
govoplan_core = ["py.typed"]
govoplan_core = ["py.typed", "resources/*.json"]
[tool.setuptools.data-files]
"govoplan_core_runtime" = ["alembic.ini"]
+1 -2
View File
@@ -12,7 +12,7 @@ class SystemSettings(Base, TimestampMixin):
__tablename__ = "core_system_settings"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default="global")
default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False)
default_locale: Mapped[str] = mapped_column(String(20), default="de", nullable=False)
allow_tenant_custom_groups: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
allow_tenant_custom_roles: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
allow_tenant_api_keys: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
@@ -20,4 +20,3 @@ class SystemSettings(Base, TimestampMixin):
__all__ = ["SystemSettings"]
+61 -5
View File
@@ -3,7 +3,9 @@ from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
from govoplan_core.core.appearance import normalize_appearance_overrides
class AuditLogItemResponse(BaseModel):
@@ -84,7 +86,7 @@ class TenantInfo(BaseModel):
slug: str
name: str
is_active: bool = True
default_locale: str = "en"
default_locale: str = "de"
enabled_language_codes: list[str] = Field(default_factory=list)
@@ -93,6 +95,45 @@ class TenantMembershipInfo(TenantInfo):
is_active: bool = True
class NavigationPreferencesPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
contract_version: Literal["1"] = "1"
order: list[str] = Field(default_factory=list, max_length=256)
hidden: list[str] = Field(default_factory=list, max_length=256)
locked: list[str] = Field(default_factory=list, max_length=256)
class AppearanceModeOverrides(BaseModel):
model_config = ConfigDict(extra="forbid")
accent: str
accent_foreground: str
surface: str
surface_foreground: str
success: str
success_foreground: str
info: str
info_foreground: str
warning: str
warning_foreground: str
danger: str
danger_foreground: str
class AppearanceOverridesDocument(BaseModel):
model_config = ConfigDict(extra="forbid")
schema_version: Literal["1"] = "1"
light: AppearanceModeOverrides
dark: AppearanceModeOverrides
@model_validator(mode="after")
def validate_accessibility(self) -> "AppearanceOverridesDocument":
normalize_appearance_overrides(self.model_dump(mode="json"))
return self
class UserUiPreferences(BaseModel):
model_config = ConfigDict(extra="ignore")
@@ -101,6 +142,20 @@ class UserUiPreferences(BaseModel):
reduce_motion: bool = False
sticky_section_sidebars: bool = True
theme: Literal["system", "light", "dark"] = "system"
palette: Literal["default", "civic_blue", "forest", "plum"] | None = None
appearance_overrides: AppearanceOverridesDocument | None = None
navigation: NavigationPreferencesPayload | None = None
class EffectiveAppearanceInfo(BaseModel):
palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
source: Literal["user", "tenant", "system", "tenant_lock", "system_lock"] = "system"
locked: bool = False
system_default_palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
tenant_default_palette: Literal["default", "civic_blue", "forest", "plum"] | None = None
inherited_palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
custom_overrides: AppearanceOverridesDocument | None = None
custom_overrides_allowed: bool = False
class UserInfo(BaseModel):
@@ -116,6 +171,7 @@ class UserInfo(BaseModel):
preferred_language: str | None = None
enabled_language_codes: list[str] = Field(default_factory=list)
ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences)
appearance: EffectiveAppearanceInfo = Field(default_factory=EffectiveAppearanceInfo)
class AuthSessionUserInfo(BaseModel):
@@ -210,7 +266,7 @@ class AuthProfileResponse(BaseModel):
active_tenant: TenantInfo
available_languages: list[LanguageInfo] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list)
default_language: str = "en"
default_language: str = "de"
profile_loaded: bool = True
@@ -239,7 +295,7 @@ class LoginResponse(BaseModel):
principal: PrincipalContextInfo | None = None
available_languages: list[LanguageInfo] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list)
default_language: str = "en"
default_language: str = "de"
profile_loaded: bool = True
roles_loaded: bool = True
groups_loaded: bool = True
@@ -257,7 +313,7 @@ class MeResponse(BaseModel):
principal: PrincipalContextInfo | None = None
available_languages: list[LanguageInfo] = Field(default_factory=list)
enabled_language_codes: list[str] = Field(default_factory=list)
default_language: str = "en"
default_language: str = "de"
profile_loaded: bool = True
roles_loaded: bool = True
groups_loaded: bool = True
+103 -8
View File
@@ -18,7 +18,9 @@ from celery.signals import (
from govoplan_core.core.campaigns import (
CAPABILITY_CAMPAIGNS_DELIVERY_TASKS,
CAPABILITY_CAMPAIGNS_SCHEDULES,
CampaignDeliveryTaskProvider,
CampaignScheduleProvider,
)
from govoplan_core.core.calendar import (
CAPABILITY_CALENDAR_OUTBOX,
@@ -100,6 +102,7 @@ celery.conf.update(
task_routes={
"govoplan.campaigns.send_email": {"queue": "send_email"},
"govoplan.campaigns.append_sent": {"queue": "append_sent"},
"govoplan.campaigns.dispatch_schedules": {"queue": "default"},
"govoplan.notifications.deliver": {"queue": "notifications"},
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
"govoplan.mail.dispatch_outbox": {"queue": "mail"},
@@ -132,6 +135,11 @@ celery.conf.update(
"schedule": 60.0,
"args": (None, 100),
},
"campaign-schedules-every-minute": {
"task": "govoplan.campaigns.dispatch_schedules",
"schedule": 60.0,
"args": (None, 50),
},
"mail-outbox-every-five-seconds": {
"task": "govoplan.mail.dispatch_outbox",
"schedule": 5.0,
@@ -555,6 +563,18 @@ def _campaign_delivery_tasks(
return capability
def _campaign_schedules(
registry: PlatformRegistry | None = None,
) -> CampaignScheduleProvider | None:
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_CAMPAIGNS_SCHEDULES):
return None
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_SCHEDULES)
if not isinstance(capability, CampaignScheduleProvider):
raise RuntimeError("Campaign schedule capability is invalid")
return capability
def _notification_dispatch(
registry: PlatformRegistry | None = None,
) -> NotificationDispatchProvider:
@@ -699,6 +719,62 @@ def _idm_assignment_lifecycle(
return capability
@celery.task(
name="govoplan.campaigns.dispatch_schedules",
bind=True,
max_retries=0,
)
def dispatch_campaign_schedules(
self,
tenant_id: str | None = None,
limit: int = 50,
):
"""Prepare manual drafts or governed autonomous Mail commands for due schedules."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
registry = _platform_registry()
defaults = {
"selected": 0,
"prepared": 0,
"autonomous_prepared": 0,
"failed": 0,
"completed": 0,
"coalesced": 0,
"duplicates": 0,
"deferred": 0,
"campaign_ids": [],
"operator_actions": [],
"refreshed": {
"checked": 0,
"accepted": 0,
"uncertain": 0,
"failed": 0,
"skipped": 0,
},
}
if not registry.has_capability(CAPABILITY_CAMPAIGNS_SCHEDULES):
return defaults
result = _run_tenant_worker_batches(
registry,
session,
capability_name=CAPABILITY_CAMPAIGNS_SCHEDULES,
tenant_id=tenant_id,
operation=lambda effective_tenant_id: _campaign_schedules(
registry
).dispatch_due( # type: ignore[union-attr]
session,
tenant_id=effective_tenant_id,
limit=limit,
),
defaults=defaults,
work_state="new",
)
session.commit()
return result
@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.
@@ -1122,7 +1198,7 @@ def dispatch_postbox_routes(
tenant_id: str | None = None,
limit: int = 50,
):
"""Deliver due Postbox vacancy escalations from durable route rows."""
"""Deliver due routes and reconcile assignment-derived notification facts."""
from govoplan_core.db.session import get_database
@@ -1136,21 +1212,40 @@ def dispatch_postbox_routes(
"cancelled": 0,
"failed": 0,
"route_ids": [],
"lifecycle_scanned": 0,
"lifecycle_changed": 0,
"lifecycle_events": 0,
"lifecycle_notifications": 0,
"lifecycle_notification_failures": 0,
}
if not registry.has_capability(CAPABILITY_POSTBOX_ROUTING):
return defaults
provider = _postbox_routing_provider(registry)
def dispatch_tenant(effective_tenant_id: str) -> Mapping[str, object]:
route_result = provider.dispatch_due_routes( # type: ignore[union-attr]
session,
tenant_id=effective_tenant_id,
limit=limit,
)
lifecycle_result = provider.reconcile_notification_lifecycle( # type: ignore[union-attr]
session,
tenant_id=effective_tenant_id,
limit=limit,
)
return {
**route_result,
**{
f"lifecycle_{key}": value for key, value in lifecycle_result.items()
},
}
result = _run_tenant_worker_batches(
registry,
session,
capability_name=CAPABILITY_POSTBOX_ROUTING,
tenant_id=tenant_id,
operation=lambda effective_tenant_id: _postbox_routing_provider(
registry
).dispatch_due_routes( # type: ignore[union-attr]
session,
tenant_id=effective_tenant_id,
limit=limit,
),
operation=dispatch_tenant,
defaults=defaults,
)
session.commit()
+114
View File
@@ -24,6 +24,10 @@ CAPABILITY_ACCESS_TENANT_PROVISIONER = "access.tenantProvisioner"
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER = "access.firstAdminProvisioner"
CAPABILITY_ACCESS_ADMINISTRATION = "access.administration"
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER = "access.governanceMaterializer"
CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1 = "access.governanceProjection.v1"
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS = (
"policy.access_explanation_subjects"
)
CAPABILITY_TENANCY_TENANT_RESOLVER = "tenancy.tenantResolver"
CAPABILITY_AUDIT_SINK = "audit.sink"
CAPABILITY_AUDIT_RECORDER = "audit.recorder"
@@ -49,6 +53,7 @@ ACCESS_CAPABILITY_NAMES = frozenset(
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER,
CAPABILITY_ACCESS_ADMINISTRATION,
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1,
CAPABILITY_TENANCY_TENANT_RESOLVER,
CAPABILITY_AUDIT_SINK,
CAPABILITY_AUDIT_RECORDER,
@@ -177,6 +182,15 @@ class PrincipalRef:
)
@dataclass(frozen=True, slots=True)
class AccessExplanationSubjectDecision:
allow_other_users: bool
reason: str
source: str
required_scope: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
def _optional_str(value: object | None) -> str | None:
return str(value) if value is not None else None
@@ -378,6 +392,82 @@ class GovernanceTemplateMaterialization:
required: bool = False
GovernanceProjectionOperation = Literal["upsert", "remove"]
GovernanceProjectionStatus = Literal[
"created",
"updated",
"unchanged",
"removed",
"absent",
"blocked",
"failed",
]
@dataclass(frozen=True, slots=True)
class GovernanceProjectionCommand:
"""Stable Access-owned input for one governance assignment projection."""
assignment_id: str
operation: GovernanceProjectionOperation
template: GovernanceTemplateMaterialization
provenance: Mapping[str, str] = field(default_factory=dict)
def __post_init__(self) -> None:
if not self.assignment_id or len(self.assignment_id) > 255:
raise ValueError("Governance projection assignment ids must contain at most 255 characters.")
if len(self.provenance) > 20:
raise ValueError("Governance projection provenance supports at most 20 entries.")
for key, value in self.provenance.items():
if not key or len(key) > 100 or len(value) > 500:
raise ValueError("Governance projection provenance entries exceed their bounds.")
@dataclass(frozen=True, slots=True)
class GovernanceProjectionBatch:
"""Versioned, bounded reconciliation request independent of Admin internals."""
operation_id: str
commands: tuple[GovernanceProjectionCommand, ...]
version: Literal["1"] = "1"
dry_run: bool = False
def __post_init__(self) -> None:
if not self.operation_id or len(self.operation_id) > 255:
raise ValueError("Governance projection operation ids must contain at most 255 characters.")
if not self.commands or len(self.commands) > 500:
raise ValueError("Governance projection batches must contain between 1 and 500 commands.")
assignment_ids = [command.assignment_id for command in self.commands]
if len(assignment_ids) != len(set(assignment_ids)):
raise ValueError("Governance projection assignment ids must be unique within a batch.")
@dataclass(frozen=True, slots=True)
class GovernanceProjectionOutcome:
assignment_id: str
template_id: str
tenant_id: str
kind: Literal["group", "role"]
operation: GovernanceProjectionOperation
status: GovernanceProjectionStatus
resource_id: str | None = None
blocker_codes: tuple[str, ...] = ()
message: str | None = None
provenance: Mapping[str, str] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class GovernanceProjectionResult:
operation_id: str
outcomes: tuple[GovernanceProjectionOutcome, ...]
version: Literal["1"] = "1"
dry_run: bool = False
@property
def blocked(self) -> tuple[GovernanceProjectionOutcome, ...]:
return tuple(item for item in self.outcomes if item.status in {"blocked", "failed"})
@dataclass(frozen=True, slots=True)
class AuditEvent:
event_type: str
@@ -564,6 +654,18 @@ class AccessExplanationService(Protocol):
...
@runtime_checkable
class AccessExplanationSubjectPolicy(Protocol):
def decide_subject_selection(
self,
session: object,
principal: PrincipalRef,
*,
tenant_id: str,
) -> AccessExplanationSubjectDecision:
...
@runtime_checkable
class TenantAccessProvisioner(Protocol):
def ensure_default_roles(self, session: object, tenant: object | None = None) -> Mapping[str, object]:
@@ -669,6 +771,18 @@ class AccessGovernanceMaterializer(Protocol):
...
@runtime_checkable
class AccessGovernanceProjectionV1(Protocol):
"""Bulk reconciliation boundary for Admin-owned governance assignments."""
def reconcile(
self,
session: object,
batch: GovernanceProjectionBatch,
) -> GovernanceProjectionResult:
...
@runtime_checkable
class AuditSink(Protocol):
def record(self, event: AuditEvent) -> None:
+223
View File
@@ -0,0 +1,223 @@
from __future__ import annotations
from dataclasses import dataclass
import re
from typing import Any, Literal, Mapping
AppearancePalette = Literal["default", "civic_blue", "forest", "plum"]
AppearanceSource = Literal["user", "tenant", "system", "tenant_lock", "system_lock"]
APPEARANCE_PALETTES: tuple[AppearancePalette, ...] = ("default", "civic_blue", "forest", "plum")
APPEARANCE_SETTINGS_KEY = "appearance"
APPEARANCE_OVERRIDE_SCHEMA_VERSION = "1"
APPEARANCE_OVERRIDE_TOKENS: tuple[str, ...] = (
"accent", "accent_foreground", "surface", "surface_foreground",
"success", "success_foreground", "info", "info_foreground",
"warning", "warning_foreground", "danger", "danger_foreground",
)
_STATUS_TOKENS = ("success", "info", "warning", "danger")
_HEX_COLOR = re.compile(r"^#[0-9a-fA-F]{6}$")
@dataclass(frozen=True, slots=True)
class EffectiveAppearance:
palette: AppearancePalette
source: AppearanceSource
locked: bool
system_default_palette: AppearancePalette
tenant_default_palette: AppearancePalette | None
inherited_palette: AppearancePalette
custom_overrides: dict[str, object] | None = None
custom_overrides_allowed: bool = False
def as_dict(self) -> dict[str, object]:
return {
"palette": self.palette,
"source": self.source,
"locked": self.locked,
"system_default_palette": self.system_default_palette,
"tenant_default_palette": self.tenant_default_palette,
"inherited_palette": self.inherited_palette,
"custom_overrides": self.custom_overrides,
"custom_overrides_allowed": self.custom_overrides_allowed,
}
def normalize_appearance_palette(value: object, *, fallback: AppearancePalette | None = None) -> AppearancePalette | None:
normalized = str(value or "").strip().lower()
return normalized if normalized in APPEARANCE_PALETTES else fallback # type: ignore[return-value]
def appearance_settings(settings: Mapping[str, Any] | None) -> tuple[AppearancePalette | None, bool]:
raw = settings.get(APPEARANCE_SETTINGS_KEY) if isinstance(settings, Mapping) else None
if not isinstance(raw, Mapping):
return None, False
return normalize_appearance_palette(raw.get("default_palette")), raw.get("palette_locked") is True
def appearance_custom_overrides_policy(settings: Mapping[str, Any] | None) -> bool | None:
raw = settings.get(APPEARANCE_SETTINGS_KEY) if isinstance(settings, Mapping) else None
if not isinstance(raw, Mapping) or "allow_custom_overrides" not in raw:
return None
return raw.get("allow_custom_overrides") is True
def update_appearance_custom_overrides_policy(
settings: Mapping[str, Any] | None,
*,
allowed: bool | None,
) -> dict[str, Any]:
updated = dict(settings or {})
appearance = dict(updated.get(APPEARANCE_SETTINGS_KEY) or {}) if isinstance(updated.get(APPEARANCE_SETTINGS_KEY), Mapping) else {}
if allowed is None:
appearance.pop("allow_custom_overrides", None)
else:
appearance["allow_custom_overrides"] = allowed
if appearance:
updated[APPEARANCE_SETTINGS_KEY] = appearance
else:
updated.pop(APPEARANCE_SETTINGS_KEY, None)
return updated
def normalize_appearance_overrides(value: object) -> dict[str, object] | None:
"""Validate and canonicalize the versioned, all-or-nothing color contract."""
if value is None:
return None
if not isinstance(value, Mapping):
raise ValueError("Appearance overrides must be an object.")
if set(value) != {"schema_version", "light", "dark"}:
raise ValueError("Appearance overrides must contain only schema_version, light, and dark.")
if str(value.get("schema_version")) != APPEARANCE_OVERRIDE_SCHEMA_VERSION:
raise ValueError("Unsupported appearance override schema version.")
normalized: dict[str, object] = {"schema_version": APPEARANCE_OVERRIDE_SCHEMA_VERSION}
for mode in ("light", "dark"):
raw_mode = value.get(mode)
if not isinstance(raw_mode, Mapping) or set(raw_mode) != set(APPEARANCE_OVERRIDE_TOKENS):
raise ValueError(f"Appearance override mode {mode} must define every supported token exactly once.")
colors: dict[str, str] = {}
for token in APPEARANCE_OVERRIDE_TOKENS:
color = str(raw_mode.get(token) or "").strip().lower()
if not _HEX_COLOR.fullmatch(color):
raise ValueError(f"Appearance override {mode}.{token} must be a six-digit hexadecimal color.")
colors[token] = color
_validate_mode_accessibility(mode, colors)
normalized[mode] = colors
return normalized
def _validate_mode_accessibility(mode: str, colors: Mapping[str, str]) -> None:
pairs = (
("accent", "accent_foreground"), ("surface", "surface_foreground"),
("success", "success_foreground"), ("info", "info_foreground"),
("warning", "warning_foreground"), ("danger", "danger_foreground"),
)
for background, foreground in pairs:
if _contrast_ratio(colors[background], colors[foreground]) < 4.5:
raise ValueError(f"Appearance override {mode}.{foreground} must have WCAG AA contrast against {mode}.{background}.")
status_colors = [colors[token] for token in _STATUS_TOKENS]
for index, first in enumerate(status_colors):
for second in status_colors[index + 1:]:
if _rgb_distance(first, second) < 12:
raise ValueError(f"Appearance override status colors in {mode} must remain visibly distinct.")
def _relative_luminance(color: str) -> float:
channels = [int(color[index:index + 2], 16) / 255 for index in (1, 3, 5)]
linear = [channel / 12.92 if channel <= 0.04045 else ((channel + 0.055) / 1.055) ** 2.4 for channel in channels]
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
def _contrast_ratio(first: str, second: str) -> float:
high, low = sorted((_relative_luminance(first), _relative_luminance(second)), reverse=True)
return (high + 0.05) / (low + 0.05)
def _rgb_distance(first: str, second: str) -> float:
first_channels = [int(first[index:index + 2], 16) for index in (1, 3, 5)]
second_channels = [int(second[index:index + 2], 16) for index in (1, 3, 5)]
return sum((left - right) ** 2 for left, right in zip(first_channels, second_channels, strict=True)) ** 0.5
def update_appearance_settings(
settings: Mapping[str, Any] | None,
*,
default_palette: AppearancePalette | None,
palette_locked: bool,
) -> dict[str, Any]:
updated = dict(settings or {})
appearance = dict(updated.get(APPEARANCE_SETTINGS_KEY) or {}) if isinstance(updated.get(APPEARANCE_SETTINGS_KEY), Mapping) else {}
if default_palette is None:
appearance.pop("default_palette", None)
else:
normalized = normalize_appearance_palette(default_palette)
if normalized is None:
raise ValueError("Unsupported appearance palette.")
appearance["default_palette"] = normalized
if palette_locked:
appearance["palette_locked"] = True
else:
appearance.pop("palette_locked", None)
if appearance:
updated[APPEARANCE_SETTINGS_KEY] = appearance
else:
updated.pop(APPEARANCE_SETTINGS_KEY, None)
return updated
def resolve_effective_appearance(
*,
system_settings: Mapping[str, Any] | None,
tenant_settings: Mapping[str, Any] | None,
user_settings: Mapping[str, Any] | None,
) -> EffectiveAppearance:
system_palette, system_locked = appearance_settings(system_settings)
system_palette = system_palette or "default"
tenant_palette, tenant_locked = appearance_settings(tenant_settings)
inherited_palette = tenant_palette or system_palette
raw_ui = user_settings.get("ui") if isinstance(user_settings, Mapping) else None
user_palette = normalize_appearance_palette(raw_ui.get("palette")) if isinstance(raw_ui, Mapping) else None
system_custom_policy = appearance_custom_overrides_policy(system_settings) is True
tenant_custom_policy = appearance_custom_overrides_policy(tenant_settings)
custom_overrides_allowed = system_custom_policy and tenant_custom_policy is not False and not system_locked and not tenant_locked
try:
custom_overrides = normalize_appearance_overrides(raw_ui.get("appearance_overrides")) if isinstance(raw_ui, Mapping) else None
except ValueError:
custom_overrides = None
if not custom_overrides_allowed:
custom_overrides = None
if system_locked:
return EffectiveAppearance(system_palette, "system_lock", True, system_palette, tenant_palette, system_palette)
if tenant_locked:
return EffectiveAppearance(inherited_palette, "tenant_lock", True, system_palette, tenant_palette, inherited_palette)
return EffectiveAppearance(
user_palette or inherited_palette,
"user" if user_palette else "tenant" if tenant_palette else "system",
False,
system_palette,
tenant_palette,
inherited_palette,
custom_overrides,
custom_overrides_allowed,
)
__all__ = [
"APPEARANCE_PALETTES",
"APPEARANCE_SETTINGS_KEY",
"APPEARANCE_OVERRIDE_SCHEMA_VERSION",
"APPEARANCE_OVERRIDE_TOKENS",
"AppearancePalette",
"AppearanceSource",
"EffectiveAppearance",
"appearance_settings",
"appearance_custom_overrides_policy",
"normalize_appearance_overrides",
"normalize_appearance_palette",
"resolve_effective_appearance",
"update_appearance_settings",
"update_appearance_custom_overrides_policy",
]
@@ -0,0 +1,81 @@
from __future__ import annotations
from collections.abc import Mapping
from datetime import datetime
from typing import Protocol, runtime_checkable
CAPABILITY_APPLICATION_STATUS_PROJECTION = "application_status.projection"
@runtime_checkable
class ApplicationStatusProjectionProvider(Protocol):
"""Bounded applicant-status access without exposing the owning module's data."""
def tenant_id_for_tracking_id(
self,
session: object,
*,
tracking_id: str,
) -> str | None:
...
def public_access_challenge(
self,
session: object,
*,
tracking_id: str,
) -> Mapping[str, object]:
...
def get_authenticated_projection(
self,
session: object,
principal: object,
*,
tracking_id: str,
observed_at: datetime,
) -> Mapping[str, object]:
...
def get_public_projection(
self,
session: object,
*,
tracking_id: str,
token: str | None,
observed_at: datetime,
) -> Mapping[str, object]:
...
def request_email_link(
self,
session: object,
*,
tracking_id: str,
email: str,
requested_at: datetime,
) -> bool:
...
def application_status_projection_provider(
registry: object | None,
) -> ApplicationStatusProjectionProvider | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_APPLICATION_STATUS_PROJECTION):
return None
capability = registry.capability(CAPABILITY_APPLICATION_STATUS_PROJECTION)
return (
capability
if isinstance(capability, ApplicationStatusProjectionProvider)
else None
)
__all__ = [
"ApplicationStatusProjectionProvider",
"CAPABILITY_APPLICATION_STATUS_PROJECTION",
"application_status_projection_provider",
]
+30
View File
@@ -45,6 +45,15 @@ class CalendarEventRef:
outbox_operation_id: str | None = None
@dataclass(frozen=True, slots=True)
class CalendarEventReleaseRef:
event_id: str
accepted: bool = True
already_released: bool = False
external_state: str = "local_released"
outbox_operation_id: str | None = None
@dataclass(frozen=True, slots=True)
class CalendarInvitationAttendeeRequest:
address: str
@@ -156,6 +165,27 @@ class CalendarSchedulingProvider(Protocol):
) -> CalendarEventRef:
...
def promote_event(
self,
session: object,
*,
tenant_id: str,
user_id: str | None,
event_id: str,
request: CalendarEventRequest,
) -> CalendarEventRef:
...
def release_event(
self,
session: object,
*,
tenant_id: str,
user_id: str | None,
event_id: str,
) -> CalendarEventReleaseRef:
...
@runtime_checkable
class CalendarOutboxProvider(Protocol):
+16
View File
@@ -10,6 +10,7 @@ CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT = "campaigns.mailPolicyContext"
CAPABILITY_CAMPAIGNS_ACCESS = "campaigns.access"
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT = "campaigns.policyContext"
CAPABILITY_CAMPAIGNS_DELIVERY_TASKS = "campaigns.deliveryTasks"
CAPABILITY_CAMPAIGNS_SCHEDULES = "campaigns.schedules"
CAPABILITY_CAMPAIGNS_RETENTION = "campaigns.retention"
@@ -105,6 +106,21 @@ class CampaignDeliveryTaskProvider(Protocol):
...
@runtime_checkable
class CampaignScheduleProvider(Protocol):
"""Durable boundary for due manual drafts and governed autonomous occurrences."""
def dispatch_due(
self,
session: object,
*,
tenant_id: str | None = None,
now: datetime | None = None,
limit: int = 50,
) -> Mapping[str, object]:
...
@runtime_checkable
class CampaignRetentionProvider(Protocol):
def apply_retention(
@@ -28,6 +28,9 @@ from govoplan_core.core.external_references import (
SourceAuthorityMode,
integration_maturity_rank,
)
from govoplan_core.core.infrastructure_capabilities import (
InfrastructureCapabilityReceipt,
)
from govoplan_core.security.http_fetch import fetch_http_text
@@ -441,6 +444,9 @@ class ConfigurationPreflightContext:
default_factory=dict
)
dry_run: bool = True
operator_scopes: frozenset[str] = frozenset()
infrastructure_receipt: InfrastructureCapabilityReceipt | None = None
infrastructure_receipt_error: str | None = None
@dataclass(frozen=True, slots=True)
@@ -586,11 +592,14 @@ def apply_configuration_package(
apply_context = ConfigurationPreflightContext(
tenant_id=context.tenant_id,
operator_user_id=context.operator_user_id,
operator_scopes=context.operator_scopes,
supplied_data=supplied_data if supplied_data is not None else context.supplied_data,
installed_modules=context.installed_modules,
capabilities=context.capabilities,
external_provider_declarations=context.external_provider_declarations,
external_provider_states=context.external_provider_states,
infrastructure_receipt=context.infrastructure_receipt,
infrastructure_receipt_error=context.infrastructure_receipt_error,
dry_run=False,
)
preflight = dry_run_configuration_package(manifest, providers, apply_context)
+151 -2
View File
@@ -5,6 +5,7 @@ from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.external_references import (
SOURCE_AUTHORITY_MODES,
SourceAuthorityMode,
@@ -23,6 +24,8 @@ CAPABILITY_DATASOURCE_CATALOGUE = "datasources.catalogue"
CAPABILITY_DATASOURCE_LIFECYCLE = "datasources.lifecycle"
CAPABILITY_DATASOURCE_PUBLICATION = "datasources.publication"
CAPABILITY_DATASOURCE_ORIGINS = "connectors.datasourceOrigins"
CAPABILITY_DATASOURCE_ARTIFACT_BACKENDS = "datasources.artifactBackends"
CAPABILITY_POLICY_DATASOURCE_VISIBILITY = "policy.datasourceVisibility"
DatasourceMode = Literal["live", "cached", "static"]
DatasourceKind = Literal[
@@ -37,6 +40,12 @@ DatasourceKind = Literal[
]
DatasourceShape = Literal["tabular", "document", "binary", "directory", "stream"]
DatasourceConsistency = Literal["current", "live", "frozen"]
DatasourceVisibilityAction = Literal["discover", "read"]
DatasourcePublicationStatus = Literal[
"published",
"published_with_warnings",
"review_required",
]
class DatasourceError(ValueError):
@@ -64,6 +73,7 @@ class DatasourceField:
name: str
data_type: str
nullable: bool = True
classification: str = "internal"
@dataclass(frozen=True, slots=True)
@@ -84,6 +94,8 @@ class DatasourceGovernance:
classification: str = "internal"
privacy_profile_ref: str | None = None
retention_policy_ref: str | None = None
access_policy_ref: str | None = None
visibility_policy: Mapping[str, object] = field(default_factory=dict)
hold_refs: tuple[str, ...] = ()
publication_state: str = "draft"
transfer_agreement_ref: str | None = None
@@ -154,6 +166,12 @@ class DatasourceGovernance:
retention_policy_ref=_optional_governance_text(
source.get("retention_policy_ref")
),
access_policy_ref=_optional_governance_text(
source.get("access_policy_ref")
),
visibility_policy=_governance_mapping(
source.get("visibility_policy")
),
hold_refs=_governance_texts(source.get("hold_refs")),
publication_state=str(source.get("publication_state") or "draft"),
transfer_agreement_ref=_optional_governance_text(
@@ -185,6 +203,8 @@ class DatasourceGovernance:
"classification": self.classification,
"privacy_profile_ref": self.privacy_profile_ref,
"retention_policy_ref": self.retention_policy_ref,
"access_policy_ref": self.access_policy_ref,
"visibility_policy": dict(self.visibility_policy),
"hold_refs": list(self.hold_refs),
"publication_state": self.publication_state,
"transfer_agreement_ref": self.transfer_agreement_ref,
@@ -197,6 +217,39 @@ class DatasourceGovernance:
}
@dataclass(frozen=True, slots=True)
class DatasourceVisibilityPolicyRequest:
tenant_id: str
datasource_ref: str
principal: PrincipalRef
action: DatasourceVisibilityAction
classification: str = "internal"
policy_ref: str | None = None
consistency: DatasourceConsistency = "current"
materialization_ref: str | None = None
@dataclass(frozen=True, slots=True)
class DatasourceVisibilityPolicyDecision:
allowed: bool
reason: str | None = None
policies: tuple[Mapping[str, object], ...] = ()
decision_ref: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class DatasourceVisibilityPolicyProvider(Protocol):
"""Optionally tighten Datasources-owned local visibility policy."""
def decide_datasource_visibility(
self,
session: object,
*,
request: DatasourceVisibilityPolicyRequest,
) -> DatasourceVisibilityPolicyDecision: ...
@dataclass(frozen=True, slots=True)
class DatasourceDescriptor:
ref: str
@@ -309,12 +362,73 @@ class DatasourceStageInput:
governance: DatasourceGovernance | None = None
@dataclass(frozen=True, slots=True)
class DatasourceArtifactReference:
"""Immutable provider-neutral reference to a durable tabular payload.
The producer owns creation of the payload. Datasources pins its locator,
checksum and declared shape without importing the artifact-owning module;
a configured payload backend verifies integrity and provides bounded reads.
"""
backend: str
locator: str
checksum: str
row_count: int
byte_count: int
schema: tuple[DatasourceField, ...]
fingerprint: str
media_type: str = "application/x-ndjson"
checkpoint: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
validation: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class DatasourceArtifactBackend(Protocol):
"""Storage-module boundary for immutable artifact-backed tabular data."""
backend: str
def verify(
self,
session: object,
*,
tenant_id: str,
artifact: DatasourceArtifactReference,
) -> None: ...
def read_rows(
self,
session: object,
*,
tenant_id: str,
artifact: DatasourceArtifactReference,
offset: int,
limit: int,
) -> Sequence[Mapping[str, object]]: ...
def delete(
self,
session: object,
*,
tenant_id: str,
artifact: DatasourceArtifactReference,
) -> None: ...
@runtime_checkable
class DatasourceArtifactBackendProvider(Protocol):
def artifact_backends(self) -> Sequence[DatasourceArtifactBackend]: ...
@dataclass(frozen=True, slots=True)
class DatasourcePublicationRequest:
producer_module: str
producer_run_ref: str
idempotency_key: str
rows: tuple[Mapping[str, object], ...]
rows: tuple[Mapping[str, object], ...] | None = None
artifact: DatasourceArtifactReference | None = None
target_datasource_ref: str | None = None
name: str | None = None
source_name: str | None = None
@@ -331,7 +445,7 @@ class DatasourcePublicationRequest:
@dataclass(frozen=True, slots=True)
class DatasourcePublicationResult:
ref: str
status: str
status: DatasourcePublicationStatus
datasource: DatasourceDescriptor
materialization: DatasourceMaterialization
replayed: bool = False
@@ -564,6 +678,17 @@ def datasource_catalogue(registry: object | None) -> DatasourceCatalogueProvider
return capability if isinstance(capability, DatasourceCatalogueProvider) else None
def datasource_artifact_backend_provider(
registry: object | None,
) -> DatasourceArtifactBackendProvider | None:
capability = _capability(registry, CAPABILITY_DATASOURCE_ARTIFACT_BACKENDS)
return (
capability
if isinstance(capability, DatasourceArtifactBackendProvider)
else None
)
def datasource_lifecycle(registry: object | None) -> DatasourceLifecycleProvider | None:
capability = _capability(registry, CAPABILITY_DATASOURCE_LIFECYCLE)
return capability if isinstance(capability, DatasourceLifecycleProvider) else None
@@ -581,6 +706,13 @@ def datasource_origins(registry: object | None) -> DatasourceOriginProvider | No
return capability if isinstance(capability, DatasourceOriginProvider) else None
def datasource_visibility_policy_provider(
registry: object | None,
) -> DatasourceVisibilityPolicyProvider | None:
capability = _capability(registry, CAPABILITY_POLICY_DATASOURCE_VISIBILITY)
return capability if isinstance(capability, DatasourceVisibilityPolicyProvider) else None
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
@@ -613,9 +745,15 @@ def _governance_mapping(value: object) -> Mapping[str, object]:
__all__ = [
"CAPABILITY_DATASOURCE_CATALOGUE",
"CAPABILITY_DATASOURCE_ARTIFACT_BACKENDS",
"CAPABILITY_DATASOURCE_LIFECYCLE",
"CAPABILITY_DATASOURCE_ORIGINS",
"CAPABILITY_DATASOURCE_PUBLICATION",
"CAPABILITY_POLICY_DATASOURCE_VISIBILITY",
"DatasourceAccessError",
"DatasourceArtifactReference",
"DatasourceArtifactBackend",
"DatasourceArtifactBackendProvider",
"DatasourceCatalogueProvider",
"DatasourceConsistency",
"DatasourceDescriptor",
@@ -631,6 +769,10 @@ __all__ = [
"DatasourceOriginProvider",
"DatasourceOriginReadRequest",
"DatasourceOriginReadResult",
"DatasourcePublicationProvider",
"DatasourcePublicationRequest",
"DatasourcePublicationResult",
"DatasourcePublicationStatus",
"DatasourceReadRequest",
"DatasourceReadResult",
"DatasourceShape",
@@ -638,7 +780,14 @@ __all__ = [
"DatasourceStageInput",
"DatasourceUnavailableError",
"DatasourceValidationError",
"DatasourceVisibilityAction",
"DatasourceVisibilityPolicyDecision",
"DatasourceVisibilityPolicyProvider",
"DatasourceVisibilityPolicyRequest",
"datasource_catalogue",
"datasource_artifact_backend_provider",
"datasource_lifecycle",
"datasource_origins",
"datasource_publication",
"datasource_visibility_policy_provider",
]
+197
View File
@@ -0,0 +1,197 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
DSAR_CAPABILITY_PREFIX = "privacy.dsar."
DsarRequestKind = Literal["access", "erasure", "access_and_erasure"]
DsarActionKind = Literal[
"delete",
"anonymize",
"revoke",
"detach",
"retain",
"manual_review",
]
@dataclass(frozen=True, slots=True)
class DsarSubjectRef:
account_id: str | None = None
identity_id: str | None = None
membership_id: str | None = None
email: str | None = None
external_references: Mapping[str, str] = field(default_factory=dict)
def has_selector(self) -> bool:
return bool(
self.account_id
or self.identity_id
or self.membership_id
or self.email
or self.external_references
)
def to_dict(self) -> dict[str, object]:
return {
"account_id": self.account_id,
"identity_id": self.identity_id,
"membership_id": self.membership_id,
"email": self.email,
"external_references": dict(self.external_references),
}
@dataclass(frozen=True, slots=True)
class DsarRecordRef:
provider_id: str
module_id: str
resource_type: str
resource_id: str
category: str
title: str
data: Mapping[str, object] = field(default_factory=dict)
observed_at: datetime | None = None
immutable_evidence: bool = False
retention_reason: str | None = None
source_path: str | None = None
def to_dict(self) -> dict[str, object]:
return {
"provider_id": self.provider_id,
"module_id": self.module_id,
"resource_type": self.resource_type,
"resource_id": self.resource_id,
"category": self.category,
"title": self.title,
"data": dict(self.data),
"observed_at": self.observed_at.isoformat() if self.observed_at else None,
"immutable_evidence": self.immutable_evidence,
"retention_reason": self.retention_reason,
"source_path": self.source_path,
}
@dataclass(frozen=True, slots=True)
class DsarErasureActionRef:
action_id: str
provider_id: str
module_id: str
kind: DsarActionKind
resource_type: str
resource_id: str
title: str
rationale: str
executable: bool
irreversible: bool = False
metadata: Mapping[str, object] = field(default_factory=dict)
def to_dict(self) -> dict[str, object]:
return {
"action_id": self.action_id,
"provider_id": self.provider_id,
"module_id": self.module_id,
"kind": self.kind,
"resource_type": self.resource_type,
"resource_id": self.resource_id,
"title": self.title,
"rationale": self.rationale,
"executable": self.executable,
"irreversible": self.irreversible,
"metadata": dict(self.metadata),
}
@dataclass(frozen=True, slots=True)
class DsarExecutionResultRef:
action_id: str
status: Literal["executed", "unchanged", "failed", "blocked"]
summary: str
evidence: Mapping[str, object] = field(default_factory=dict)
def to_dict(self) -> dict[str, object]:
return {
"action_id": self.action_id,
"status": self.status,
"summary": self.summary,
"evidence": dict(self.evidence),
}
@runtime_checkable
class DsarProvider(Protocol):
provider_id: str
module_id: str
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]: ...
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]: ...
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]: ...
def dsar_capability_name(module_id: str) -> str:
normalized = module_id.strip().casefold()
if not normalized or not normalized.replace("_", "").isalnum():
raise ValueError("DSAR module id must be an identifier.")
return f"{DSAR_CAPABILITY_PREFIX}{normalized}"
def dsar_provider_names(registry: object | None) -> tuple[str, ...]:
if registry is None or not hasattr(registry, "capability_names"):
return ()
return tuple(
name
for name in registry.capability_names()
if name.startswith(DSAR_CAPABILITY_PREFIX)
)
def dsar_provider(
registry: object,
capability_name: str,
) -> DsarProvider:
provider = registry.require_capability(capability_name)
if not isinstance(provider, DsarProvider):
raise TypeError(f"{capability_name} does not implement DsarProvider")
return provider
__all__ = [
"DSAR_CAPABILITY_PREFIX",
"DsarActionKind",
"DsarErasureActionRef",
"DsarExecutionResultRef",
"DsarProvider",
"DsarRecordRef",
"DsarRequestKind",
"DsarSubjectRef",
"dsar_capability_name",
"dsar_provider",
"dsar_provider_names",
]
+176
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
from govoplan_core.core.access import ResourceAccessExplanationProvider
@@ -9,6 +10,49 @@ from govoplan_core.core.access import ResourceAccessExplanationProvider
CAPABILITY_FILES_ACCESS = "files.access"
CAPABILITY_FILES_ARTIFACT_STORE = "files.artifact_store"
CAPABILITY_FILES_POSTBOX_REFERENCES = "files.postbox_references"
CAPABILITY_FILES_TABULAR_CONTENT = "files.tabular_content"
class ManagedTabularFileError(ValueError):
"""Stable base error for exact-version managed tabular file access."""
class ManagedTabularFileNotFoundError(ManagedTabularFileError):
pass
class ManagedTabularFileAccessError(ManagedTabularFileError):
pass
class ManagedTabularFileUnavailableError(ManagedTabularFileError):
pass
class ManagedTabularFileValidationError(ManagedTabularFileError):
pass
@dataclass(frozen=True, slots=True)
class ManagedTabularFile:
"""Authorized metadata for one immutable managed file version."""
file_asset_id: str
file_version_id: str
filename: str
display_path: str
content_type: str | None
size_bytes: int
sha256: str
updated_at: datetime | None = None
current_version: bool = True
@dataclass(frozen=True, slots=True)
class ManagedTabularFileContent:
file: ManagedTabularFile
payload: bytes
@dataclass(frozen=True, slots=True)
@@ -34,6 +78,30 @@ class ManagedArtifactRef:
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxFileReferenceRequest:
reference_type: str
reference_id: str
postbox_id: str
message_id: str
@dataclass(frozen=True, slots=True)
class PostboxFileReferenceRef:
reference_type: str
reference_id: str
available: bool
reason_code: str
file_asset_id: str | None = None
file_version_id: str | None = None
filename: str | None = None
content_type: str | None = None
size_bytes: int | None = None
sha256: str | None = None
download_path: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class FileAccessProvider(ResourceAccessExplanationProvider, Protocol):
"""Resource-level access explanation provider for Files-owned resources."""
@@ -50,3 +118,111 @@ class ManagedArtifactStore(Protocol):
*,
request: ManagedArtifactWriteRequest,
) -> ManagedArtifactRef: ...
@runtime_checkable
class PostboxFileReferenceProvider(Protocol):
"""Resolve Files-owned references after Postbox and Files authorization."""
def resolve_postbox_references(
self,
session: object,
principal: object,
*,
tenant_id: str,
requests: tuple[PostboxFileReferenceRequest, ...],
) -> tuple[PostboxFileReferenceRef, ...]: ...
@runtime_checkable
class ManagedTabularFileProvider(Protocol):
"""List and open authorized CSV/XLSX content without exposing Files internals."""
def list_tabular_files(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
) -> tuple[ManagedTabularFile, ...]: ...
def get_tabular_file(
self,
session: object,
principal: object,
*,
file_asset_id: str,
file_version_id: str | None = None,
) -> ManagedTabularFile | None: ...
def read_tabular_file(
self,
session: object,
principal: object,
*,
file_asset_id: str,
file_version_id: str,
max_bytes: int,
) -> ManagedTabularFileContent: ...
def postbox_file_reference_provider(
registry: object | None,
) -> PostboxFileReferenceProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_FILES_POSTBOX_REFERENCES)
):
return None
provider = registry.require_capability(CAPABILITY_FILES_POSTBOX_REFERENCES)
if not isinstance(provider, PostboxFileReferenceProvider):
raise TypeError(
"files.postbox_references provider does not implement "
"PostboxFileReferenceProvider"
)
return provider
def managed_tabular_file_provider(
registry: object | None,
) -> ManagedTabularFileProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_FILES_TABULAR_CONTENT)
):
return None
provider = registry.require_capability(CAPABILITY_FILES_TABULAR_CONTENT)
if not isinstance(provider, ManagedTabularFileProvider):
raise TypeError(
"files.tabular_content provider does not implement "
"ManagedTabularFileProvider"
)
return provider
__all__ = [
"CAPABILITY_FILES_ACCESS",
"CAPABILITY_FILES_ARTIFACT_STORE",
"CAPABILITY_FILES_POSTBOX_REFERENCES",
"CAPABILITY_FILES_TABULAR_CONTENT",
"FileAccessProvider",
"ManagedArtifactRef",
"ManagedArtifactStore",
"ManagedArtifactWriteRequest",
"ManagedTabularFile",
"ManagedTabularFileAccessError",
"ManagedTabularFileContent",
"ManagedTabularFileError",
"ManagedTabularFileNotFoundError",
"ManagedTabularFileProvider",
"ManagedTabularFileUnavailableError",
"ManagedTabularFileValidationError",
"PostboxFileReferenceProvider",
"PostboxFileReferenceRef",
"PostboxFileReferenceRequest",
"managed_tabular_file_provider",
"postbox_file_reference_provider",
]
+1 -1
View File
@@ -356,7 +356,7 @@ def consume_first_admin_credential(
tenant = Tenant(
slug=clean_tenant_slug,
name=clean_tenant_name,
default_locale="en",
default_locale="de",
settings={},
is_active=True,
)
+227
View File
@@ -0,0 +1,227 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.institutional import EvidenceReference, InstitutionalReference
CAPABILITY_FORM_EVIDENCE_PREFIX = "forms_runtime.evidence."
FormEvidenceState = Literal[
"accepted",
"pending",
"rejected",
"expired",
"revoked",
"unavailable",
]
_FORM_EVIDENCE_STATES = {
"accepted",
"pending",
"rejected",
"expired",
"revoked",
"unavailable",
}
class FormEvidenceContractError(ValueError):
"""Stable error for provider-neutral Form evidence operations."""
@dataclass(frozen=True, slots=True)
class FormEvidenceGrantRequest:
"""Request a short-lived, purpose-bound grant from an evidence owner."""
tenant_id: str
instance_id: str
definition_ref: InstitutionalReference
evidence_kind: str
purpose: str
idempotency_key: str
expires_at: datetime
custodian_ref: str | None = None
max_size_bytes: int | None = None
allowed_content_types: tuple[str, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
_require_text(self.tenant_id, "Form evidence tenant")
_require_text(self.instance_id, "Form evidence instance")
_require_text(self.evidence_kind, "Form evidence kind")
_require_text(self.purpose, "Form evidence purpose")
_require_text(self.idempotency_key, "Form evidence idempotency key")
if (
self.definition_ref.kind != "form"
or self.definition_ref.tenant_id != self.tenant_id
or not self.definition_ref.version
):
raise FormEvidenceContractError(
"Form evidence grants require an exact same-tenant Form definition."
)
if self.expires_at.tzinfo is None or self.expires_at.utcoffset() is None:
raise FormEvidenceContractError(
"Form evidence grant expiry must include a timezone."
)
if self.max_size_bytes is not None and self.max_size_bytes <= 0:
raise FormEvidenceContractError(
"Form evidence grant size limits must be positive."
)
if any(not item.strip() for item in self.allowed_content_types):
raise FormEvidenceContractError(
"Form evidence content types cannot be empty."
)
@dataclass(frozen=True, slots=True)
class FormEvidenceGrant:
provider_id: str
grant_id: str
upload_token: str | None
upload_url: str
expires_at: datetime
max_size_bytes: int
allowed_content_types: tuple[str, ...] = ()
replayed: bool = False
def __post_init__(self) -> None:
for value, label in (
(self.provider_id, "Form evidence provider"),
(self.grant_id, "Form evidence grant"),
(self.upload_url, "Form evidence upload URL"),
):
_require_text(value, label)
if self.upload_token is not None:
_require_text(self.upload_token, "Form evidence upload token")
if self.max_size_bytes <= 0:
raise FormEvidenceContractError(
"Form evidence grant size limits must be positive."
)
if self.expires_at.tzinfo is None or self.expires_at.utcoffset() is None:
raise FormEvidenceContractError(
"Form evidence grant expiry must include a timezone."
)
@dataclass(frozen=True, slots=True)
class FormEvidenceInspectionRequest:
tenant_id: str
instance_id: str
definition_ref: InstitutionalReference
evidence: EvidenceReference
purpose: str
final: bool
def __post_init__(self) -> None:
_require_text(self.tenant_id, "Form evidence tenant")
_require_text(self.instance_id, "Form evidence instance")
_require_text(self.purpose, "Form evidence purpose")
if self.definition_ref.tenant_id != self.tenant_id:
raise FormEvidenceContractError(
"Form evidence inspection cannot cross tenants."
)
if self.evidence.tenant_id != self.tenant_id:
raise FormEvidenceContractError(
"Form evidence inspection cannot cross tenants."
)
@dataclass(frozen=True, slots=True)
class FormEvidenceInspection:
provider_id: str
reference: EvidenceReference
state: FormEvidenceState
observed_at: datetime
retryable: bool = False
reason: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
_require_text(self.provider_id, "Form evidence provider")
if self.state not in _FORM_EVIDENCE_STATES:
raise FormEvidenceContractError(
f"Unsupported Form evidence state: {self.state!r}."
)
if self.observed_at.tzinfo is None or self.observed_at.utcoffset() is None:
raise FormEvidenceContractError(
"Form evidence inspection time must include a timezone."
)
if self.state == "accepted" and self.retryable:
raise FormEvidenceContractError(
"Accepted Form evidence cannot require a retry."
)
@property
def accepted(self) -> bool:
return self.state == "accepted"
@runtime_checkable
class FormEvidenceProvider(Protocol):
provider_id: str
def supported_kinds(self) -> Sequence[str]: ...
def create_upload_grant(
self,
session: object,
principal: object,
*,
request: FormEvidenceGrantRequest,
) -> FormEvidenceGrant: ...
def inspect_evidence(
self,
session: object,
principal: object,
*,
request: FormEvidenceInspectionRequest,
) -> FormEvidenceInspection: ...
def form_evidence_capability(provider_id: str) -> str:
normalized = str(provider_id or "").strip().lower().replace("-", "_")
if not normalized or not normalized.replace("_", "").isalnum():
raise FormEvidenceContractError("Invalid Form evidence provider id.")
return f"{CAPABILITY_FORM_EVIDENCE_PREFIX}{normalized}"
def form_evidence_provider(
registry: object | None,
provider_id: str,
) -> FormEvidenceProvider | None:
capability_name = form_evidence_capability(provider_id)
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(capability_name):
return None
if hasattr(registry, "require_capability"):
provider = registry.require_capability(capability_name)
elif hasattr(registry, "capability"):
provider = registry.capability(capability_name)
else:
return None
return provider if isinstance(provider, FormEvidenceProvider) else None
def _require_text(value: str, label: str) -> None:
if not isinstance(value, str) or not value.strip():
raise FormEvidenceContractError(f"{label} is required.")
__all__ = [
"CAPABILITY_FORM_EVIDENCE_PREFIX",
"FormEvidenceContractError",
"FormEvidenceGrant",
"FormEvidenceGrantRequest",
"FormEvidenceInspection",
"FormEvidenceInspectionRequest",
"FormEvidenceProvider",
"FormEvidenceState",
"form_evidence_capability",
"form_evidence_provider",
]
@@ -0,0 +1,236 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal, Mapping, cast
InformationGovernanceAdoption = Literal[
"not_applicable",
"contract_only",
"partial",
"enforced",
]
INFORMATION_GOVERNANCE_ADOPTION_ORDER: tuple[InformationGovernanceAdoption, ...] = (
"not_applicable",
"contract_only",
"partial",
"enforced",
)
class InformationGovernanceDeclarationError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class InformationGovernanceDimension:
"""Truthful module-level adoption claim for one cross-cutting dimension."""
adoption: InformationGovernanceAdoption = "contract_only"
object_types: tuple[str, ...] = ()
evidence: tuple[str, ...] = ()
limitation: str | None = None
def __post_init__(self) -> None:
if self.adoption not in INFORMATION_GOVERNANCE_ADOPTION_ORDER:
raise InformationGovernanceDeclarationError(
f"Unsupported information-governance adoption: {self.adoption!r}."
)
for field_name in ("object_types", "evidence"):
values = getattr(self, field_name)
if len(values) != len(set(values)) or any(not item.strip() for item in values):
raise InformationGovernanceDeclarationError(
f"Information-governance {field_name.replace('_', ' ')} must "
"contain unique non-empty values."
)
if self.adoption == "enforced" and not self.evidence:
raise InformationGovernanceDeclarationError(
"An enforced information-governance dimension requires evidence."
)
if self.adoption in {"partial", "enforced"} and not self.object_types:
raise InformationGovernanceDeclarationError(
"Partial and enforced information-governance dimensions must "
"name their covered object types."
)
if self.adoption == "not_applicable" and self.object_types:
raise InformationGovernanceDeclarationError(
"A non-applicable information-governance dimension cannot declare object types."
)
if self.adoption in {"contract_only", "partial"} and not str(
self.limitation or ""
).strip():
raise InformationGovernanceDeclarationError(
"Contract-only and partial adoption must state the current limitation."
)
def to_dict(self) -> dict[str, object]:
return {
"adoption": self.adoption,
"object_types": list(self.object_types),
"evidence": list(self.evidence),
"limitation": self.limitation,
}
def _contract_only_dimension() -> InformationGovernanceDimension:
return InformationGovernanceDimension(
adoption="contract_only",
limitation=(
"The platform contract applies, but module-specific adoption evidence "
"has not been declared."
),
)
@dataclass(frozen=True, slots=True)
class ModuleInformationGovernance:
"""Cross-cutting data-use requirements and honest adoption evidence."""
temporal_browsing: InformationGovernanceDimension = field(
default_factory=_contract_only_dimension
)
purpose_aware_access: InformationGovernanceDimension = field(
default_factory=_contract_only_dimension
)
retention: InformationGovernanceDimension = field(
default_factory=_contract_only_dimension
)
institutional_context: InformationGovernanceDimension = field(
default_factory=_contract_only_dimension
)
current_authorization_for_historical_reads: bool = True
contract_version: str = "1"
def __post_init__(self) -> None:
if self.contract_version != "1":
raise InformationGovernanceDeclarationError(
"Unsupported module information-governance contract version."
)
if not self.current_authorization_for_historical_reads:
raise InformationGovernanceDeclarationError(
"Historical reads must always use current authorization."
)
for name, dimension in self.dimensions.items():
if not isinstance(dimension, InformationGovernanceDimension):
raise InformationGovernanceDeclarationError(
f"Information-governance dimension {name!r} has an invalid value."
)
@property
def dimensions(self) -> Mapping[str, InformationGovernanceDimension]:
return {
"temporal_browsing": self.temporal_browsing,
"purpose_aware_access": self.purpose_aware_access,
"retention": self.retention,
"institutional_context": self.institutional_context,
}
def to_dict(self) -> dict[str, object]:
return {
"contract_version": self.contract_version,
"current_authorization_for_historical_reads": (
self.current_authorization_for_historical_reads
),
"dimensions": {
name: dimension.to_dict()
for name, dimension in self.dimensions.items()
},
}
def information_governance_from_mapping(
value: Mapping[str, object],
) -> ModuleInformationGovernance:
raw_dimensions = value.get("dimensions")
if not isinstance(raw_dimensions, Mapping):
raise InformationGovernanceDeclarationError(
"Information-governance dimensions must be an object."
)
def dimension(name: str) -> InformationGovernanceDimension:
raw_dimension = raw_dimensions.get(name)
if not isinstance(raw_dimension, Mapping):
raise InformationGovernanceDeclarationError(
f"Information-governance dimension {name!r} must be an object."
)
def text_tuple(field_name: str) -> tuple[str, ...]:
raw_values = raw_dimension.get(field_name, ())
if not isinstance(raw_values, (list, tuple)):
raise InformationGovernanceDeclarationError(
f"Information-governance {name}.{field_name} must be a list."
)
if any(not isinstance(item, str) for item in raw_values):
raise InformationGovernanceDeclarationError(
f"Information-governance {name}.{field_name} must contain strings."
)
return tuple(raw_values)
raw_limitation = raw_dimension.get("limitation")
raw_adoption = raw_dimension.get("adoption") or "contract_only"
if not isinstance(raw_adoption, str):
raise InformationGovernanceDeclarationError(
f"Information-governance {name}.adoption must be a string."
)
if raw_limitation is not None and not isinstance(raw_limitation, str):
raise InformationGovernanceDeclarationError(
f"Information-governance {name}.limitation must be a string."
)
return InformationGovernanceDimension(
adoption=cast(
InformationGovernanceAdoption,
raw_adoption,
),
object_types=text_tuple("object_types"),
evidence=text_tuple("evidence"),
limitation=raw_limitation,
)
current_authorization = value.get(
"current_authorization_for_historical_reads",
True,
)
if not isinstance(current_authorization, bool):
raise InformationGovernanceDeclarationError(
"current_authorization_for_historical_reads must be boolean."
)
return ModuleInformationGovernance(
contract_version=str(value.get("contract_version") or "1"),
current_authorization_for_historical_reads=current_authorization,
temporal_browsing=dimension("temporal_browsing"),
purpose_aware_access=dimension("purpose_aware_access"),
retention=dimension("retention"),
institutional_context=dimension("institutional_context"),
)
def information_governance_maturity_issues(
declaration: ModuleInformationGovernance,
*,
maturity: str | None,
) -> tuple[str, ...]:
if maturity not in {"reference_ready", "supported", "lts"}:
return ()
incomplete = [
name
for name, dimension in declaration.dimensions.items()
if dimension.adoption not in {"not_applicable", "enforced"}
]
if not incomplete:
return ()
return (
f"Maturity {maturity!r} requires enforced or explicitly non-applicable "
"information governance for: " + ", ".join(incomplete),
)
__all__ = [
"INFORMATION_GOVERNANCE_ADOPTION_ORDER",
"InformationGovernanceAdoption",
"InformationGovernanceDeclarationError",
"InformationGovernanceDimension",
"ModuleInformationGovernance",
"information_governance_from_mapping",
"information_governance_maturity_issues",
]
@@ -0,0 +1,363 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
import json
import os
from pathlib import Path
import re
from typing import Any
DEPLOYMENT_CAPABILITIES_ENV = "GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH"
MAX_CAPABILITY_DOCUMENT_BYTES = 256 * 1024
CAPABILITY_STATES = frozenset(
{
"configured",
"available_unconfigured",
"externally_supplied",
"unavailable",
}
)
_ENV_REFERENCE_RE = re.compile(r"^env:[A-Za-z_][A-Za-z0-9_]*$")
class InfrastructureCapabilityReceiptError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class InfrastructureCapability:
id: str
label: str
state: str
source: str
detail: str
endpoint: Mapping[str, object]
secret_refs: tuple[str, ...]
dependent_modules: tuple[str, ...]
def to_dict(self) -> dict[str, object]:
return {
"id": self.id,
"label": self.label,
"state": self.state,
"source": self.source,
"detail": self.detail,
"endpoint": dict(self.endpoint),
"secret_refs": list(self.secret_refs),
"dependent_modules": list(self.dependent_modules),
}
@dataclass(frozen=True, slots=True)
class InfrastructurePostInstallTask:
id: str
resume_key: str
capability_id: str
state: str
owner_module: str
summary: str
required_inputs: tuple[str, ...]
secret_boundary: str
def to_dict(self) -> dict[str, object]:
return {
"id": self.id,
"resume_key": self.resume_key,
"capability_id": self.capability_id,
"state": self.state,
"owner_module": self.owner_module,
"summary": self.summary,
"required_inputs": list(self.required_inputs),
"secret_boundary": self.secret_boundary,
}
@dataclass(frozen=True, slots=True)
class InfrastructureCapabilityReceipt:
installation_id: str
profile: str
capabilities: tuple[InfrastructureCapability, ...]
post_install_tasks: tuple[InfrastructurePostInstallTask, ...]
schema_version: int = 1
def capability(self, capability_id: str) -> InfrastructureCapability | None:
return next(
(item for item in self.capabilities if item.id == capability_id),
None,
)
def tasks_for(
self,
*,
capability_id: str | None = None,
owner_module: str | None = None,
) -> tuple[InfrastructurePostInstallTask, ...]:
return tuple(
item
for item in self.post_install_tasks
if (capability_id is None or item.capability_id == capability_id)
and (owner_module is None or item.owner_module == owner_module)
)
def to_dict(self) -> dict[str, object]:
return {
"schema_version": self.schema_version,
"installation_id": self.installation_id,
"profile": self.profile,
"capabilities": [item.to_dict() for item in self.capabilities],
"post_install_tasks": [
item.to_dict() for item in self.post_install_tasks
],
}
def load_infrastructure_capability_receipt(
path: Path | str | None = None,
) -> InfrastructureCapabilityReceipt | None:
configured_path = path
if configured_path is None:
raw_path = os.getenv(DEPLOYMENT_CAPABILITIES_ENV, "").strip()
if not raw_path:
return None
configured_path = raw_path
return read_infrastructure_capability_receipt(Path(configured_path))
def read_infrastructure_capability_receipt(
path: Path,
) -> InfrastructureCapabilityReceipt:
if path.is_symlink() or not path.is_file():
raise InfrastructureCapabilityReceiptError(
"Deployment capability receipt is not a regular file."
)
try:
expected_size = path.stat().st_size
if expected_size > MAX_CAPABILITY_DOCUMENT_BYTES:
raise InfrastructureCapabilityReceiptError(
"Deployment capability receipt exceeds 256 KiB."
)
raw = path.read_bytes()
except OSError as exc:
raise InfrastructureCapabilityReceiptError(
"Deployment capability receipt could not be read."
) from exc
if len(raw) != expected_size:
raise InfrastructureCapabilityReceiptError(
"Deployment capability receipt changed while being read."
)
try:
payload = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise InfrastructureCapabilityReceiptError(
"Deployment capability receipt is not valid UTF-8 JSON."
) from exc
return infrastructure_capability_receipt_from_mapping(payload)
def infrastructure_capability_receipt_from_mapping(
payload: object,
) -> InfrastructureCapabilityReceipt:
if (
not isinstance(payload, Mapping)
or type(payload.get("schema_version")) is not int
or payload.get("schema_version") != 1
):
raise InfrastructureCapabilityReceiptError(
"Deployment capability receipt has an unsupported schema."
)
raw_capabilities = payload.get("capabilities")
if not isinstance(raw_capabilities, list) or len(raw_capabilities) > 100:
raise InfrastructureCapabilityReceiptError(
"Deployment capability receipt has invalid capabilities."
)
capabilities = tuple(_capability(item) for item in raw_capabilities)
capability_ids = [item.id for item in capabilities]
if len(capability_ids) != len(set(capability_ids)):
raise InfrastructureCapabilityReceiptError(
"Deployment capability receipt repeats a capability id."
)
raw_tasks = payload.get("post_install_tasks", [])
if not isinstance(raw_tasks, list) or len(raw_tasks) > 100:
raise InfrastructureCapabilityReceiptError(
"Deployment capability receipt has invalid post-install tasks."
)
tasks = tuple(_task(item) for item in raw_tasks)
known_capability_ids = set(capability_ids)
if any(item.capability_id not in known_capability_ids for item in tasks):
raise InfrastructureCapabilityReceiptError(
"Deployment post-install task references an unknown capability."
)
return InfrastructureCapabilityReceipt(
installation_id=_required_text(payload, "installation_id", maximum=100),
profile=_required_text(payload, "profile", maximum=100),
capabilities=capabilities,
post_install_tasks=tasks,
)
def deployment_capability_status(
path: Path | str | None = None,
) -> dict[str, object]:
try:
receipt = load_infrastructure_capability_receipt(path)
except InfrastructureCapabilityReceiptError as exc:
return _unavailable_status(configured=True, error=str(exc))
if receipt is None:
return _unavailable_status(configured=False, error=None)
return {
"configured": True,
"available": True,
**receipt.to_dict(),
"error": None,
}
def _capability(value: object) -> InfrastructureCapability:
if not isinstance(value, Mapping):
raise InfrastructureCapabilityReceiptError(
"Deployment capability entries must be objects."
)
state = _required_text(value, "state", maximum=40)
if state not in CAPABILITY_STATES:
raise InfrastructureCapabilityReceiptError(
f"Deployment capability state is unsupported: {state!r}."
)
normalized_endpoint = _normalized_endpoint(value.get("endpoint", {}))
secret_refs = _string_list(value.get("secret_refs"), maximum_items=30)
if any(not _ENV_REFERENCE_RE.fullmatch(item) for item in secret_refs):
raise InfrastructureCapabilityReceiptError(
"Deployment capability secrets must use environment references."
)
return InfrastructureCapability(
id=_required_text(value, "id", maximum=120),
label=_required_text(value, "label", maximum=200),
state=state,
source=_required_text(value, "source", maximum=120),
detail=_required_text(value, "detail", maximum=1000),
endpoint=normalized_endpoint,
secret_refs=secret_refs,
dependent_modules=_string_list(
value.get("dependent_modules"),
maximum_items=100,
),
)
def _normalized_endpoint(value: object) -> dict[str, object]:
if not isinstance(value, Mapping) or len(value) > 10:
raise InfrastructureCapabilityReceiptError(
"Deployment capability endpoint metadata is invalid."
)
endpoint: dict[str, object] = {}
for key, raw in value.items():
if not isinstance(key, str) or not key or len(key) > 50:
raise InfrastructureCapabilityReceiptError(
"Deployment capability endpoint key is invalid."
)
if any(
marker in key.casefold()
for marker in ("password", "secret", "token", "credential")
):
raise InfrastructureCapabilityReceiptError(
"Deployment capability endpoint metadata contains a secret field."
)
if key.casefold() == "port" and (
type(raw) is not int or not 1 <= raw <= 65535
):
raise InfrastructureCapabilityReceiptError(
"Deployment capability endpoint port is invalid."
)
if isinstance(raw, bool) or raw is None:
endpoint[key] = raw
elif isinstance(raw, int):
endpoint[key] = raw
elif isinstance(raw, str) and len(raw) <= 500:
endpoint[key] = raw
else:
raise InfrastructureCapabilityReceiptError(
"Deployment capability endpoint value is invalid."
)
return endpoint
def _task(value: object) -> InfrastructurePostInstallTask:
if not isinstance(value, Mapping):
raise InfrastructureCapabilityReceiptError(
"Deployment post-install task entries must be objects."
)
return InfrastructurePostInstallTask(
id=_required_text(value, "id", maximum=120),
resume_key=_required_text(value, "resume_key", maximum=240),
capability_id=_required_text(value, "capability_id", maximum=120),
state=_required_text(value, "state", maximum=40),
owner_module=_required_text(value, "owner_module", maximum=120),
summary=_required_text(value, "summary", maximum=1000),
required_inputs=_string_list(
value.get("required_inputs"),
maximum_items=30,
),
secret_boundary=_required_text(
value,
"secret_boundary",
maximum=120,
),
)
def _required_text(
value: Mapping[str, Any],
key: str,
*,
maximum: int,
) -> str:
raw = value.get(key)
text = str(raw).strip() if raw is not None else ""
if not text or len(text) > maximum:
raise InfrastructureCapabilityReceiptError(
f"Deployment capability field {key!r} is invalid."
)
return text
def _string_list(value: object, *, maximum_items: int) -> tuple[str, ...]:
if not isinstance(value, list) or len(value) > maximum_items:
raise InfrastructureCapabilityReceiptError(
"Deployment capability list field is invalid."
)
result: list[str] = []
for item in value:
if not isinstance(item, str) or not item.strip() or len(item) > 500:
raise InfrastructureCapabilityReceiptError(
"Deployment capability list item is invalid."
)
result.append(item.strip())
return tuple(result)
def _unavailable_status(*, configured: bool, error: str | None) -> dict[str, object]:
return {
"configured": configured,
"available": False,
"schema_version": None,
"installation_id": None,
"profile": None,
"capabilities": [],
"post_install_tasks": [],
"error": error,
}
__all__ = [
"CAPABILITY_STATES",
"DEPLOYMENT_CAPABILITIES_ENV",
"InfrastructureCapability",
"InfrastructureCapabilityReceipt",
"InfrastructureCapabilityReceiptError",
"InfrastructurePostInstallTask",
"deployment_capability_status",
"infrastructure_capability_receipt_from_mapping",
"load_infrastructure_capability_receipt",
"read_infrastructure_capability_receipt",
]
+11 -4
View File
@@ -698,12 +698,15 @@ def _validate_module_catalog_trust(
"A module catalog source is configured without a trusted keyring file.",
"Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.",
)
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL")):
if not (
_clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS"))
or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL"))
):
collector.add(
"error",
"GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL",
"GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS",
"A module catalog source is configured without an approved release channel.",
"Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.",
"Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS=stable or another approved deployment channel.",
)
@@ -734,6 +737,8 @@ CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
SCHEDULING_PUBLIC_SELF_ENROLLMENT_ENABLED=true
SCHEDULING_PUBLIC_SELF_ENROLLMENT_MAX_CAPACITY=10000
# Deployment-wide connector egress policy. Enable private networks only when
# this installation intentionally integrates with internal services.
@@ -777,7 +782,7 @@ DEV_MAILBOX_API_ENABLED=false
GOVOPLAN_MODULE_PACKAGE_CATALOG_URL=https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json
GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE=/etc/govoplan/catalog-keyring.json
GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable
GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS=stable
"""
@@ -815,6 +820,8 @@ CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
SCHEDULING_PUBLIC_SELF_ENROLLMENT_ENABLED=true
SCHEDULING_PUBLIC_SELF_ENROLLMENT_MAX_CAPACITY=10000
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
+52
View File
@@ -9,6 +9,7 @@ from typing import Protocol, runtime_checkable
CAPABILITY_MAIL_DELIVERY_OUTBOX = "mail.delivery_outbox"
CAPABILITY_MAIL_NOTIFICATION_DELIVERY = "mail.notificationDelivery"
CAPABILITY_MAIL_BOUNCE_PROCESSING = "mail.bounce_processing"
CAPABILITY_MAIL_POSTBOX_BRIDGE = "mail.postbox_bridge"
@dataclass(frozen=True, slots=True)
@@ -81,6 +82,28 @@ class MailBounceObservationRef:
evidence: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class MailPostboxBridgeRequest:
tenant_id: str
target: object
profile_id: str
folder: str
uid: str
uidvalidity: str
raw_message: bytes
classification: str = "internal"
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class MailPostboxBridgeResult:
postbox_id: str
message_id: str
delivery_id: str
duplicate: bool
source_digest: str
@runtime_checkable
class MailBounceProcessingProvider(Protocol):
"""Mail-owned DSN ingestion and durable correlation boundary."""
@@ -116,6 +139,17 @@ class MailBounceProcessingProvider(Protocol):
...
@runtime_checkable
class MailPostboxBridgeProvider(Protocol):
"""Translate one immutable Mail observation into Postbox delivery."""
def bridge_message(
self,
session: object,
request: MailPostboxBridgeRequest,
) -> MailPostboxBridgeResult: ...
def notification_mail_delivery_provider(
registry: object | None,
) -> NotificationMailDeliveryProvider | None:
@@ -150,3 +184,21 @@ def mail_bounce_processing_provider(
"MailBounceProcessingProvider"
)
return provider
def mail_postbox_bridge_provider(
registry: object | None,
) -> MailPostboxBridgeProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_MAIL_POSTBOX_BRIDGE)
):
return None
provider = registry.require_capability(CAPABILITY_MAIL_POSTBOX_BRIDGE)
if not isinstance(provider, MailPostboxBridgeProvider):
raise TypeError(
"mail.postbox_bridge provider does not implement "
"MailPostboxBridgeProvider"
)
return provider
+358 -17
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from collections import defaultdict
from collections.abc import Iterable, Mapping
from contextlib import AbstractContextManager, closing
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from datetime import UTC, datetime
from importlib import metadata
import hashlib
@@ -18,6 +18,7 @@ import sqlite3
import stat
import subprocess # nosec B404 - installer commands are structured and policy-validated before execution.
import sys
import tempfile
import tomllib
from typing import Any, Literal
import time
@@ -62,6 +63,7 @@ MIGRATION_TASK_PHASES = (
MIGRATION_TASK_MUTATING_PHASES = {"pre_migration_prepare", "post_migration_backfill"}
MIGRATION_TASK_REVIEW_SAFETY = {"requires_review", "forward_only", "destructive"}
MIGRATION_TASK_BLOCKING_SAFETY = {"forward_only", "destructive"}
MAX_PACKAGE_ARTIFACT_BYTES = 512 * 1024 * 1024
@dataclass(frozen=True, slots=True)
@@ -464,14 +466,20 @@ def _package_target_action_preflight_issues(
"Python installs must include the distribution package name so rollback can uninstall newly added packages.",
item.module_id,
))
if item.python_ref and not _looks_pinned_dependency_ref(item.python_ref):
if item.python_ref and not (
_looks_pinned_dependency_ref(item.python_ref)
or _artifact_ref_is_digest_pinned(item, "python", item.python_ref)
):
issues.append(ModuleInstallerIssue(
"blocker",
"unpinned_python_ref",
"Python install refs must be pinned to an exact version or tagged git ref.",
item.module_id,
))
if item.webui_ref and not _looks_pinned_dependency_ref(item.webui_ref):
if item.webui_ref and not (
_looks_pinned_dependency_ref(item.webui_ref)
or _artifact_ref_is_digest_pinned(item, "webui", item.webui_ref)
):
issues.append(ModuleInstallerIssue(
"blocker",
"unpinned_webui_ref",
@@ -481,6 +489,23 @@ def _package_target_action_preflight_issues(
return tuple(issues)
def _artifact_ref_is_digest_pinned(
item: ModuleInstallPlanItem,
kind: str,
package_ref: str,
) -> bool:
metadata = _artifact_metadata(item.artifact_integrity, kind)
if metadata is None:
return False
expected_ref = _artifact_text(metadata, "ref") or _artifact_text(metadata, "expected_ref")
sha256 = _artifact_text(metadata, "sha256")
return bool(
expected_ref == package_ref
and sha256
and re.fullmatch(r"[0-9a-f]{64}", sha256.lower())
)
def _frontend_rebuild_preflight_issues(
*,
frontend_rebuild_required: bool,
@@ -519,6 +544,7 @@ def run_module_install_plan(
) -> ModuleInstallerRunResult:
maintenance_mode = saved_maintenance_mode(session)
effective_runtime_dir = runtime_dir or default_installer_runtime_dir(database_url)
effective_plan = plan
preflight = module_install_preflight(
plan=plan,
available=available,
@@ -532,9 +558,30 @@ def run_module_install_plan(
if not preflight.allowed:
raise ModuleInstallerError("Install preflight is blocked: " + "; ".join(issue.message for issue in preflight.issues if issue.severity == "blocker"))
if not dry_run:
effective_plan = acquire_catalog_package_artifacts(
plan,
runtime_dir=effective_runtime_dir,
)
preflight = module_install_preflight(
plan=effective_plan,
available=available,
current_enabled=current_enabled,
desired_enabled=desired_enabled,
maintenance_mode=maintenance_mode.enabled,
session=session,
webui_root=webui_root,
runtime_dir=effective_runtime_dir,
)
if not preflight.allowed:
raise ModuleInstallerError(
"Install preflight is blocked after artifact acquisition: "
+ "; ".join(issue.message for issue in preflight.issues if issue.severity == "blocker")
)
state = _prepare_module_install_run(
session=session,
plan=plan,
plan=effective_plan,
preflight=preflight,
database_url=database_url,
effective_runtime_dir=effective_runtime_dir,
@@ -556,7 +603,7 @@ def run_module_install_plan(
executed, failed_error = _execute_module_install_run(
session=session,
plan=plan,
plan=effective_plan,
available=available,
effective_runtime_dir=effective_runtime_dir,
state=state,
@@ -566,7 +613,7 @@ def run_module_install_plan(
return _failed_module_install_run_result(
session=session,
state=state,
plan=plan,
plan=effective_plan,
executed=executed,
failed_error=failed_error,
effective_runtime_dir=effective_runtime_dir,
@@ -578,7 +625,7 @@ def run_module_install_plan(
return _applied_module_install_run_result(
session=session,
plan=plan,
plan=effective_plan,
desired_enabled=desired_enabled,
activate_installed_modules=activate_installed_modules,
remove_uninstalled_modules_from_desired=remove_uninstalled_modules_from_desired,
@@ -1568,9 +1615,11 @@ def _structured_item_commands(
webui_changed = False
if item.action in PACKAGE_TARGET_ACTIONS:
if item.python_ref:
commands.append(_structured_command([sys.executable, "-m", "pip", "install", item.python_ref], source="module-plan.python"))
python_source = _verified_artifact_install_ref(item, "python") or item.python_ref
commands.append(_structured_command([sys.executable, "-m", "pip", "install", python_source], source="module-plan.python"))
if item.webui_package and item.webui_ref and webui_root is not None:
commands.append(_structured_command([npm_bin, "pkg", "set", f"dependencies.{item.webui_package}={item.webui_ref}"], cwd=webui_root, source="module-plan.webui"))
webui_source = _verified_artifact_install_ref(item, "webui") or item.webui_ref
commands.append(_structured_command([npm_bin, "pkg", "set", f"dependencies.{item.webui_package}={webui_source}"], cwd=webui_root, source="module-plan.webui"))
webui_changed = True
elif item.action == "uninstall":
if item.python_package:
@@ -1581,6 +1630,14 @@ def _structured_item_commands(
return tuple(commands), webui_changed
def _verified_artifact_install_ref(item: ModuleInstallPlanItem, kind: str) -> str | None:
metadata = _artifact_metadata(item.artifact_integrity, kind)
path = _artifact_path(metadata) if metadata is not None else None
if path is None:
return None
return path.as_uri() if kind == "webui" else str(path)
def _structured_webui_followup_commands(
*,
webui_changed: bool,
@@ -1932,9 +1989,7 @@ def _package_catalog_preflight_issues(
return ()
catalog_items = tuple(item for item in package_items if item.source == "catalog")
try:
from govoplan_core.core.module_package_catalog import validate_module_package_catalog
result = validate_module_package_catalog()
result = _validate_catalog_for_plan(catalog_items)
except Exception as exc:
return _catalog_validation_exception_issues(exc, catalog_items=bool(catalog_items))
issues = list(_catalog_validation_result_issues(result, catalog_items=bool(catalog_items)))
@@ -1942,10 +1997,33 @@ def _package_catalog_preflight_issues(
return tuple(issues)
issues.extend(_catalog_warning_issues(result))
if catalog_items:
issues.extend(_catalog_plan_binding_issues(catalog_items, result))
issues.extend(_selected_catalog_interface_issues(catalog_items, result, available))
return tuple(issues)
def _validate_catalog_for_plan(
catalog_items: tuple[ModuleInstallPlanItem, ...],
) -> dict[str, object]:
from govoplan_core.core.module_package_catalog import (
OFFICIAL_MODULE_PACKAGE_CATALOG_URL,
validate_module_package_catalog,
validate_official_module_package_catalog,
)
configured = validate_module_package_catalog()
if configured.get("configured") or not catalog_items:
return configured
sources = {
str(item.catalog.get("source") or "")
for item in catalog_items
if isinstance(item.catalog, Mapping)
}
if sources == {OFFICIAL_MODULE_PACKAGE_CATALOG_URL}:
return validate_official_module_package_catalog()
return configured
def _catalog_validation_exception_issues(exc: Exception, *, catalog_items: bool) -> tuple[ModuleInstallerIssue, ...]:
severity: IssueSeverity = "blocker" if catalog_items else "warning"
return (ModuleInstallerIssue(
@@ -1985,6 +2063,97 @@ def _catalog_warning_issues(result: Mapping[str, object]) -> tuple[ModuleInstall
return tuple(ModuleInstallerIssue("warning", "catalog_warning", str(warning)) for warning in warnings)
def _catalog_plan_binding_issues(
items: tuple[ModuleInstallPlanItem, ...],
validation: Mapping[str, object],
) -> tuple[ModuleInstallerIssue, ...]:
"""Require every trusted plan row to match its signed catalog entry exactly."""
modules = _catalog_modules_by_id(validation)
issues: list[ModuleInstallerIssue] = []
for item in items:
entry = modules.get(item.module_id)
if entry is None or entry.get("action") not in PACKAGE_TARGET_ACTIONS:
issues.append(ModuleInstallerIssue(
"blocker",
"catalog_plan_entry_missing",
f"The validated catalog no longer contains an install or update entry for {item.module_id!r}.",
item.module_id,
))
continue
mismatches = _catalog_plan_entry_mismatches(item, entry, validation)
if mismatches:
issues.append(ModuleInstallerIssue(
"blocker",
"catalog_plan_binding_mismatch",
(
"The saved package plan differs from its validated signed catalog entry "
f"for: {', '.join(mismatches)}. Remove and add the catalog item again."
),
item.module_id,
))
return tuple(issues)
def _catalog_plan_entry_mismatches(
item: ModuleInstallPlanItem,
entry: Mapping[str, object],
validation: Mapping[str, object],
) -> tuple[str, ...]:
mismatches: list[str] = []
for attribute in ("python_package", "python_ref", "webui_package", "webui_ref"):
if getattr(item, attribute) != _catalog_optional_string(entry, attribute):
mismatches.append(attribute)
if _catalog_integrity_identity(item.artifact_integrity) != _catalog_integrity_identity(
entry.get("artifact_integrity")
):
mismatches.append("artifact_integrity")
catalog = item.catalog if isinstance(item.catalog, Mapping) else {}
expected_snapshot = {
"source": validation.get("source") or validation.get("path"),
"channel": validation.get("channel"),
"sequence": validation.get("sequence"),
"signed": bool(validation.get("signed")),
"trusted": bool(validation.get("trusted")),
"key_id": validation.get("key_id"),
}
for attribute, expected in expected_snapshot.items():
actual = catalog.get(attribute)
if actual != expected:
mismatches.append(f"catalog.{attribute}")
return tuple(mismatches)
def _catalog_integrity_identity(value: object) -> dict[str, dict[str, object]]:
if not isinstance(value, Mapping):
return {}
identity: dict[str, dict[str, object]] = {}
for kind in ("python", "webui"):
raw = value.get(kind)
if not isinstance(raw, Mapping):
continue
identity[kind] = {
field: raw.get(field)
for field in (
"ref",
"url",
"filename",
"sha256",
"size",
"integrity",
"sbom_url",
"provenance_url",
"registry_identity",
"git_ref",
"source_commit",
)
if raw.get(field) is not None
}
return identity
def _module_install_target_plan(
plan: ModuleInstallPlan,
available: Mapping[str, ModuleManifest],
@@ -2817,12 +2986,15 @@ def _topological_cycle_ids(incoming: Mapping[str, set[str]]) -> tuple[str, ...]:
def _catalog_modules_for_target_plan(
planned_items: tuple[ModuleInstallPlanItem, ...],
) -> dict[str, Mapping[str, object]]:
if not any(item.source == "catalog" and item.action in PACKAGE_TARGET_ACTIONS for item in planned_items):
catalog_items = tuple(
item
for item in planned_items
if item.source == "catalog" and item.action in PACKAGE_TARGET_ACTIONS
)
if not catalog_items:
return {}
try:
from govoplan_core.core.module_package_catalog import validate_module_package_catalog
result = validate_module_package_catalog()
result = _validate_catalog_for_plan(catalog_items)
except Exception:
return {}
if result.get("valid") is not True:
@@ -3772,6 +3944,156 @@ def _configured_require_artifact_integrity() -> bool:
return os.getenv("GOVOPLAN_MODULE_INSTALLER_REQUIRE_ARTIFACT_INTEGRITY", "").strip().lower() in {"1", "true", "yes", "on"}
def acquire_catalog_package_artifacts(
plan: ModuleInstallPlan,
*,
runtime_dir: Path,
) -> ModuleInstallPlan:
"""Materialize trusted catalog archives before package mutation."""
items: list[ModuleInstallPlanItem] = []
for item in plan.items:
if item.status != "planned" or item.action not in PACKAGE_TARGET_ACTIONS:
items.append(item)
continue
raw_integrity = item.artifact_integrity
if not isinstance(raw_integrity, Mapping):
items.append(item)
continue
integrity: dict[str, object] = dict(raw_integrity)
changed = False
for kind in ("python", "webui"):
metadata = _artifact_metadata(integrity, kind)
if metadata is None or _artifact_path(metadata) is not None:
continue
if not _catalog_artifact_acquisition_ready(item, metadata):
continue
updated = dict(metadata)
updated["artifact_path"] = str(
_acquire_package_artifact(
metadata,
runtime_dir=runtime_dir,
module_id=item.module_id,
kind=kind,
)
)
integrity[kind] = updated
changed = True
items.append(replace(item, artifact_integrity=integrity) if changed else item)
return replace(plan, items=tuple(items))
def _catalog_artifact_acquisition_ready(
item: ModuleInstallPlanItem,
metadata: Mapping[str, object],
) -> bool:
catalog = item.catalog
if (
item.source != "catalog"
or not isinstance(catalog, Mapping)
or catalog.get("signed") is not True
or catalog.get("trusted") is not True
):
return False
url = _artifact_text(metadata, "url")
filename = _artifact_text(metadata, "filename")
sha256 = _artifact_text(metadata, "sha256")
size = metadata.get("size")
return bool(
url
and url.startswith("https://")
and filename
and Path(filename).name == filename
and sha256
and re.fullmatch(r"[0-9a-f]{64}", sha256.lower())
and isinstance(size, int)
and not isinstance(size, bool)
and 0 < size <= MAX_PACKAGE_ARTIFACT_BYTES
)
def _acquire_package_artifact(
metadata: Mapping[str, object],
*,
runtime_dir: Path,
module_id: str,
kind: str,
) -> Path:
url = validate_http_url(_artifact_text(metadata, "url") or "", label=f"{kind.capitalize()} package URL")
if not url.startswith("https://"):
raise ModuleInstallerError(f"{kind.capitalize()} package URL must use HTTPS.")
filename = _artifact_text(metadata, "filename") or ""
expected_sha256 = (_artifact_text(metadata, "sha256") or "").lower()
expected_size = metadata.get("size")
if (
not filename
or Path(filename).name != filename
or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._+!-]{0,255}", filename) is None
or re.fullmatch(r"[0-9a-f]{64}", expected_sha256) is None
or not isinstance(expected_size, int)
or isinstance(expected_size, bool)
or not 0 < expected_size <= MAX_PACKAGE_ARTIFACT_BYTES
):
raise ModuleInstallerError(f"Catalog artifact metadata is incomplete for {module_id}/{kind}.")
cache_root = runtime_dir / "artifacts"
_ensure_private_artifact_directory(cache_root)
digest_root = cache_root / expected_sha256
_ensure_private_artifact_directory(digest_root)
target = digest_root / filename
if target.exists() or target.is_symlink():
if target.is_symlink() or not target.is_file():
raise ModuleInstallerError(f"Cached package artifact is not a regular file: {target}")
if target.stat().st_size != expected_size or _sha256_file(target) != expected_sha256:
raise ModuleInstallerError(f"Cached package artifact does not match its catalog identity: {target}")
return target
try:
response = fetch_http(
url,
timeout=float(os.getenv("GOVOPLAN_MODULE_INSTALLER_DOWNLOAD_TIMEOUT_SECONDS", "120")),
label=f"{module_id} {kind} package URL",
max_bytes=min(expected_size + 1, MAX_PACKAGE_ARTIFACT_BYTES),
)
except (OSError, ValueError) as exc:
raise ModuleInstallerError(f"Could not download {module_id} {kind} package: {exc}") from exc
if response.status < 200 or response.status >= 300:
raise ModuleInstallerError(f"Could not download {module_id} {kind} package: HTTP {response.status}.")
if len(response.body) != expected_size or hashlib.sha256(response.body).hexdigest() != expected_sha256:
raise ModuleInstallerError(f"Downloaded {module_id} {kind} package does not match its signed catalog identity.")
temporary_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="wb",
prefix=f".{filename}.",
suffix=".tmp",
dir=digest_root,
delete=False,
) as handle:
temporary_path = Path(handle.name)
handle.write(response.body)
handle.flush()
os.fsync(handle.fileno())
temporary_path.chmod(0o600)
os.replace(temporary_path, target)
target.chmod(0o600)
except OSError as exc:
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)
raise ModuleInstallerError(f"Could not cache {module_id} {kind} package.") from exc
return target
def _ensure_private_artifact_directory(path: Path) -> None:
if path.is_symlink():
raise ModuleInstallerError(f"Installer artifact cache must not be a symlink: {path}")
path.mkdir(parents=True, mode=0o700, exist_ok=True)
path.chmod(0o700)
if not path.is_dir() or stat.S_IMODE(path.stat().st_mode) != 0o700:
raise ModuleInstallerError(f"Installer artifact cache is not private: {path}")
def _verify_artifact_integrity(
planned_items: tuple[ModuleInstallPlanItem, ...],
*,
@@ -3839,7 +4161,17 @@ def _verify_artifact_metadata(
}
if package_name:
record["package"] = package_name
for key in ("sha256", "sbom_url", "provenance_url", "registry_identity", "git_ref"):
for key in (
"sha256",
"url",
"filename",
"integrity",
"sbom_url",
"provenance_url",
"registry_identity",
"git_ref",
"source_commit",
):
value = _artifact_text(metadata, key)
if value:
record[key] = value
@@ -3860,6 +4192,15 @@ def _verify_artifact_metadata(
item.module_id,
))
return record, tuple(issues)
if artifact_path is None and _catalog_artifact_acquisition_ready(item, metadata):
record["acquisition_pending"] = True
issues.append(ModuleInstallerIssue(
"info",
"artifact_acquisition_pending",
f"{kind.capitalize()} artifact will be downloaded and verified by the installer daemon before package mutation.",
item.module_id,
))
return record, tuple(issues)
if artifact_path is None:
issues.append(ModuleInstallerIssue(
"blocker" if require_verified else "warning",
@@ -6,6 +6,7 @@ from collections import defaultdict
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
from importlib.resources import files
from pathlib import Path
import json
import os
@@ -17,6 +18,10 @@ 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.core.information_governance import (
information_governance_from_mapping,
information_governance_maturity_issues,
)
from govoplan_core.core.provider_governance import (
external_provider_from_mapping,
module_architecture_from_mapping,
@@ -25,6 +30,11 @@ from govoplan_core.core.provider_governance import (
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_]*)+$")
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
_ARTIFACT_FILENAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+!-]{0,255}$")
_SOURCE_REPOSITORY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$")
_SOURCE_REF_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/+!-]{0,127}$")
_SOURCE_COMMIT_RE = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$")
CATALOG_MIGRATION_SAFETY = ("automatic", "requires_review", "forward_only", "destructive")
CATALOG_MIGRATION_TASK_PHASES = (
"pre_migration_check",
@@ -32,6 +42,8 @@ CATALOG_MIGRATION_TASK_PHASES = (
"post_migration_backfill",
"post_migration_verify",
)
OFFICIAL_MODULE_PACKAGE_CATALOG_URL = "https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json"
OFFICIAL_MODULE_PACKAGE_CATALOG_CHANNEL = "stable"
@dataclass(frozen=True, slots=True)
@@ -97,6 +109,18 @@ def validate_module_package_catalog(
return _valid_catalog_result(catalog_source, state)
def validate_official_module_package_catalog() -> dict[str, object]:
"""Read the public GovOPlaN directory against Core's pinned trust anchor."""
keyring = files("govoplan_core").joinpath("resources/catalog-keyring.json").read_text(encoding="utf-8")
return validate_module_package_catalog(
OFFICIAL_MODULE_PACKAGE_CATALOG_URL,
require_trusted=True,
approved_channels=(OFFICIAL_MODULE_PACKAGE_CATALOG_CHANNEL,),
trusted_keys=_parse_trusted_keys(keyring),
)
def _catalog_validation_state(
source: Path | str | None,
*,
@@ -319,7 +343,10 @@ def _configured_require_signature() -> bool:
def _configured_approved_channels() -> tuple[str, ...]:
value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS", "").strip()
value = (
os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS", "").strip()
or os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL", "").strip()
)
if not value:
return ()
return tuple(item.strip() for item in value.split(",") if item.strip())
@@ -628,8 +655,29 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
"requires_interfaces": _normalize_catalog_interface_requirements(value.get("requires_interfaces"), module_id=module_id),
"notes": _optional_str(value, "notes"),
"tags": _string_list(value.get("tags")),
"availability": _catalog_availability(value, module_id=module_id),
"availability_reason": _optional_str(value, "availability_reason"),
"configuration_requirements": _string_list(value.get("configuration_requirements")),
"permissions": _normalize_catalog_permissions(
value.get("permissions"),
module_id=module_id,
),
}
if item["availability"] == "withdrawn" and not item["availability_reason"]:
raise ValueError(
f"Withdrawn module package catalog entry {module_id!r} requires availability_reason."
)
release_notes_url = _optional_str(value, "release_notes_url")
if release_notes_url is not None:
item["release_notes_url"] = _catalog_https_url(
release_notes_url,
label=f"Module package catalog release_notes_url for {module_id!r}",
)
source = _normalize_catalog_source(value.get("source"), module_id=module_id)
if source:
item["source"] = source
raw_architecture = value.get("architecture")
architecture_maturity: str | None = None
if raw_architecture is not None:
if not isinstance(raw_architecture, Mapping):
raise ValueError(
@@ -647,6 +695,27 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
+ "; ".join(issues)
)
item["architecture"] = architecture.to_dict()
architecture_maturity = architecture.maturity
raw_information_governance = value.get("information_governance")
if raw_information_governance is not None:
if not isinstance(raw_information_governance, Mapping):
raise ValueError(
"Module package catalog information_governance for "
f"{module_id!r} must be an object."
)
information_governance = information_governance_from_mapping(
raw_information_governance
)
governance_issues = information_governance_maturity_issues(
information_governance,
maturity=architecture_maturity,
)
if governance_issues:
raise ValueError(
"Module package catalog information_governance for "
f"{module_id!r} is invalid: " + "; ".join(governance_issues)
)
item["information_governance"] = information_governance.to_dict()
raw_providers = value.get("external_providers")
if raw_providers is not None:
if not isinstance(raw_providers, list):
@@ -710,6 +779,170 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
return item
def _catalog_availability(value: Mapping[str, object], *, module_id: str) -> str:
availability = str(value.get("availability") or "available").strip().lower()
if availability not in {"available", "withdrawn"}:
raise ValueError(
f"Unsupported catalog availability for {module_id!r}: {availability!r}."
)
return availability
def _normalize_catalog_permissions(
value: object,
*,
module_id: str,
) -> list[dict[str, object]]:
if value is None:
return []
if not isinstance(value, list):
raise ValueError(
f"Module package catalog permissions for {module_id!r} must be a list."
)
if len(value) > 1000:
raise ValueError(
f"Module package catalog permissions for {module_id!r} exceed 1000 entries."
)
normalized: list[dict[str, object]] = []
seen: set[str] = set()
for raw in value:
if not isinstance(raw, Mapping):
raise ValueError(
f"Module package catalog permission entries for {module_id!r} must be objects."
)
scope = _bounded_catalog_permission_text(
raw,
"scope",
module_id=module_id,
maximum=200,
)
if scope in seen:
raise ValueError(
f"Module package catalog entry {module_id!r} declares permission {scope!r} more than once."
)
seen.add(scope)
level = _bounded_catalog_permission_text(
raw,
"level",
module_id=module_id,
maximum=20,
)
if level not in {"system", "tenant"}:
raise ValueError(
f"Module package catalog permission {module_id!r}/{scope!r} has unsupported level {level!r}."
)
deprecated = raw.get("deprecated", False)
if not isinstance(deprecated, bool):
raise ValueError(
f"Module package catalog permission {module_id!r}/{scope!r} deprecated must be true or false."
)
normalized.append(
{
"scope": scope,
"label": _bounded_catalog_permission_text(
raw,
"label",
module_id=module_id,
maximum=200,
),
"description": _bounded_catalog_permission_text(
raw,
"description",
module_id=module_id,
maximum=1000,
),
"category": _bounded_catalog_permission_text(
raw,
"category",
module_id=module_id,
maximum=120,
),
"level": level,
"resource": _bounded_catalog_permission_text(
raw,
"resource",
module_id=module_id,
maximum=120,
),
"action": _bounded_catalog_permission_text(
raw,
"action",
module_id=module_id,
maximum=120,
),
"deprecated": deprecated,
}
)
return normalized
def _bounded_catalog_permission_text(
value: Mapping[str, object],
key: str,
*,
module_id: str,
maximum: int,
) -> str:
text = _required_str(value, key)
if len(text) > maximum:
raise ValueError(
f"Module package catalog permission {key!r} for {module_id!r} exceeds {maximum} characters."
)
return text
def _normalize_catalog_source(
value: object,
*,
module_id: str,
) -> dict[str, object]:
if value is None:
return {}
if not isinstance(value, Mapping):
raise ValueError(
f"Module package catalog source for {module_id!r} must be an object."
)
repository = _required_str(value, "repository")
tag = _required_str(value, "tag")
commit = _required_str(value, "commit").lower()
if (
_SOURCE_REPOSITORY_RE.fullmatch(repository) is None
or repository.startswith("/")
or repository.endswith("/")
or ".." in repository.split("/")
):
raise ValueError(
f"Module package catalog source repository for {module_id!r} is invalid."
)
if _SOURCE_REF_RE.fullmatch(tag) is None or ".." in tag.split("/"):
raise ValueError(
f"Module package catalog source tag for {module_id!r} is invalid."
)
if _SOURCE_COMMIT_RE.fullmatch(commit) is None:
raise ValueError(
f"Module package catalog source commit for {module_id!r} is invalid."
)
source: dict[str, object] = {
"repository": repository,
"tag": tag,
"commit": commit,
}
for field in ("repository_url", "revision_url"):
url = _optional_str(value, field)
if url is not None:
source[field] = _catalog_https_url(
url,
label=f"Module package catalog source {field} for {module_id!r}",
)
return source
def _catalog_https_url(value: str, *, label: str) -> str:
if not is_http_url(value) or not value.startswith("https://"):
raise ValueError(f"{label} must use HTTPS.")
return value
def _catalog_migration_safety(value: Any, *, module_id: str) -> str:
if value is None:
return "automatic"
@@ -780,14 +1013,14 @@ def _catalog_optional_positive_int(value: dict[str, Any], key: str, *, module_id
return integer
def _required_str(value: dict[str, Any], key: str) -> str:
def _required_str(value: Mapping[str, Any], key: str) -> str:
item = _optional_str(value, key)
if not item:
raise ValueError(f"Module package catalog entry is missing {key!r}.")
return item
def _optional_str(value: dict[str, Any], key: str) -> str | None:
def _optional_str(value: Mapping[str, Any], key: str) -> str | None:
item = value.get(key)
if item is None:
return None
@@ -943,20 +1176,40 @@ def _normalize_artifact_integrity(value: Any) -> dict[str, object]:
continue
if not isinstance(raw, dict):
raise ValueError(f"Module package catalog artifact_integrity.{key} must be an object.")
clean = {
clean: dict[str, object] = {
field: text
for field in (
"ref",
"path",
"artifact_path",
"url",
"filename",
"sha256",
"integrity",
"sbom_url",
"provenance_url",
"registry_identity",
"git_ref",
"source_commit",
)
if (text := _optional_str(raw, field))
}
url = clean.get("url")
if isinstance(url, str) and (not is_http_url(url) or not url.startswith("https://")):
raise ValueError(f"Module package catalog artifact_integrity.{key}.url must use HTTPS.")
filename = clean.get("filename")
if isinstance(filename, str) and _ARTIFACT_FILENAME_RE.fullmatch(filename) is None:
raise ValueError(f"Module package catalog artifact_integrity.{key}.filename is invalid.")
sha256 = clean.get("sha256")
if isinstance(sha256, str) and _SHA256_RE.fullmatch(sha256.lower()) is None:
raise ValueError(f"Module package catalog artifact_integrity.{key}.sha256 is invalid.")
if isinstance(sha256, str):
clean["sha256"] = sha256.lower()
size = raw.get("size")
if size is not None:
if not isinstance(size, int) or isinstance(size, bool) or size <= 0 or size > 512 * 1024 * 1024:
raise ValueError(f"Module package catalog artifact_integrity.{key}.size is invalid.")
clean["size"] = size
if clean:
normalized[key] = clean
return normalized
+85 -18
View File
@@ -4,6 +4,7 @@ from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any, Literal, Protocol, TYPE_CHECKING
from govoplan_core.core.information_governance import ModuleInformationGovernance
from govoplan_core.core.ownership import OwnershipProviderRegistration
from govoplan_core.core.provider_governance import (
ExternalProviderDeclaration,
@@ -14,16 +15,21 @@ from govoplan_core.core.views import ViewSurface
if TYPE_CHECKING:
from fastapi import APIRouter
from govoplan_core.core.operations import OperationalCheckProviderRegistration
from govoplan_core.core.operations import (
OperationalCheckProviderRegistration,
RuntimeWorkStatusProviderRegistration,
)
from govoplan_core.core.search import (
SearchProviderRegistration,
SearchSourceProviderRegistration,
)
from govoplan_core.core.tasks import WorkItemProviderRegistration
from govoplan_core.core.workflows import WorkflowDefinitionContribution
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION = "1"
SUPPORTED_PRESENTATION_CONTRACT_VERSION = "1"
PermissionLevel = Literal["system", "tenant"]
SubjectType = Literal["account", "membership", "group", "service_account", "tenant"]
@@ -33,7 +39,9 @@ MigrationTaskPhase = Literal[
"post_migration_backfill",
"post_migration_verify",
]
MigrationTaskSafety = Literal["automatic", "requires_review", "forward_only", "destructive"]
MigrationTaskSafety = Literal[
"automatic", "requires_review", "forward_only", "destructive"
]
MigrationTaskStatus = Literal["ok", "warning", "blocked", "skipped"]
@@ -74,8 +82,6 @@ class NavItem:
surface_id: str | None = None
@dataclass(frozen=True, slots=True)
class FrontendRoute:
path: str
@@ -95,6 +101,43 @@ class PublicFrontendRoute:
order: int = 100
@dataclass(frozen=True, slots=True)
class ProductAreaContribution:
"""Assign module-owned surfaces to a user-facing product area."""
id: str
module_id: str
label: str
icon: str
surface_ids: tuple[str, ...]
description: str | None = None
order: int = 100
@dataclass(frozen=True, slots=True)
class QuickAccessTool:
"""Declare a versioned, bounded module-owned Quick Access tool."""
id: str
module_id: str
category_id: str
label: str
surface_id: str
icon: str
description: str | None = None
full_page_path: str | None = None
required_all: tuple[str, ...] = ()
required_any: tuple[str, ...] = ()
order: int = 100
default_enabled: bool = True
modes: tuple[str, ...] = ("browse",)
contract_version: str = "1"
availability: Literal["global", "active_object"] = "global"
accepted_reference_kinds: tuple[str, ...] = ()
returned_reference_kinds: tuple[str, ...] = ()
help_context_id: str | None = None
@dataclass(frozen=True, slots=True)
class FrontendModule:
module_id: str
@@ -109,6 +152,8 @@ class FrontendModule:
nav_items: tuple[NavItem, ...] = ()
settings_routes: tuple[FrontendRoute, ...] = ()
view_surfaces: tuple[ViewSurface, ...] = ()
product_areas: tuple[ProductAreaContribution, ...] = ()
quick_access_tools: tuple[QuickAccessTool, ...] = ()
@dataclass(frozen=True, slots=True)
@@ -146,7 +191,9 @@ class ModuleMigrationTaskResult:
details: Mapping[str, Any] = field(default_factory=dict)
ModuleMigrationTaskExecutor = Callable[[ModuleMigrationTaskContext], ModuleMigrationTaskResult | None]
ModuleMigrationTaskExecutor = Callable[
[ModuleMigrationTaskContext], ModuleMigrationTaskResult | None
]
@dataclass(frozen=True, slots=True)
@@ -203,7 +250,9 @@ class ModuleUninstallGuardResult:
message: str
UninstallGuardProvider = Callable[[object | None, str], Iterable[ModuleUninstallGuardResult]]
UninstallGuardProvider = Callable[
[object | None, str], Iterable[ModuleUninstallGuardResult]
]
@dataclass(frozen=True, slots=True)
@@ -223,7 +272,9 @@ class ModuleContext:
DocumentationLayer = Literal["always", "configured", "available", "evidence"]
DocumentationLinkKind = Literal["runtime", "api", "repository", "wiki", "public"]
DocumentationType = Literal["admin", "user"]
DocumentationConfigurationState = Literal["enabled", "disabled", "inherited", "unavailable"]
DocumentationConfigurationState = Literal[
"enabled", "disabled", "inherited", "unavailable"
]
DocumentationSourceKind = Literal[
"manifest",
"route",
@@ -288,16 +339,23 @@ def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str
"""
raw_kind = topic.metadata.get("kind")
kind = raw_kind.strip().lower().replace("_", "-") if isinstance(raw_kind, str) else ""
kind = (
raw_kind.strip().lower().replace("_", "-") if isinstance(raw_kind, str) else ""
)
if kind != "workflow" or "user" not in topic.documentation_types:
return ()
if not topic.conditions:
return ("user workflow topics must declare at least one scope-conditioned alternative",)
return (
"user workflow topics must declare at least one scope-conditioned alternative",
)
unscoped_alternatives = tuple(
index
for index, condition in enumerate(topic.conditions, start=1)
if not any(scope.strip() for scope in (*condition.required_scopes, *condition.any_scopes))
if not any(
scope.strip()
for scope in (*condition.required_scopes, *condition.any_scopes)
)
)
if not unscoped_alternatives:
return ()
@@ -369,14 +427,11 @@ class CapabilityDocumentation:
class ResourceAclProvider(Protocol):
resource_type: str
def can_read(self, principal: object, resource_id: str) -> bool:
...
def can_read(self, principal: object, resource_id: str) -> bool: ...
def can_write(self, principal: object, resource_id: str) -> bool:
...
def can_write(self, principal: object, resource_id: str) -> bool: ...
def explain(self, principal: object, resource_id: str) -> AccessDecision:
...
def explain(self, principal: object, resource_id: str) -> AccessDecision: ...
TenantSummaryProvider = Callable[[object, str], Mapping[str, int]]
@@ -435,17 +490,29 @@ class ModuleManifest:
ownership_providers: tuple[OwnershipProviderRegistration, ...] = ()
tenant_summary_providers: tuple[TenantSummaryProvider, ...] = ()
tenant_summary_batch_providers: tuple[TenantSummaryBatchProvider, ...] = ()
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(
default_factory=dict
)
uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = ()
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
capability_documentation: Mapping[str, CapabilityDocumentation] = field(default_factory=dict)
capability_documentation: Mapping[str, CapabilityDocumentation] = field(
default_factory=dict
)
search_providers: tuple["SearchProviderRegistration", ...] = ()
search_sources: tuple["SearchSourceProviderRegistration", ...] = ()
work_item_providers: tuple["WorkItemProviderRegistration", ...] = ()
operational_check_providers: tuple[
"OperationalCheckProviderRegistration",
...,
] = ()
runtime_work_status_providers: tuple[
"RuntimeWorkStatusProviderRegistration",
...,
] = ()
architecture: ModuleArchitectureDeclaration | None = None
information_governance: ModuleInformationGovernance = field(
default_factory=ModuleInformationGovernance
)
external_providers: tuple[ExternalProviderDeclaration, ...] = ()
external_provider_state_providers: tuple[
ExternalProviderStateProviderRegistration,
+175
View File
@@ -0,0 +1,175 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Iterable, Mapping
NAVIGATION_PREFERENCES_KEY = "navigation_preferences"
NAVIGATION_PREFERENCES_CONTRACT_VERSION = "1"
_MAX_ITEMS = 256
@dataclass(frozen=True, slots=True)
class NavigationPreferences:
order: tuple[str, ...] = ()
hidden: tuple[str, ...] = ()
locked: tuple[str, ...] = ()
def as_dict(self) -> dict[str, object]:
return {
"contract_version": NAVIGATION_PREFERENCES_CONTRACT_VERSION,
"order": list(self.order),
"hidden": list(self.hidden),
"locked": list(self.locked),
}
@dataclass(frozen=True, slots=True)
class EffectiveNavigationItem:
id: str
order: int
visible: bool
locked: bool
order_source: str
visibility_source: str
lock_source: str | None = None
def as_dict(self) -> dict[str, object]:
return {
"navigation_id": self.id,
"order": self.order,
"navigation_visible": self.visible,
"navigation_locked": self.locked,
"navigation_order_source": self.order_source,
"navigation_visibility_source": self.visibility_source,
"navigation_lock_source": self.lock_source,
}
def navigation_preferences_from_settings(
settings: object,
) -> NavigationPreferences | None:
if not isinstance(settings, Mapping):
return None
raw = settings.get(NAVIGATION_PREFERENCES_KEY)
if not isinstance(raw, Mapping):
return None
return navigation_preferences_from_mapping(raw)
def navigation_preferences_from_mapping(
raw: Mapping[str, Any],
) -> NavigationPreferences:
return NavigationPreferences(
order=_ids(raw.get("order")),
hidden=_ids(raw.get("hidden")),
locked=_ids(raw.get("locked")),
)
def update_navigation_preferences(
settings: object,
preferences: NavigationPreferences | Mapping[str, Any] | None,
) -> dict[str, Any]:
updated = dict(settings) if isinstance(settings, Mapping) else {}
if preferences is None:
updated.pop(NAVIGATION_PREFERENCES_KEY, None)
else:
raw = preferences.as_dict() if isinstance(preferences, NavigationPreferences) else preferences
updated[NAVIGATION_PREFERENCES_KEY] = navigation_preferences_from_mapping(
raw
).as_dict()
return updated
def resolve_navigation_preferences(
item_ids: Iterable[str],
*,
system: NavigationPreferences | None = None,
tenant: NavigationPreferences | None = None,
user: NavigationPreferences | None = None,
) -> dict[str, EffectiveNavigationItem]:
ordered = list(dict.fromkeys(_clean_id(item_id) for item_id in item_ids))
ordered = [item_id for item_id in ordered if item_id]
available = set(ordered)
order_source = {item_id: "module" for item_id in ordered}
visibility = {item_id: True for item_id in ordered}
visibility_source = {item_id: "module" for item_id in ordered}
locks: dict[str, str] = {}
for source, preferences, may_lock in (
("system", system, True),
("tenant", tenant, True),
("user", user, False),
):
if preferences is None:
continue
requested_order = [item_id for item_id in preferences.order if item_id in available]
if requested_order:
requested = set(requested_order)
ordered = [*requested_order, *(item_id for item_id in ordered if item_id not in requested)]
for item_id in requested_order:
order_source[item_id] = source
requested_hidden = set(preferences.hidden).intersection(available)
for item_id in available:
if item_id in locks:
visibility[item_id] = True
visibility_source[item_id] = locks[item_id]
continue
visibility[item_id] = item_id not in requested_hidden
visibility_source[item_id] = source
if may_lock:
for item_id in preferences.locked:
if item_id not in available:
continue
locks[item_id] = source
visibility[item_id] = True
visibility_source[item_id] = source
return {
item_id: EffectiveNavigationItem(
id=item_id,
order=index,
visible=visibility[item_id],
locked=item_id in locks,
order_source=order_source[item_id],
visibility_source=visibility_source[item_id],
lock_source=locks.get(item_id),
)
for index, item_id in enumerate(ordered)
}
def _ids(value: object) -> tuple[str, ...]:
if not isinstance(value, (list, tuple)):
return ()
cleaned = tuple(
dict.fromkeys(
item_id
for item in value[:_MAX_ITEMS]
if (item_id := _clean_id(item))
)
)
return cleaned
def _clean_id(value: object) -> str:
if not isinstance(value, str):
return ""
clean = value.strip()
if not clean or len(clean) > 255 or any(ord(character) < 32 for character in clean):
return ""
return clean
__all__ = [
"EffectiveNavigationItem",
"NAVIGATION_PREFERENCES_CONTRACT_VERSION",
"NAVIGATION_PREFERENCES_KEY",
"NavigationPreferences",
"navigation_preferences_from_mapping",
"navigation_preferences_from_settings",
"resolve_navigation_preferences",
"update_navigation_preferences",
]
+83 -1
View File
@@ -1,7 +1,8 @@
from __future__ import annotations
from collections.abc import Callable, Mapping
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal
@@ -42,3 +43,84 @@ class OperationalCheckProviderRegistration:
provider: OperationalCheckProvider
cache_seconds: int = 60
RuntimeWorkState = Literal[
"disabled",
"unconfigured",
"starting",
"healthy",
"idle",
"busy",
"degraded",
"stale",
"unreachable",
]
@dataclass(frozen=True, slots=True)
class RuntimeWorkStatusContext:
"""Sanitized process evidence supplied to a runtime-work provider."""
profile: str
observed_at: datetime
stale_after_seconds: int
runtime_nodes: Sequence[Mapping[str, object]] = ()
@dataclass(frozen=True, slots=True)
class RuntimeWorkStatus:
"""Bounded worker/queue status with explicit unsupported metrics."""
provider_id: str
label: str
backend: str
enabled: bool
configured: bool
state: RuntimeWorkState
detail: str
observed_at: datetime
active_workers: int | None = None
last_heartbeat_at: datetime | None = None
queue_depths: Mapping[str, int | None] = field(default_factory=dict)
active_work: int | None = None
reserved_work: int | None = None
failures: int | None = None
stale_after_seconds: int | None = None
guidance: str = ""
def as_dict(self) -> dict[str, object]:
return {
"provider_id": self.provider_id,
"label": self.label,
"backend": self.backend,
"enabled": self.enabled,
"configured": self.configured,
"state": self.state,
"detail": self.detail,
"observed_at": self.observed_at.isoformat(),
"active_workers": self.active_workers,
"last_heartbeat_at": (
self.last_heartbeat_at.isoformat()
if self.last_heartbeat_at is not None
else None
),
"queue_depths": dict(self.queue_depths),
"active_work": self.active_work,
"reserved_work": self.reserved_work,
"failures": self.failures,
"stale_after_seconds": self.stale_after_seconds,
"guidance": self.guidance,
}
RuntimeWorkStatusProvider = Callable[[RuntimeWorkStatusContext], RuntimeWorkStatus]
@dataclass(frozen=True, slots=True)
class RuntimeWorkStatusProviderRegistration:
"""Register one optional provider-neutral worker/queue observation."""
module_id: str
provider_id: str
provider: RuntimeWorkStatusProvider
cache_seconds: int = 15
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
from govoplan_core.core.institutional import EvidenceReference
CAPABILITY_PAYMENT_REQUESTS = "payments.requests"
@dataclass(frozen=True, slots=True)
class PaymentRequestCommand:
tenant_id: str
source_module: str
source_resource_type: str
source_resource_id: str
amount_minor: int
currency: str
subject: str
idempotency_key: str
requested_at: datetime
requested_by_ref: str
due_at: datetime | None = None
context_refs: Mapping[str, str] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ManualPaymentReconciliationCommand:
tenant_id: str
payment_id: str
amount_minor: int
currency: str
transaction_reference: str
evidence_ref: EvidenceReference
idempotency_key: str
received_at: datetime
recorded_at: datetime
recorded_by_ref: str
metadata: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class PaymentRequestProvider(Protocol):
def request_payment(
self,
session: object,
command: PaymentRequestCommand,
) -> Mapping[str, object]:
...
def get_payment(
self,
session: object,
*,
tenant_id: str,
payment_id: str,
) -> Mapping[str, object] | None:
...
def reconcile_manual_payment(
self,
session: object,
command: ManualPaymentReconciliationCommand,
) -> Mapping[str, object]:
...
def payment_request_provider(registry: object | None) -> PaymentRequestProvider | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(CAPABILITY_PAYMENT_REQUESTS):
return None
capability = registry.capability(CAPABILITY_PAYMENT_REQUESTS)
return capability if isinstance(capability, PaymentRequestProvider) else None
__all__ = [
"CAPABILITY_PAYMENT_REQUESTS",
"ManualPaymentReconciliationCommand",
"PaymentRequestCommand",
"PaymentRequestProvider",
"payment_request_provider",
]
@@ -21,11 +21,13 @@ PlatformInterfaceKind = Literal[
"frontend_route",
"navigation",
"permission",
"product_area",
"provided_interface",
"public_route",
"search_provider",
"search_source",
"settings_route",
"quick_access_tool",
"view_surface",
]
@@ -217,6 +219,45 @@ def manifest_interface_declarations(
},
)
)
for area in frontend.product_areas:
declarations.append(
PlatformInterfaceDeclaration(
id=f"{manifest.id}.{area.id}",
module_id=manifest.id,
kind="product_area",
label=area.label,
required_all=(),
required_any=(),
metadata={
"area_id": area.id,
"icon": area.icon,
"description": area.description,
"order": area.order,
"surface_ids": list(area.surface_ids),
},
)
)
for tool in frontend.quick_access_tools:
declarations.append(
PlatformInterfaceDeclaration(
id=tool.id,
module_id=manifest.id,
kind="quick_access_tool",
label=tool.label,
path=tool.full_page_path,
required_all=tool.required_all,
required_any=tool.required_any,
metadata={
"category_id": tool.category_id,
"surface_id": tool.surface_id,
"icon": tool.icon,
"description": tool.description,
"order": tool.order,
"default_enabled": tool.default_enabled,
"modes": list(tool.modes),
},
)
)
frontend_navigation = {
declaration.id: declaration
+223
View File
@@ -7,6 +7,20 @@ from urllib.parse import quote, unquote
from govoplan_core.core.access import PrincipalRef
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
PolicyImpactPopulationState = Literal[
"complete",
"sampled",
"truncated",
"unavailable",
]
CampaignArchiveEncryptionMethod = Literal["aes", "zip_standard"]
CampaignArchivePasswordDeliveryChannel = Literal[
"separate_mail",
"sms",
"letter",
"phone",
"in_person",
]
SchedulingParticipantVisibility = Literal["aggregates_only", "names_and_statuses"]
DefinitionScopeType = Literal["system", "tenant", "group", "user"]
DefinitionKind = Literal["flow", "template"]
@@ -45,6 +59,8 @@ CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY = "policy.schedulingParticipant
CAPABILITY_POLICY_DEFINITION_GOVERNANCE = "policy.definitionGovernance"
CAPABILITY_POLICY_VIEW_GOVERNANCE = "policy.viewGovernance"
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE = "policy.functionAssignmentGovernance"
CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION = "policy.campaignArchiveEncryption"
CAPABILITY_POLICY_IMPACT_SUBJECT_PREFIX = "policy.impactSubjects."
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = (
"system",
@@ -182,6 +198,213 @@ class PolicyDecision:
}
@dataclass(frozen=True, slots=True)
class PolicyImpactPopulationRequest:
"""One explicit, bounded request to an optional impact-subject provider."""
tenant_id: str
policy_family: str
selector: Mapping[str, Any] = field(default_factory=dict)
limit: int = 200
actor_scopes: tuple[str, ...] = ()
allow_sensitive_details: bool = False
def __post_init__(self) -> None:
if not self.tenant_id.strip():
raise ValueError("Policy impact population requires a tenant ID")
if not self.policy_family.strip() or len(self.policy_family) > 120:
raise ValueError(
"Policy impact population family must contain 1 to 120 characters"
)
if self.limit < 1 or self.limit > 500:
raise ValueError("Policy impact population limit must be between 1 and 500")
@dataclass(frozen=True, slots=True)
class PolicyImpactSubject:
"""Provider-owned reference safe for Policy to compare without domain imports."""
module_id: str
resource_type: str
resource_id: str
action: str
label: str | None = None
scope_type: PolicyScopeType | None = None
scope_id: str | None = None
attributes: Mapping[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
for label, value, maximum in (
("module ID", self.module_id, 80),
("resource type", self.resource_type, 80),
("resource ID", self.resource_id, 240),
("action", self.action, 120),
):
if not value.strip() or len(value) > maximum:
raise ValueError(
f"Policy impact subject {label} must contain 1 to {maximum} characters"
)
@property
def key(self) -> tuple[str, str, str, str]:
return (
self.module_id,
self.resource_type,
self.resource_id,
self.action,
)
def to_dict(self) -> dict[str, Any]:
return {
"module_id": self.module_id,
"resource_type": self.resource_type,
"resource_id": self.resource_id,
"action": self.action,
"label": self.label,
"scope_type": self.scope_type,
"scope_id": self.scope_id,
"attributes": dict(self.attributes),
}
@dataclass(frozen=True, slots=True)
class PolicyImpactSubjectBatch:
provider_id: str
subjects: tuple[PolicyImpactSubject, ...] = ()
state: PolicyImpactPopulationState = "complete"
total_available: int | None = None
explanation: str | None = None
def __post_init__(self) -> None:
if not self.provider_id.strip() or len(self.provider_id) > 120:
raise ValueError(
"Policy impact provider ID must contain 1 to 120 characters"
)
if len(self.subjects) > 500:
raise ValueError("Policy impact providers may return at most 500 subjects")
if len({subject.key for subject in self.subjects}) != len(self.subjects):
raise ValueError("Policy impact provider returned duplicate subjects")
if self.total_available is not None and self.total_available < len(self.subjects):
raise ValueError(
"Policy impact population total cannot be smaller than its subjects"
)
if self.state == "unavailable" and not self.explanation:
raise ValueError("Unavailable policy impact populations need an explanation")
def to_dict(self, *, include_subjects: bool = True) -> dict[str, Any]:
return {
"provider_id": self.provider_id,
"state": self.state,
"returned": len(self.subjects),
"total_available": self.total_available,
"explanation": self.explanation,
"subjects": (
[subject.to_dict() for subject in self.subjects]
if include_subjects
else []
),
}
@runtime_checkable
class PolicyImpactSubjectProvider(Protocol):
provider_id: str
supported_policy_families: tuple[str, ...]
def collect_policy_impact_subjects(
self,
session: object | None = None,
*,
request: PolicyImpactPopulationRequest,
) -> PolicyImpactSubjectBatch: ...
def policy_impact_subject_provider(
registry: object | None,
provider_id: str,
) -> PolicyImpactSubjectProvider | None:
clean_provider_id = provider_id.strip()
if not clean_provider_id or registry is None:
return None
capability_name = f"{CAPABILITY_POLICY_IMPACT_SUBJECT_PREFIX}{clean_provider_id}"
if (
not hasattr(registry, "has_capability")
or not registry.has_capability(capability_name)
):
return None
capability = registry.capability(capability_name)
if not isinstance(capability, PolicyImpactSubjectProvider):
return None
return capability
@dataclass(frozen=True, slots=True)
class CampaignArchiveEncryptionRequest:
"""Context required to resolve one Campaign archive-encryption ceiling.
The owning module supplies the stable Campaign and owner references. Policy
owns hierarchy evaluation; Campaign owns archive configuration and evidence.
"""
tenant_id: str
campaign_id: str
owner_type: Literal["user", "group"] | None = None
owner_id: str | None = None
@dataclass(frozen=True, slots=True)
class CampaignArchiveEncryptionDecision:
allowed_password_encryption_methods: frozenset[CampaignArchiveEncryptionMethod]
allowed_password_delivery_channels: frozenset[
CampaignArchivePasswordDeliveryChannel
]
policy_hash: str
source_path: tuple[PolicySourceStep, ...] = ()
reason: str | None = None
diagnostics: tuple[Mapping[str, Any], ...] = ()
def to_dict(self) -> dict[str, Any]:
return {
"allowed_password_encryption_methods": sorted(
self.allowed_password_encryption_methods
),
"allowed_password_delivery_channels": sorted(
self.allowed_password_delivery_channels
),
"policy_hash": self.policy_hash,
"source_path": [step.to_dict() for step in self.source_path],
"reason": self.reason,
"diagnostics": [dict(item) for item in self.diagnostics],
}
@runtime_checkable
class CampaignArchiveEncryptionPolicy(Protocol):
def resolve_campaign_archive_encryption(
self,
session: object | None = None,
*,
request: CampaignArchiveEncryptionRequest,
) -> CampaignArchiveEncryptionDecision: ...
def campaign_archive_encryption_policy(
registry: object | None,
) -> CampaignArchiveEncryptionPolicy | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION)
):
return None
capability = registry.capability(CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION)
return (
capability
if isinstance(capability, CampaignArchiveEncryptionPolicy)
else None
)
@dataclass(frozen=True, slots=True)
class FunctionAssignmentGovernanceRequest:
tenant_id: str
+58
View File
@@ -15,6 +15,7 @@ CAPABILITY_POSTBOX_MESSAGES = "postbox.messages"
CAPABILITY_POSTBOX_DELIVERY = "postbox.delivery"
CAPABILITY_POSTBOX_EVIDENCE = "postbox.evidence"
CAPABILITY_POSTBOX_ROUTING = "postbox.routing"
CAPABILITY_POSTBOX_PORTAL = "postbox.portal_projection"
PostboxAction = Literal[
"discover",
@@ -171,6 +172,11 @@ class PostboxDirectoryEntryRef:
template_revision_id: str | None = None
holder_count: int = 0
vacant: bool = True
encryption_profile: str = "plaintext_v1"
key_epoch: int = 1
encryption_vault_id: str | None = None
protection_policy: Mapping[str, object] = field(default_factory=dict)
grouping_policy: Mapping[str, object] = field(default_factory=dict)
access: PostboxAccessDecisionRef | None = None
resource_revision: int = 1
etag: str | None = None
@@ -259,6 +265,9 @@ class PostboxMessageAuthoringRequest:
idempotency_key: str
subject: str
body_text: str | None = None
ciphertext_ref: str | None = None
signed_manifest_ref: str | None = None
wrapped_keys: tuple[PostboxWrappedKeyRef, ...] = ()
classification: str = "internal"
participants: tuple[PostboxParticipantRef, ...] = ()
attachments: tuple[PostboxAttachmentRef, ...] = ()
@@ -300,6 +309,7 @@ class PostboxDeliveryRequest:
body_text: str | None = None
sender_label: str | None = None
classification: str = "internal"
action_required: bool = False
participants: tuple[PostboxParticipantRef, ...] = ()
attachments: tuple[PostboxAttachmentRef, ...] = ()
expires_at: datetime | None = None
@@ -323,6 +333,14 @@ class PostboxDeliveryResult:
evidence: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxPortalEntryRef:
postbox: PostboxDirectoryEntryRef
unread_count: int = 0
latest_message_at: datetime | None = None
route_path: str = "/postbox"
class PostboxDeliveryRejected(RuntimeError):
"""A delivery was rejected before the provider accepted any effect."""
@@ -494,6 +512,31 @@ class PostboxRoutingProvider(Protocol):
) -> Mapping[str, object]:
...
def reconcile_notification_lifecycle(
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 50,
) -> Mapping[str, object]:
"""Reconcile assignment-derived Postbox notification facts."""
...
@runtime_checkable
class PostboxPortalProjectionProvider(Protocol):
"""Project portal-enabled Postboxes without transferring access ownership."""
def list_portal_entries(
self,
session: object,
principal: object,
*,
tenant_id: str,
limit: int = 100,
) -> Sequence[PostboxPortalEntryRef]: ...
def _postbox_provider(
registry: object | None,
@@ -573,3 +616,18 @@ def postbox_routing_provider(
provider_type=PostboxRoutingProvider,
)
return provider if isinstance(provider, PostboxRoutingProvider) else None
def postbox_portal_projection_provider(
registry: object | None,
) -> PostboxPortalProjectionProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_PORTAL,
provider_type=PostboxPortalProjectionProvider,
)
return (
provider
if isinstance(provider, PostboxPortalProjectionProvider)
else None
)
+369
View File
@@ -0,0 +1,369 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
CAPABILITY_RECORDS_FILING = "records.filing"
CAPABILITY_RECORD_SOURCE_PREFIX = "records.source."
CAPABILITY_RECORD_ARCHIVE_PREFIX = "records.archive."
RecordSourceAuthority = Literal[
"native_authoritative",
"external_authoritative",
"external_mirror",
"governed_sync",
"governance_overlay",
"linked_reference",
]
RecordArchiveOutcome = Literal["accepted", "rejected", "outcome_unknown"]
_RECORD_SOURCE_AUTHORITIES = {
"native_authoritative",
"external_authoritative",
"external_mirror",
"governed_sync",
"governance_overlay",
"linked_reference",
}
_RECORD_ARCHIVE_OUTCOMES = {"accepted", "rejected", "outcome_unknown"}
class RecordContractError(ValueError):
"""Stable error for provider-neutral record filing operations."""
@dataclass(frozen=True, slots=True)
class RecordSourceLocator:
"""Exact source revision requested for filing into a record."""
tenant_id: str
source_module: str
resource_type: str
resource_id: str
source_revision: str
metadata: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
_require_text_fields(
self,
"tenant_id",
"source_module",
"resource_type",
"resource_id",
"source_revision",
)
if len(self.resource_id) > 500 or len(self.source_revision) > 255:
raise RecordContractError("Record source identity is too long.")
@dataclass(frozen=True, slots=True)
class RecordSourceReference:
"""Provider-resolved immutable source metadata safe to preserve in Records."""
locator: RecordSourceLocator
label: str
authority_mode: RecordSourceAuthority = "linked_reference"
content_sha256: str | None = None
content_type: str | None = None
size_bytes: int | None = None
valid_from: datetime | None = None
valid_to: datetime | None = None
recorded_at: datetime | None = None
launch_url: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
if not self.label.strip():
raise RecordContractError("Record source references require a label.")
if len(self.label) > 500:
raise RecordContractError(
"Record source labels are limited to 500 characters."
)
if self.size_bytes is not None and self.size_bytes < 0:
raise RecordContractError("Record source sizes cannot be negative.")
if self.content_sha256 is not None:
digest = self.content_sha256.removeprefix("sha256:")
if len(digest) != 64 or any(
character not in "0123456789abcdefABCDEF" for character in digest
):
raise RecordContractError(
"Record source SHA-256 digests must be hexadecimal."
)
if self.valid_from and self.valid_to and self.valid_to <= self.valid_from:
raise RecordContractError(
"Record source valid_to must be after valid_from."
)
@dataclass(frozen=True, slots=True)
class RecordFilingRequest:
tenant_id: str
record_id: str
source: RecordSourceLocator
purpose: str
filing_reason: str
idempotency_key: str
volume_id: str | None = None
relationship: str = "contains"
institutional_context: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
_require_text_fields(
self,
"tenant_id",
"record_id",
"purpose",
"filing_reason",
"idempotency_key",
"relationship",
)
if self.source.tenant_id != self.tenant_id:
raise RecordContractError("Record filing cannot cross tenants.")
if len(self.purpose) > 255 or len(self.filing_reason) > 2_000:
raise RecordContractError("Record filing purpose or reason is too long.")
if len(self.idempotency_key) > 255:
raise RecordContractError(
"Record filing idempotency keys are limited to 255 characters."
)
@dataclass(frozen=True, slots=True)
class RecordFilingResult:
record_id: str
item_id: str
sequence: int
source: RecordSourceReference
filed_at: datetime
replayed: bool = False
@dataclass(frozen=True, slots=True)
class RecordTransferPackage:
"""Exact, digest-bound package prepared by Records for one provider profile."""
tenant_id: str
package_id: str
record_id: str
record_revision: int
profile: str
manifest_sha256: str
manifest: Mapping[str, object]
def __post_init__(self) -> None:
_require_text_fields(
self,
"tenant_id",
"package_id",
"record_id",
"profile",
"manifest_sha256",
)
if self.record_revision < 1:
raise RecordContractError("Record transfer revisions must be positive.")
_require_sha256(self.manifest_sha256, "Record transfer manifest")
@dataclass(frozen=True, slots=True)
class RecordArchiveProviderState:
provider_id: str
label: str
profiles: tuple[str, ...]
authority_modes: tuple[RecordSourceAuthority, ...]
healthy: bool
checked_at: datetime
last_success_at: datetime | None = None
freshness_seconds: int | None = None
limitations: tuple[str, ...] = ()
simulated: bool = False
def __post_init__(self) -> None:
_require_text_fields(self, "provider_id", "label")
if not self.profiles or any(not item.strip() for item in self.profiles):
raise RecordContractError(
"Record archive providers require at least one profile."
)
if not self.authority_modes:
raise RecordContractError(
"Record archive providers require an authority mode."
)
if any(mode not in _RECORD_SOURCE_AUTHORITIES for mode in self.authority_modes):
raise RecordContractError(
"Record archive providers declared an invalid authority mode."
)
if self.freshness_seconds is not None and self.freshness_seconds < 0:
raise RecordContractError(
"Record archive provider freshness cannot be negative."
)
@dataclass(frozen=True, slots=True)
class RecordArchiveTransferRequest:
package: RecordTransferPackage
purpose: str
idempotency_key: str
institutional_context: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
_require_text_fields(self, "purpose", "idempotency_key")
if len(self.purpose) > 255 or len(self.idempotency_key) > 255:
raise RecordContractError(
"Record archive purpose or idempotency key is too long."
)
@dataclass(frozen=True, slots=True)
class RecordArchiveReceipt:
provider_id: str
package_id: str
outcome: RecordArchiveOutcome
observed_at: datetime
receipt_sha256: str
external_reference: str | None = None
retry_safe: bool = False
simulated: bool = False
metadata: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
_require_text_fields(
self,
"provider_id",
"package_id",
"outcome",
"receipt_sha256",
)
_require_sha256(self.receipt_sha256, "Record archive receipt")
if self.outcome not in _RECORD_ARCHIVE_OUTCOMES:
raise RecordContractError("Record archive receipt outcome is invalid.")
if self.outcome == "outcome_unknown" and self.retry_safe:
raise RecordContractError(
"Unknown archive outcomes cannot be declared retry-safe."
)
@runtime_checkable
class RecordSourceProvider(Protocol):
provider_id: str
def resource_types(self) -> Sequence[str]: ...
def resolve(
self,
session: object,
principal: object,
*,
locator: RecordSourceLocator,
purpose: str,
) -> RecordSourceReference:
"""Resolve one currently authorized, exact source revision."""
@runtime_checkable
class RecordFilingService(Protocol):
def file(
self,
session: object,
principal: object,
*,
request: RecordFilingRequest,
) -> RecordFilingResult: ...
@runtime_checkable
class RecordArchiveProvider(Protocol):
provider_id: str
def state(self) -> RecordArchiveProviderState: ...
def dispatch(
self,
session: object,
principal: object,
*,
request: RecordArchiveTransferRequest,
) -> RecordArchiveReceipt:
"""Dispatch one prepared package without retrying an unknown outcome."""
def record_source_capability(source_module: str) -> str:
normalized = _capability_suffix(source_module, "source module")
return f"{CAPABILITY_RECORD_SOURCE_PREFIX}{normalized}"
def record_archive_capability(provider_id: str) -> str:
normalized = _capability_suffix(provider_id, "archive provider")
return f"{CAPABILITY_RECORD_ARCHIVE_PREFIX}{normalized}"
def record_source_capabilities(registry: object | None) -> tuple[str, ...]:
if registry is None or not hasattr(registry, "capability_names"):
return ()
return tuple(
name
for name in registry.capability_names()
if str(name).startswith(CAPABILITY_RECORD_SOURCE_PREFIX)
)
def record_archive_capabilities(registry: object | None) -> tuple[str, ...]:
if registry is None or not hasattr(registry, "capability_names"):
return ()
return tuple(
name
for name in registry.capability_names()
if str(name).startswith(CAPABILITY_RECORD_ARCHIVE_PREFIX)
)
def _require_text_fields(value: object, *field_names: str) -> None:
for field_name in field_names:
if not str(getattr(value, field_name, "") or "").strip():
raise RecordContractError(
f"Record contract field {field_name} is required."
)
def _capability_suffix(value: str, label: str) -> str:
normalized = value.strip().lower()
if not normalized or any(
character not in "abcdefghijklmnopqrstuvwxyz0123456789_-"
for character in normalized
):
raise RecordContractError(f"Record {label} identifiers are invalid.")
return normalized
def _require_sha256(value: str, label: str) -> None:
digest = value.removeprefix("sha256:")
if len(digest) != 64 or any(
character not in "0123456789abcdefABCDEF" for character in digest
):
raise RecordContractError(f"{label} SHA-256 must be hexadecimal.")
__all__ = [
"CAPABILITY_RECORD_ARCHIVE_PREFIX",
"CAPABILITY_RECORDS_FILING",
"CAPABILITY_RECORD_SOURCE_PREFIX",
"RecordArchiveOutcome",
"RecordArchiveProvider",
"RecordArchiveProviderState",
"RecordArchiveReceipt",
"RecordArchiveTransferRequest",
"RecordContractError",
"RecordFilingRequest",
"RecordFilingResult",
"RecordFilingService",
"RecordSourceAuthority",
"RecordSourceLocator",
"RecordSourceProvider",
"RecordSourceReference",
"RecordTransferPackage",
"record_archive_capabilities",
"record_archive_capability",
"record_source_capabilities",
"record_source_capability",
]
File diff suppressed because it is too large Load Diff
+258
View File
@@ -0,0 +1,258 @@
from __future__ import annotations
from collections.abc import Callable, Mapping
from datetime import datetime
from typing import Any
from govoplan_core.core.operations import (
RuntimeWorkState,
RuntimeWorkStatus,
RuntimeWorkStatusContext,
)
from govoplan_core.settings import settings
QueueDepthReader = Callable[[list[str]], Mapping[str, int | None]]
def celery_runtime_work_status(context: RuntimeWorkStatusContext) -> RuntimeWorkStatus:
"""Observe the built-in Celery backend without exposing it to Ops."""
queues = _configured_queues()
backend_configured = bool(str(settings.redis_url or "").strip())
if not settings.celery_enabled:
return _status(
context,
enabled=False,
configured=backend_configured,
state="disabled",
detail="Background workers are intentionally disabled.",
active_workers=0,
queue_depths={queue: None for queue in queues},
active_work=0,
reserved_work=0,
guidance=(
"Synchronous development paths may be used in development. "
"Enable and monitor workers before production queue-backed work."
if context.profile in {"development", "local-dev"}
else "Enable a worker backend before accepting queue-backed work."
),
)
if not backend_configured or not queues:
return _status(
context,
enabled=True,
configured=False,
state="unconfigured",
detail="Background workers are enabled but their backend or queue list is not configured.",
queue_depths={queue: None for queue in queues},
guidance="Configure the broker and an explicit queue list, then start the required worker pools.",
)
try:
from govoplan_core.celery_app import celery
inspector = celery.control.inspect(timeout=0.75)
return collect_celery_runtime_work_status(
context,
inspector=inspector,
queues=queues,
queue_depth_reader=_redis_queue_depths,
)
except Exception: # noqa: BLE001 - status must isolate and sanitize provider failures.
return _status(
context,
enabled=True,
configured=True,
state="unreachable",
detail="The configured worker backend did not return bounded status evidence.",
queue_depths={queue: None for queue in queues},
guidance="Verify broker reachability and worker processes; do not infer health from missing metrics.",
)
def collect_celery_runtime_work_status(
context: RuntimeWorkStatusContext,
*,
inspector: Any,
queues: list[str],
queue_depth_reader: QueueDepthReader,
) -> RuntimeWorkStatus:
replies = inspector.ping() or {}
if not isinstance(replies, Mapping) or not replies:
state = "starting" if _fresh_worker_nodes(context) else "unreachable"
return _status(
context,
enabled=True,
configured=True,
state=state,
detail=(
"Worker processes are starting but have not answered the bounded status probe."
if state == "starting"
else "No configured worker answered the bounded status probe."
),
active_workers=0,
queue_depths={queue: None for queue in queues},
guidance="Wait for startup or verify worker and broker connectivity.",
)
active_queues_by_worker = inspector.active_queues() or {}
active_by_worker = inspector.active() or {}
reserved_by_worker = inspector.reserved() or {}
active_queues = sorted(
{
str(queue.get("name"))
for worker_queues in active_queues_by_worker.values()
if isinstance(worker_queues, list)
for queue in worker_queues
if isinstance(queue, Mapping) and queue.get("name")
}
)
missing_queues = sorted(set(queues) - set(active_queues))
active_work = _task_count(active_by_worker)
reserved_work = _task_count(reserved_by_worker)
try:
measured_depths = dict(queue_depth_reader(queues))
except Exception: # noqa: BLE001 - queue depth remains explicitly unsupported.
measured_depths = {}
queue_depths = {
queue: _bounded_count(measured_depths.get(queue)) for queue in queues
}
known_depth = sum(value for value in queue_depths.values() if value is not None)
worker_nodes = _worker_nodes(context)
stale_nodes = [node for node in worker_nodes if node.get("stale") is True]
latest_heartbeat = _latest_heartbeat(worker_nodes)
if stale_nodes and len(stale_nodes) >= len(worker_nodes) > 0:
state = "stale"
detail = "All registered worker heartbeats are stale."
guidance = "Restore worker heartbeats or replace the stale worker incarnations."
elif missing_queues or stale_nodes:
state = "degraded"
detail = "Worker status is partial: a queue lacks a consumer or a registered worker is stale."
guidance = "Restore the missing queue consumers and investigate stale worker heartbeats."
elif active_work + reserved_work + known_depth > 0:
state = "busy"
detail = "Workers are processing or waiting to process queued work."
guidance = "Monitor queue age and failures; scale only within configured provider limits."
elif all(value is not None for value in queue_depths.values()):
state = "idle"
detail = "Workers are available and all measured queues are empty."
guidance = "No action is required."
else:
state = "healthy"
detail = "Workers answered and cover all configured queues; queue depth is unavailable."
guidance = "Treat queue depth as unavailable, not empty."
return _status(
context,
enabled=True,
configured=True,
state=state,
detail=detail,
active_workers=len(replies),
last_heartbeat_at=latest_heartbeat,
queue_depths=queue_depths,
active_work=active_work,
reserved_work=reserved_work,
guidance=guidance,
)
def _status(
context: RuntimeWorkStatusContext,
*,
enabled: bool,
configured: bool,
state: RuntimeWorkState,
detail: str,
active_workers: int | None = None,
last_heartbeat_at: datetime | None = None,
queue_depths: Mapping[str, int | None] | None = None,
active_work: int | None = None,
reserved_work: int | None = None,
guidance: str,
) -> RuntimeWorkStatus:
return RuntimeWorkStatus(
provider_id="core.celery",
label="Background workers",
backend="Celery",
enabled=enabled,
configured=configured,
state=state,
detail=detail,
observed_at=context.observed_at,
active_workers=active_workers,
last_heartbeat_at=last_heartbeat_at,
queue_depths=queue_depths or {},
active_work=active_work,
reserved_work=reserved_work,
failures=None,
stale_after_seconds=context.stale_after_seconds,
guidance=guidance,
)
def _configured_queues() -> list[str]:
return sorted(
{
item.strip()
for item in str(settings.celery_queues or "").split(",")
if item.strip()
}
)
def _redis_queue_depths(queues: list[str]) -> Mapping[str, int | None]:
from redis import Redis
client = Redis.from_url(
settings.redis_url,
socket_connect_timeout=0.75,
socket_timeout=0.75,
)
pipeline = client.pipeline(transaction=False)
for queue in queues:
pipeline.llen(queue)
values = pipeline.execute()
return {
queue: _bounded_count(value)
for queue, value in zip(queues, values, strict=True)
}
def _task_count(tasks_by_worker: object) -> int:
if not isinstance(tasks_by_worker, Mapping):
return 0
return sum(
len(tasks) for tasks in tasks_by_worker.values() if isinstance(tasks, list)
)
def _bounded_count(value: object) -> int | None:
if isinstance(value, bool) or not isinstance(value, int | float):
return None
return max(0, int(value))
def _worker_nodes(context: RuntimeWorkStatusContext) -> list[Mapping[str, object]]:
return [node for node in context.runtime_nodes if node.get("role") == "worker"]
def _fresh_worker_nodes(context: RuntimeWorkStatusContext) -> list[Mapping[str, object]]:
return [node for node in _worker_nodes(context) if node.get("stale") is not True]
def _latest_heartbeat(nodes: list[Mapping[str, object]]) -> datetime | None:
values: list[datetime] = []
for node in nodes:
raw = node.get("last_heartbeat_at")
if isinstance(raw, datetime):
values.append(raw)
continue
if isinstance(raw, str):
try:
values.append(datetime.fromisoformat(raw.replace("Z", "+00:00")))
except ValueError:
continue
return max(values) if values else None
@@ -0,0 +1,621 @@
from __future__ import annotations
import hashlib
import json
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Literal, Protocol, runtime_checkable
SEMANTIC_DOCUMENTATION_SUBJECT_CAPABILITY_PREFIX = (
"documentation.semantic_subjects."
)
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION = "1"
SemanticDocumentationSubjectAvailability = Literal[
"available",
"changed",
"superseded",
"missing",
"temporarily_unavailable",
]
_MODULE_ID_RE = re.compile(r"^[a-z][a-z0-9_]{0,79}$")
_KIND_RE = re.compile(r"^[a-z][a-z0-9_.-]{0,119}$")
_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:@-]{0,254}$")
_LOCALE_RE = re.compile(r"^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$")
_REASON_CODE_RE = re.compile(r"^[a-z][a-z0-9_]{0,79}$")
_SHA256_RE = re.compile(r"^(?:sha256:)?[0-9a-fA-F]{64}$")
class SemanticDocumentationContractError(ValueError):
"""Raised when a semantic-documentation subject violates the Core contract."""
@dataclass(frozen=True, slots=True)
class SemanticDocumentationSubjectAnchor:
kind: str
id: str
def __post_init__(self) -> None:
_require_match(self.kind, _KIND_RE, "Semantic subject anchor kind")
_require_match(self.id, _IDENTIFIER_RE, "Semantic subject anchor id")
def to_dict(self) -> dict[str, str]:
return {"kind": self.kind, "id": self.id}
@classmethod
def from_mapping(
cls, value: Mapping[str, object]
) -> SemanticDocumentationSubjectAnchor:
_require_keys(value, {"kind", "id"}, "Semantic subject anchor")
return cls(kind=_required_text(value, "kind"), id=_required_text(value, "id"))
@dataclass(frozen=True, slots=True)
class SemanticDocumentationSubjectReference:
module_id: str
tenant_id: str
subject_kind: str
subject_id: str
anchor: SemanticDocumentationSubjectAnchor | None = None
observed_revision: str | None = None
observed_fingerprint: str | None = None
def __post_init__(self) -> None:
_require_match(self.module_id, _MODULE_ID_RE, "Semantic subject module id")
_require_match(self.tenant_id, _IDENTIFIER_RE, "Semantic subject tenant id")
_require_match(self.subject_kind, _KIND_RE, "Semantic subject kind")
_require_match(self.subject_id, _IDENTIFIER_RE, "Semantic subject id")
_optional_text(self.observed_revision, "Semantic subject observed revision", 255)
if self.observed_fingerprint is not None and not _SHA256_RE.fullmatch(
self.observed_fingerprint
):
raise SemanticDocumentationContractError(
"Semantic subject observed fingerprint must be a SHA-256 digest."
)
@property
def stable_key(self) -> str:
identity = {
"anchor": self.anchor.to_dict() if self.anchor else None,
"module_id": self.module_id,
"subject_id": self.subject_id,
"subject_kind": self.subject_kind,
"tenant_id": self.tenant_id,
}
encoded = json.dumps(
identity, ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode("utf-8")
return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
def to_dict(self) -> dict[str, object]:
return {
"module_id": self.module_id,
"tenant_id": self.tenant_id,
"subject_kind": self.subject_kind,
"subject_id": self.subject_id,
"anchor": self.anchor.to_dict() if self.anchor else None,
"observed_revision": self.observed_revision,
"observed_fingerprint": self.observed_fingerprint,
}
@classmethod
def from_mapping(
cls, value: Mapping[str, object]
) -> SemanticDocumentationSubjectReference:
_require_keys(
value,
{
"module_id",
"tenant_id",
"subject_kind",
"subject_id",
"anchor",
"observed_revision",
"observed_fingerprint",
},
"Semantic subject reference",
)
raw_anchor = value.get("anchor")
if raw_anchor is not None and not isinstance(raw_anchor, Mapping):
raise SemanticDocumentationContractError(
"Semantic subject anchor must be an object."
)
return cls(
module_id=_required_text(value, "module_id"),
tenant_id=_required_text(value, "tenant_id"),
subject_kind=_required_text(value, "subject_kind"),
subject_id=_required_text(value, "subject_id"),
anchor=(
SemanticDocumentationSubjectAnchor.from_mapping(raw_anchor)
if isinstance(raw_anchor, Mapping)
else None
),
observed_revision=_mapping_optional_text(value, "observed_revision"),
observed_fingerprint=_mapping_optional_text(
value, "observed_fingerprint"
),
)
@dataclass(frozen=True, slots=True)
class SemanticDocumentationBreadcrumb:
label: str
subject_kind: str
subject_id: str
anchor: SemanticDocumentationSubjectAnchor | None = None
def __post_init__(self) -> None:
_required_bounded_text(self.label, "Semantic subject breadcrumb label", 300)
_require_match(self.subject_kind, _KIND_RE, "Semantic breadcrumb kind")
_require_match(self.subject_id, _IDENTIFIER_RE, "Semantic breadcrumb id")
def to_dict(self) -> dict[str, object]:
return {
"label": self.label,
"subject_kind": self.subject_kind,
"subject_id": self.subject_id,
"anchor": self.anchor.to_dict() if self.anchor else None,
}
@dataclass(frozen=True, slots=True)
class SemanticDocumentationSubjectDescriptor:
reference: SemanticDocumentationSubjectReference
labels: Mapping[str, str]
descriptions: Mapping[str, str] = field(default_factory=dict)
breadcrumbs: tuple[SemanticDocumentationBreadcrumb, ...] = ()
route: str | None = None
route_anchor: str | None = None
audience: tuple[str, ...] = ()
classification: str = "internal"
required_scopes: tuple[str, ...] = ()
def __post_init__(self) -> None:
if not self.reference.observed_revision:
raise SemanticDocumentationContractError(
"Semantic subject descriptors require a current revision."
)
if not self.reference.observed_fingerprint:
raise SemanticDocumentationContractError(
"Semantic subject descriptors require a current fingerprint."
)
_localized_text(self.labels, "Semantic subject labels", required=True, limit=300)
_localized_text(
self.descriptions,
"Semantic subject descriptions",
required=False,
limit=2_000,
)
if len(self.breadcrumbs) > 32:
raise SemanticDocumentationContractError(
"Semantic subject breadcrumbs are limited to 32 items."
)
if self.route is not None:
_optional_text(self.route, "Semantic subject route", 2_000)
if not self.route.startswith("/") or self.route.startswith("//"):
raise SemanticDocumentationContractError(
"Semantic subject routes must be local absolute paths."
)
if self.route_anchor is not None:
_require_match(
self.route_anchor,
_IDENTIFIER_RE,
"Semantic subject route anchor",
)
_text_tuple(self.audience, "Semantic subject audience", maximum=32)
_required_bounded_text(
self.classification, "Semantic subject classification", 120
)
_text_tuple(
self.required_scopes, "Semantic subject required scopes", maximum=64
)
def to_dict(self) -> dict[str, object]:
return {
"reference": self.reference.to_dict(),
"labels": dict(self.labels),
"descriptions": dict(self.descriptions),
"breadcrumbs": [item.to_dict() for item in self.breadcrumbs],
"route": self.route,
"route_anchor": self.route_anchor,
"audience": list(self.audience),
"classification": self.classification,
"required_scopes": list(self.required_scopes),
}
@dataclass(frozen=True, slots=True)
class SemanticDocumentationSubjectResolution:
requested_reference: SemanticDocumentationSubjectReference
availability: SemanticDocumentationSubjectAvailability
subject: SemanticDocumentationSubjectDescriptor | None = None
superseded_by: SemanticDocumentationSubjectReference | None = None
reason_code: str | None = None
def __post_init__(self) -> None:
if self.reason_code is not None:
_require_match(
self.reason_code, _REASON_CODE_RE, "Semantic resolution reason code"
)
if self.availability in {"available", "changed"}:
if self.subject is None:
raise SemanticDocumentationContractError(
f"Semantic subject {self.availability} resolutions require a descriptor."
)
if (
self.subject.reference.stable_key
!= self.requested_reference.stable_key
):
raise SemanticDocumentationContractError(
"Semantic subject resolution changed the requested identity."
)
changed = _reference_changed(
self.requested_reference, self.subject.reference
)
if self.availability == "available" and changed:
raise SemanticDocumentationContractError(
"Changed semantic subjects must use the changed availability."
)
if self.availability == "changed" and not changed:
raise SemanticDocumentationContractError(
"Changed semantic subject resolutions require a revision or fingerprint change."
)
elif self.subject is not None:
raise SemanticDocumentationContractError(
f"Semantic subject {self.availability} resolutions cannot include a descriptor."
)
if self.availability == "superseded":
if self.superseded_by is None:
raise SemanticDocumentationContractError(
"Superseded semantic subjects require a replacement reference."
)
elif self.superseded_by is not None:
raise SemanticDocumentationContractError(
"Only superseded semantic subjects may declare a replacement."
)
if self.availability in {"missing", "temporarily_unavailable"} and not self.reason_code:
raise SemanticDocumentationContractError(
f"Semantic subject {self.availability} resolutions require a reason code."
)
def to_dict(self) -> dict[str, object]:
return {
"requested_reference": self.requested_reference.to_dict(),
"availability": self.availability,
"subject": self.subject.to_dict() if self.subject else None,
"superseded_by": (
self.superseded_by.to_dict() if self.superseded_by else None
),
"reason_code": self.reason_code,
}
@dataclass(frozen=True, slots=True)
class SemanticDocumentationSubjectQuery:
tenant_id: str
query: str = ""
subject_kinds: tuple[str, ...] = ()
limit: int = 50
cursor: str | None = None
def __post_init__(self) -> None:
_require_match(self.tenant_id, _IDENTIFIER_RE, "Semantic query tenant id")
if not isinstance(self.query, str) or len(self.query) > 300:
raise SemanticDocumentationContractError(
"Semantic subject query must be text of at most 300 characters."
)
if self.query:
_required_bounded_text(self.query, "Semantic subject query", 300)
if not 1 <= self.limit <= 200:
raise SemanticDocumentationContractError(
"Semantic subject query limit must be between 1 and 200."
)
_text_tuple(self.subject_kinds, "Semantic query subject kinds", maximum=100)
for kind in self.subject_kinds:
_require_match(kind, _KIND_RE, "Semantic query subject kind")
_optional_text(self.cursor, "Semantic query cursor", 1_000)
@dataclass(frozen=True, slots=True)
class SemanticDocumentationSubjectPage:
subjects: tuple[SemanticDocumentationSubjectDescriptor, ...] = ()
next_cursor: str | None = None
has_more: bool = False
def __post_init__(self) -> None:
keys = tuple(item.reference.stable_key for item in self.subjects)
if len(keys) != len(set(keys)):
raise SemanticDocumentationContractError(
"Semantic subject pages cannot contain duplicate identities."
)
_optional_text(self.next_cursor, "Semantic subject page cursor", 1_000)
if self.has_more and not self.next_cursor:
raise SemanticDocumentationContractError(
"Semantic subject pages with more results require a cursor."
)
@runtime_checkable
class SemanticDocumentationSubjectProvider(Protocol):
provider_id: str
module_id: str
contract_version: str
def list_subjects(
self,
session: object,
principal: object,
*,
request: SemanticDocumentationSubjectQuery,
) -> SemanticDocumentationSubjectPage: ...
def resolve_subject(
self,
session: object,
principal: object,
*,
reference: SemanticDocumentationSubjectReference,
) -> SemanticDocumentationSubjectResolution | None:
"""Return None when the principal may not know whether a subject exists."""
def semantic_documentation_subject_capability(module_id: str) -> str:
_require_match(module_id, _MODULE_ID_RE, "Semantic subject module id")
return f"{SEMANTIC_DOCUMENTATION_SUBJECT_CAPABILITY_PREFIX}{module_id}"
def semantic_documentation_subject_provider_names(
registry: object | None,
) -> tuple[str, ...]:
if registry is None or not hasattr(registry, "capability_names"):
return ()
return tuple(
str(name)
for name in registry.capability_names()
if str(name).startswith(SEMANTIC_DOCUMENTATION_SUBJECT_CAPABILITY_PREFIX)
)
def semantic_documentation_subject_providers(
registry: object | None,
) -> tuple[tuple[str, SemanticDocumentationSubjectProvider], ...]:
if registry is None or not hasattr(registry, "capability"):
return ()
providers: list[tuple[str, SemanticDocumentationSubjectProvider]] = []
for capability_name in semantic_documentation_subject_provider_names(registry):
module_id = capability_name.removeprefix(
SEMANTIC_DOCUMENTATION_SUBJECT_CAPABILITY_PREFIX
)
provider = registry.capability(capability_name)
if not isinstance(provider, SemanticDocumentationSubjectProvider):
raise TypeError(
f"Invalid semantic-documentation provider capability: {capability_name}"
)
if provider.module_id != module_id:
raise SemanticDocumentationContractError(
f"Semantic provider module {provider.module_id!r} does not match "
f"capability {capability_name!r}."
)
if (
provider.contract_version
!= SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
):
raise SemanticDocumentationContractError(
f"Unsupported semantic-documentation provider contract: "
f"{provider.contract_version!r}."
)
providers.append((module_id, provider))
return tuple(providers)
def list_semantic_documentation_subjects(
registry: object | None,
session: object,
principal: object,
*,
request: SemanticDocumentationSubjectQuery,
) -> tuple[tuple[str, SemanticDocumentationSubjectPage], ...]:
if _principal_tenant_id(principal) != request.tenant_id:
return ()
pages: list[tuple[str, SemanticDocumentationSubjectPage]] = []
for module_id, provider in semantic_documentation_subject_providers(registry):
page = provider.list_subjects(
session,
principal,
request=request,
)
if any(
subject.reference.module_id != module_id
or subject.reference.tenant_id != request.tenant_id
for subject in page.subjects
):
raise SemanticDocumentationContractError(
f"Semantic provider {module_id!r} returned a foreign subject."
)
pages.append((module_id, page))
return tuple(pages)
def resolve_semantic_documentation_subject(
registry: object | None,
session: object,
principal: object,
*,
reference: SemanticDocumentationSubjectReference,
) -> SemanticDocumentationSubjectResolution | None:
if _principal_tenant_id(principal) != reference.tenant_id:
return None
capability_name = semantic_documentation_subject_capability(reference.module_id)
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(capability_name)
):
return SemanticDocumentationSubjectResolution(
requested_reference=reference,
availability="temporarily_unavailable",
reason_code="provider_unavailable",
)
provider = registry.capability(capability_name)
if not isinstance(provider, SemanticDocumentationSubjectProvider):
raise TypeError(
f"Invalid semantic-documentation provider capability: {capability_name}"
)
if (
provider.module_id != reference.module_id
or provider.contract_version
!= SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
):
raise SemanticDocumentationContractError(
f"Semantic provider {capability_name!r} does not match the Core contract."
)
result = provider.resolve_subject(
session,
principal,
reference=reference,
)
if result is None:
return None
if result.requested_reference != reference:
raise SemanticDocumentationContractError(
"Semantic provider returned a resolution for another reference."
)
return result
def semantic_documentation_fingerprint(value: object) -> str:
try:
encoded = json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
).encode("utf-8")
except (TypeError, ValueError) as exc:
raise SemanticDocumentationContractError(
"Semantic fingerprint input must be canonical JSON data."
) from exc
return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
def _reference_changed(
requested: SemanticDocumentationSubjectReference,
current: SemanticDocumentationSubjectReference,
) -> bool:
comparisons = (
(requested.observed_revision, current.observed_revision),
(requested.observed_fingerprint, current.observed_fingerprint),
)
return any(expected is not None and expected != actual for expected, actual in comparisons)
def _principal_tenant_id(principal: object) -> str:
return str(getattr(principal, "tenant_id", "") or "")
def _localized_text(
values: Mapping[str, str],
label: str,
*,
required: bool,
limit: int,
) -> None:
if required and not values:
raise SemanticDocumentationContractError(f"{label} are required.")
if len(values) > 20:
raise SemanticDocumentationContractError(f"{label} are limited to 20 locales.")
for locale, value in values.items():
if not _LOCALE_RE.fullmatch(str(locale)):
raise SemanticDocumentationContractError(
f"{label} contain an invalid locale: {locale!r}."
)
_required_bounded_text(value, f"{label} value", limit)
def _text_tuple(values: Sequence[str], label: str, *, maximum: int) -> None:
if len(values) > maximum:
raise SemanticDocumentationContractError(
f"{label} are limited to {maximum} items."
)
normalized = tuple(str(value).strip() for value in values)
if any(not value or len(value) > 255 for value in normalized):
raise SemanticDocumentationContractError(
f"{label} must contain non-empty bounded text."
)
if len(normalized) != len(set(normalized)):
raise SemanticDocumentationContractError(f"{label} must be unique.")
def _require_match(value: str, pattern: re.Pattern[str], label: str) -> None:
if not isinstance(value, str) or not pattern.fullmatch(value):
raise SemanticDocumentationContractError(f"{label} is invalid.")
def _required_bounded_text(value: str, label: str, limit: int) -> None:
if not isinstance(value, str) or not value.strip() or len(value) > limit:
raise SemanticDocumentationContractError(
f"{label} must be non-empty and at most {limit} characters."
)
if any(ord(character) < 32 and character not in "\n\t" for character in value):
raise SemanticDocumentationContractError(f"{label} contains control characters.")
def _optional_text(value: str | None, label: str, limit: int) -> None:
if value is not None:
_required_bounded_text(value, label, limit)
def _require_keys(
value: Mapping[str, object], allowed: set[str], label: str
) -> None:
unexpected = sorted(str(key) for key in value if str(key) not in allowed)
if unexpected:
raise SemanticDocumentationContractError(
f"{label} contains unsupported fields: {', '.join(unexpected)}."
)
def _required_text(value: Mapping[str, object], key: str) -> str:
result = value.get(key)
if not isinstance(result, str) or not result.strip():
raise SemanticDocumentationContractError(
f"Semantic subject field {key} is required."
)
return result
def _mapping_optional_text(value: Mapping[str, object], key: str) -> str | None:
result = value.get(key)
if result is None:
return None
if not isinstance(result, str):
raise SemanticDocumentationContractError(
f"Semantic subject field {key} must be text."
)
return result
__all__ = [
"SEMANTIC_DOCUMENTATION_SUBJECT_CAPABILITY_PREFIX",
"SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION",
"SemanticDocumentationBreadcrumb",
"SemanticDocumentationContractError",
"SemanticDocumentationSubjectAnchor",
"SemanticDocumentationSubjectAvailability",
"SemanticDocumentationSubjectDescriptor",
"SemanticDocumentationSubjectPage",
"SemanticDocumentationSubjectProvider",
"SemanticDocumentationSubjectQuery",
"SemanticDocumentationSubjectReference",
"SemanticDocumentationSubjectResolution",
"list_semantic_documentation_subjects",
"resolve_semantic_documentation_subject",
"semantic_documentation_fingerprint",
"semantic_documentation_subject_capability",
"semantic_documentation_subject_provider_names",
"semantic_documentation_subject_providers",
]
+332
View File
@@ -0,0 +1,332 @@
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.modules import ModuleContext
WorkItemStatus = Literal[
"open",
"in_progress",
"deferred",
"blocked",
"completed",
"cancelled",
]
WorkItemPriority = Literal["low", "normal", "high", "urgent"]
WorkAssignmentKind = Literal[
"account",
"group",
"role",
"function",
"function_assignment",
"anyone",
]
WORK_ITEM_CONTRACT_VERSION = "1"
CAPABILITY_TASK_COMMANDS = "tasks.commands"
@dataclass(frozen=True, slots=True)
class WorkAssignmentRef:
kind: WorkAssignmentKind
id: str
label: str | None = None
def __post_init__(self) -> None:
if self.kind not in {
"account",
"group",
"role",
"function",
"function_assignment",
"anyone",
}:
raise ValueError(f"Unsupported work-assignment kind: {self.kind!r}.")
if not self.id.strip():
raise ValueError("Work assignments require an id.")
if len(self.id) > 255:
raise ValueError("Work assignment ids are limited to 255 characters.")
if self.label is not None and len(self.label) > 500:
raise ValueError("Work assignment labels are limited to 500 characters.")
if self.kind == "anyone" and self.id != "*":
raise ValueError("Broad work assignments use the canonical '*' id.")
@dataclass(frozen=True, slots=True)
class WorkSourceRef:
module_id: str
resource_type: str
resource_id: str
revision: str | None = None
url: str | None = None
label: str | None = None
def __post_init__(self) -> None:
required = (self.module_id, self.resource_type, self.resource_id)
if any(not value.strip() for value in required):
raise ValueError("Work source references require module, type, and id.")
limits = {
"module_id": 100,
"resource_type": 100,
"resource_id": 255,
"revision": 255,
"url": 1_500,
"label": 500,
}
for field_name, limit in limits.items():
value = getattr(self, field_name)
if value is not None and len(value) > limit:
raise ValueError(
f"Work source {field_name} is limited to {limit} characters."
)
@dataclass(frozen=True, slots=True)
class WorkItem:
id: str
provider_id: str
owner_module: str
tenant_id: str
title: str
status: WorkItemStatus = "open"
priority: WorkItemPriority = "normal"
summary: str | None = None
required_action: str | None = None
action_url: str | None = None
due_at: datetime | None = None
deferred_until: datetime | None = None
assignments: tuple[WorkAssignmentRef, ...] = ()
sources: tuple[WorkSourceRef, ...] = ()
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
revision: str = "1"
created_at: datetime | None = None
updated_at: datetime | None = None
def __post_init__(self) -> None:
required = {
"id": self.id,
"provider_id": self.provider_id,
"owner_module": self.owner_module,
"tenant_id": self.tenant_id,
"title": self.title,
"revision": self.revision,
}
if any(not value.strip() for value in required.values()):
raise ValueError(
"Work items require stable identity, owner, tenant, and title."
)
limits = {
"id": 255,
"provider_id": 200,
"owner_module": 100,
"tenant_id": 255,
"title": 500,
"summary": 4_000,
"required_action": 500,
"action_url": 1_500,
"revision": 255,
}
for field_name, limit in limits.items():
value = getattr(self, field_name)
if value is not None and len(value) > limit:
raise ValueError(
f"Work item {field_name} is limited to {limit} characters."
)
if len(self.assignments) > 100 or len(self.sources) > 100:
raise ValueError("Work items support at most 100 assignments and sources.")
_validate_action_url(self.action_url)
if self.status not in {
"open",
"in_progress",
"deferred",
"blocked",
"completed",
"cancelled",
}:
raise ValueError(f"Unsupported work-item status: {self.status!r}.")
if self.priority not in {"low", "normal", "high", "urgent"}:
raise ValueError(f"Unsupported work-item priority: {self.priority!r}.")
@dataclass(frozen=True, slots=True)
class WorkItemQuery:
tenant_id: str
statuses: tuple[WorkItemStatus, ...] = (
"open",
"in_progress",
"deferred",
"blocked",
)
priorities: tuple[WorkItemPriority, ...] = ()
provider_ids: tuple[str, ...] = ()
owner_modules: tuple[str, ...] = ()
due_before: datetime | None = None
text: str = ""
limit: int = 100
def __post_init__(self) -> None:
if not self.tenant_id.strip():
raise ValueError("Work-item queries require a tenant.")
if not 1 <= self.limit <= 500:
raise ValueError("Work-item query limits must be between 1 and 500.")
normalized = self.text.strip()
if len(normalized) > 500:
raise ValueError("Work-item query text is limited to 500 characters.")
if len(self.provider_ids) > 50 or len(self.owner_modules) > 50:
raise ValueError("Work-item queries support at most 50 provider filters.")
object.__setattr__(self, "text", normalized)
@dataclass(frozen=True, slots=True)
class WorkItemPage:
items: tuple[WorkItem, ...]
total: int
truncated: bool = False
def __post_init__(self) -> None:
if self.total < len(self.items):
raise ValueError(
"Work-item totals cannot be smaller than the returned page."
)
@runtime_checkable
class WorkItemProvider(Protocol):
def list_items(
self,
session: object,
principal: object,
*,
query: WorkItemQuery,
) -> WorkItemPage:
"""Return only items the current principal may discover and act on."""
@dataclass(frozen=True, slots=True)
class TaskCreateCommand:
tenant_id: str
title: str
idempotency_key: str
summary: str | None = None
priority: WorkItemPriority = "normal"
due_at: datetime | None = None
required_action: str | None = None
action_url: str | None = None
assignments: tuple[WorkAssignmentRef, ...] = ()
sources: tuple[WorkSourceRef, ...] = ()
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
if not self.tenant_id.strip() or not self.title.strip():
raise ValueError("Task commands require a tenant and title.")
if not self.idempotency_key.strip() or len(self.idempotency_key) > 255:
raise ValueError("Task commands require a bounded idempotency key.")
if not self.assignments:
raise ValueError("Explicit tasks require at least one assignment.")
if self.priority not in {"low", "normal", "high", "urgent"}:
raise ValueError(f"Unsupported task priority: {self.priority!r}.")
limits = {
"title": 500,
"summary": 4_000,
"required_action": 500,
"action_url": 1_500,
}
for field_name, limit in limits.items():
value = getattr(self, field_name)
if value is not None and len(value) > limit:
raise ValueError(
f"Task command {field_name} is limited to {limit} characters."
)
if len(self.assignments) > 100 or len(self.sources) > 100:
raise ValueError(
"Task commands support at most 100 assignments and sources."
)
_validate_action_url(self.action_url)
def _validate_action_url(value: str | None) -> None:
if value is None:
return
candidate = value.strip()
if not candidate:
return
if (
not candidate.startswith("/")
or candidate.startswith("//")
or "\\" in candidate
or any(ord(character) < 32 or ord(character) == 127 for character in candidate)
):
raise ValueError("Work-item action URLs must be application-relative paths.")
@runtime_checkable
class TaskCommandProvider(Protocol):
def create_task(
self,
session: object,
principal: object,
*,
command: TaskCreateCommand,
) -> WorkItem: ...
WorkItemProviderFactory = Callable[[ModuleContext], WorkItemProvider]
@dataclass(frozen=True, slots=True)
class WorkItemProviderRegistration:
id: str
factory: WorkItemProviderFactory
order: int = 100
def create(self, context: ModuleContext) -> WorkItemProvider:
provider = self.factory(context)
if not isinstance(provider, WorkItemProvider):
raise TypeError(
f"Work-item provider {self.id!r} does not implement WorkItemProvider."
)
return provider
@dataclass(frozen=True, slots=True)
class RegisteredWorkItemProvider:
module_id: str
registration: WorkItemProviderRegistration
def task_command_provider(registry: object | None) -> TaskCommandProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(CAPABILITY_TASK_COMMANDS)
):
return None
provider = registry.capability(CAPABILITY_TASK_COMMANDS)
return provider if isinstance(provider, TaskCommandProvider) else None
__all__ = [
"CAPABILITY_TASK_COMMANDS",
"RegisteredWorkItemProvider",
"TaskCommandProvider",
"TaskCreateCommand",
"WORK_ITEM_CONTRACT_VERSION",
"WorkAssignmentKind",
"WorkAssignmentRef",
"WorkItem",
"WorkItemPage",
"WorkItemPriority",
"WorkItemProvider",
"WorkItemProviderFactory",
"WorkItemProviderRegistration",
"WorkItemQuery",
"WorkItemStatus",
"WorkSourceRef",
"task_command_provider",
]
+39
View File
@@ -8,6 +8,7 @@ from typing import Literal, Protocol, runtime_checkable
CAPABILITY_TEMPLATE_CATALOG = "templates.catalog"
CAPABILITY_TEMPLATE_RENDERER = "templates.renderer"
CAPABILITY_TEMPLATE_CONTENT_LIBRARY = "templates.content_library"
TemplateType = Literal[
"label",
@@ -17,6 +18,7 @@ TemplateType = Literal[
"form_letter",
"list_layout",
"email",
"content_fragment",
"generic",
]
TemplateOutputFormat = Literal["html", "text"]
@@ -79,6 +81,10 @@ class TemplateRevisionRef:
locale: str
required_fields: tuple[TemplateFieldRequirement, ...]
output_profiles: tuple[TemplateOutputProfile, ...]
content_text: str | None = None
content_html: str | None = None
layout: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
published_at: datetime | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@@ -166,6 +172,23 @@ class TemplateRenderResult:
payload: bytes | None = None
@dataclass(frozen=True, slots=True)
class TemplateContentDraftRequest:
"""Provider-neutral request for a reusable text/HTML content draft."""
name: str
template_type: TemplateType
usages: tuple[str, ...]
content_text: str | None = None
content_html: str | None = None
description: str | None = None
locale: str = "de"
scope_type: Literal["tenant", "group", "user"] = "tenant"
scope_id: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
required_fields: tuple[TemplateFieldRequirement, ...] = ()
@runtime_checkable
class TemplateCatalogProvider(Protocol):
def list_templates(
@@ -213,13 +236,29 @@ class TemplateRendererProvider(Protocol):
) -> TemplateRenderResult: ...
@runtime_checkable
class TemplateContentLibraryProvider(Protocol):
"""Create reusable content drafts while Templates retains ownership."""
def create_content_draft(
self,
session: object,
principal: object,
*,
request: TemplateContentDraftRequest,
) -> TemplateRef: ...
__all__ = [
"CAPABILITY_TEMPLATE_CATALOG",
"CAPABILITY_TEMPLATE_CONTENT_LIBRARY",
"CAPABILITY_TEMPLATE_RENDERER",
"TemplateArtifactRef",
"TemplateCatalogProvider",
"TemplateCompatibility",
"TemplateCompatibilityError",
"TemplateContentDraftRequest",
"TemplateContentLibraryProvider",
"TemplateContractError",
"TemplateFieldRequirement",
"TemplateNotFoundError",
+182
View File
@@ -0,0 +1,182 @@
from __future__ import annotations
from contextvars import ContextVar, Token
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Literal
TemporalValidityMode = Literal["current", "at", "all"]
VALIDITY_MODES = frozenset({"current", "at", "all"})
VALIDITY_MODE_HEADER = "X-Govoplan-Validity-Mode"
VALID_AT_HEADER = "X-Govoplan-Valid-At"
RECORDED_AT_HEADER = "X-Govoplan-Recorded-At"
TEMPORAL_EVALUATED_AT_HEADER = "X-Govoplan-Temporal-Evaluated-At"
TEMPORAL_VARY_HEADERS = (
VALIDITY_MODE_HEADER,
VALID_AT_HEADER,
RECORDED_AT_HEADER,
)
class TemporalContextError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class TemporalDataContext:
"""Bitemporal read context.
Valid time answers when a fact applies. Recorded time answers which version
of that fact was known to the system. Authorization remains outside this
context and is always evaluated under the current security state.
"""
validity_mode: TemporalValidityMode = "current"
valid_at: datetime | None = None
recorded_at: datetime | None = None
evaluated_at: datetime = field(default_factory=lambda: datetime.now(UTC))
def __post_init__(self) -> None:
if self.validity_mode not in VALIDITY_MODES:
raise TemporalContextError(
f"Unsupported temporal validity mode: {self.validity_mode!r}."
)
for name in ("valid_at", "recorded_at", "evaluated_at"):
value = getattr(self, name)
if value is not None and value.tzinfo is None:
raise TemporalContextError(f"Temporal {name} must include a timezone.")
if self.validity_mode == "at" and self.valid_at is None:
raise TemporalContextError("Validity mode 'at' requires valid_at.")
if self.validity_mode != "at" and self.valid_at is not None:
raise TemporalContextError(
"valid_at is only permitted when validity mode is 'at'."
)
@property
def validity_instant(self) -> datetime | None:
if self.validity_mode == "all":
return None
if self.validity_mode == "at":
return self.valid_at
return self.evaluated_at
@property
def is_default(self) -> bool:
return self.validity_mode == "current" and self.recorded_at is None
def to_dict(self) -> dict[str, str | None]:
return {
"validity_mode": self.validity_mode,
"valid_at": _datetime_text(self.valid_at),
"recorded_at": _datetime_text(self.recorded_at),
"evaluated_at": _datetime_text(self.evaluated_at),
}
_temporal_context: ContextVar[TemporalDataContext | None] = ContextVar(
"govoplan_temporal_data_context",
default=None,
)
def parse_temporal_data_context(
*,
validity_mode: str | None = None,
valid_at: str | None = None,
recorded_at: str | None = None,
evaluated_at: datetime | None = None,
) -> TemporalDataContext:
clean_mode = (validity_mode or "current").strip().lower()
if clean_mode not in VALIDITY_MODES:
raise TemporalContextError(
"Temporal validity mode must be one of: current, at, all."
)
return TemporalDataContext(
validity_mode=clean_mode, # type: ignore[arg-type]
valid_at=_parse_datetime(valid_at, "valid_at"),
recorded_at=_parse_datetime(recorded_at, "recorded_at"),
evaluated_at=evaluated_at or datetime.now(UTC),
)
def current_temporal_data_context() -> TemporalDataContext:
return _temporal_context.get() or TemporalDataContext()
def bind_temporal_data_context(
context: TemporalDataContext,
) -> Token[TemporalDataContext | None]:
return _temporal_context.set(context)
def reset_temporal_data_context(token: Token[TemporalDataContext | None]) -> None:
_temporal_context.reset(token)
def temporal_revision_matches(
context: TemporalDataContext,
*,
valid_from: datetime | None = None,
valid_to: datetime | None = None,
revision_recorded_at: datetime | None = None,
superseded_at: datetime | None = None,
) -> bool:
cutoff = context.recorded_at
if cutoff is None:
if superseded_at is not None:
return False
else:
if revision_recorded_at is None or revision_recorded_at > cutoff:
return False
if superseded_at is not None and superseded_at <= cutoff:
return False
instant = context.validity_instant
if instant is None:
return True
return (valid_from is None or valid_from <= instant) and (
valid_to is None or valid_to > instant
)
def _parse_datetime(value: str | None, name: str) -> datetime | None:
clean = str(value or "").strip()
if not clean:
return None
if len(clean) > 64:
raise TemporalContextError(f"Temporal {name} is too long.")
normalized = f"{clean[:-1]}+00:00" if clean.endswith(("Z", "z")) else clean
try:
parsed = datetime.fromisoformat(normalized)
except ValueError as exc:
raise TemporalContextError(
f"Temporal {name} must be an ISO 8601 timestamp."
) from exc
if parsed.tzinfo is None:
raise TemporalContextError(f"Temporal {name} must include a timezone.")
return parsed.astimezone(UTC)
def _datetime_text(value: datetime | None) -> str | None:
if value is None:
return None
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
__all__ = [
"RECORDED_AT_HEADER",
"TEMPORAL_EVALUATED_AT_HEADER",
"TEMPORAL_VARY_HEADERS",
"VALIDITY_MODE_HEADER",
"VALID_AT_HEADER",
"TemporalContextError",
"TemporalDataContext",
"TemporalValidityMode",
"bind_temporal_data_context",
"current_temporal_data_context",
"parse_temporal_data_context",
"reset_temporal_data_context",
"temporal_revision_matches",
]
+5 -2
View File
@@ -2,8 +2,8 @@ from __future__ import annotations
import re
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Literal, Protocol, runtime_checkable
from dataclasses import dataclass, field
from typing import Literal, Mapping, Protocol, runtime_checkable
VIEWS_MODULE_ID = "views"
@@ -17,6 +17,8 @@ ViewSurfaceKind = Literal[
"section",
"action",
"selector",
"product_area",
"quick_access",
]
_SURFACE_ID_RE = re.compile(r"^[a-z][a-z0-9_.-]{2,159}$")
@@ -42,6 +44,7 @@ class EffectiveView:
revision_id: str | None
name: str | None
visible_surface_ids: frozenset[str]
presentation: Mapping[str, object] = field(default_factory=dict)
locked: bool = False
projection_active: bool = False
provenance: tuple[dict[str, object], ...] = ()
+1 -1
View File
@@ -73,7 +73,7 @@ def bootstrap_dev_data(
) -> BootstrapResult:
tenant = session.query(Tenant).filter(Tenant.slug == tenant_slug).one_or_none()
if tenant is None:
tenant = Tenant(slug=tenant_slug, name="Default Tenant", default_locale="en", settings={})
tenant = Tenant(slug=tenant_slug, name="Default Tenant", default_locale="de", settings={})
session.add(tenant)
session.flush()
+4 -1
View File
@@ -685,7 +685,10 @@ def configured_migration_heads(
manifest_factories=manifest_factories,
migration_track=migration_track,
)
return tuple(sorted(ScriptDirectory.from_config(config).get_heads()))
scripts = ScriptDirectory.from_config(config)
return tuple(
sorted(revision.revision for revision in scripts.get_revisions("heads"))
)
def database_is_at_configured_heads(
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from typing import Any
from sqlalchemy import or_
from govoplan_core.core.temporal import (
TemporalContextError,
TemporalDataContext,
current_temporal_data_context,
)
def apply_temporal_revision_filter(
query: Any,
model: type[Any],
*,
context: TemporalDataContext | None = None,
valid_from: str | None = "valid_from",
valid_to: str | None = "valid_to",
recorded_at: str | None = "recorded_at",
superseded_at: str | None = "superseded_at",
) -> Any:
"""Apply latest/as-recorded and valid-time clauses to a revision query."""
resolved = context or current_temporal_data_context()
clauses: list[Any] = []
superseded_column = _optional_column(model, superseded_at)
recorded_column = _optional_column(model, recorded_at)
if resolved.recorded_at is None:
if superseded_column is not None:
clauses.append(superseded_column.is_(None))
else:
if recorded_column is None or superseded_column is None:
raise TemporalContextError(
f"{model.__name__} does not expose recorded/superseded revision time."
)
clauses.extend(
(
recorded_column <= resolved.recorded_at,
or_(
superseded_column.is_(None),
superseded_column > resolved.recorded_at,
),
)
)
instant = resolved.validity_instant
if instant is not None:
valid_from_column = _optional_column(model, valid_from)
valid_to_column = _optional_column(model, valid_to)
if valid_from_column is not None:
clauses.append(
or_(valid_from_column.is_(None), valid_from_column <= instant)
)
if valid_to_column is not None:
clauses.append(or_(valid_to_column.is_(None), valid_to_column > instant))
return query.filter(*clauses) if clauses else query
def _optional_column(model: type[Any], name: str | None) -> Any | None:
if name is None:
return None
column = getattr(model, name, None)
if column is None:
raise TemporalContextError(
f"{model.__name__} has no temporal column named {name!r}."
)
return column
__all__ = ["apply_temporal_revision_filter"]
+14 -7
View File
@@ -4,10 +4,12 @@ import re
from typing import Any, Iterable
I18N_SETTINGS_KEY = "i18n"
REFERENCE_LANGUAGE_CODE = "de"
SOURCE_LANGUAGE_CODE = "en"
DEFAULT_LANGUAGE_PACKAGES: tuple[dict[str, str], ...] = (
{"code": "en", "label": "English", "native_label": "English"},
{"code": "de", "label": "German", "native_label": "Deutsch"},
{"code": "en", "label": "English", "native_label": "English"},
)
@@ -66,7 +68,7 @@ def normalize_enabled_language_codes(
available_languages: Iterable[dict[str, Any]],
*,
default_locale: object = None,
fallback_codes: Iterable[str] = ("en", "de"),
fallback_codes: Iterable[str] = (REFERENCE_LANGUAGE_CODE, SOURCE_LANGUAGE_CODE),
) -> list[str]:
available_codes = [normalize_language_code(item.get("code")) for item in available_languages if isinstance(item, dict)]
available = {code for code in available_codes if code}
@@ -88,8 +90,10 @@ def normalize_enabled_language_codes(
if enabled:
return enabled
if "en" in available:
return ["en"]
if REFERENCE_LANGUAGE_CODE in available:
return [REFERENCE_LANGUAGE_CODE]
if SOURCE_LANGUAGE_CODE in available:
return [SOURCE_LANGUAGE_CODE]
return available_codes[:1]
@@ -126,7 +130,7 @@ def preferred_language_code(user_settings: dict[str, Any] | None, allowed_codes:
default = resolve_language_code(default_locale, enabled)
if default:
return default
return enabled[0] if enabled else "en"
return enabled[0] if enabled else REFERENCE_LANGUAGE_CODE
def update_i18n_settings(settings: dict[str, Any] | None, **values: Any) -> dict[str, Any]:
@@ -141,10 +145,13 @@ def system_i18n_payload(settings_item: Any | None) -> dict[str, object]:
settings = getattr(settings_item, "settings", None)
packages = system_language_packages(settings)
available_codes = [item["code"] for item in packages]
default_language = resolve_language_code(getattr(settings_item, "default_locale", None), available_codes) or "en"
default_language = (
resolve_language_code(getattr(settings_item, "default_locale", None), available_codes)
or REFERENCE_LANGUAGE_CODE
)
enabled = system_enabled_language_codes(settings, default_locale=default_language)
if default_language not in enabled:
default_language = enabled[0] if enabled else "en"
default_language = enabled[0] if enabled else REFERENCE_LANGUAGE_CODE
return {
"available_languages": packages,
"enabled_languages": enabled,
+7 -2
View File
@@ -1,3 +1,8 @@
from govoplan_core.mail.config import ImapConfig, SmtpConfig, TransportSecurity
from govoplan_core.mail.config import (
ImapConfig,
ImapFolderMappings,
SmtpConfig,
TransportSecurity,
)
__all__ = ["ImapConfig", "SmtpConfig", "TransportSecurity"]
__all__ = ["ImapConfig", "ImapFolderMappings", "SmtpConfig", "TransportSecurity"]
+46 -6
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
class StrictModel(BaseModel):
@@ -34,21 +34,61 @@ class SmtpServerConfig(StrictModel):
return self
class ImapFolderMappings(StrictModel):
"""Profile-level names for the standard IMAP mailbox roles."""
inbox: str | None = None
sent: str | None = None
drafts: str | None = None
trash: str | None = None
archive: str | None = None
junk: str | None = None
@field_validator("*", mode="before")
@classmethod
def normalize_folder_name(cls, value: Any) -> Any:
if value is None:
return None
normalized = str(value).strip()
return normalized or None
class ImapServerConfig(StrictModel):
host: str | None = None
port: int | None = Field(default=None, ge=1, le=65535)
security: TransportSecurity = TransportSecurity.TLS
sent_folder: str = "auto"
folder_mappings: ImapFolderMappings | None = None
timeout_seconds: int = Field(default=30, ge=1)
@model_validator(mode="before")
@classmethod
def discard_legacy_enabled(cls, value: Any) -> Any:
if isinstance(value, dict) and "enabled" in value:
data = dict(value)
data.pop("enabled", None)
return data
return value
if not isinstance(value, dict):
return value
data = dict(value)
data.pop("enabled", None)
mappings_value = data.get("folder_mappings")
mappings = (
mappings_value.model_dump(exclude_none=True)
if isinstance(mappings_value, ImapFolderMappings)
else dict(mappings_value)
if isinstance(mappings_value, dict)
else {}
)
mapped_sent = str(mappings.get("sent") or "").strip()
legacy_sent = str(data.get("sent_folder") or "").strip()
if mapped_sent:
# The typed mapping is canonical when both new and legacy callers
# provide a Sent value. Keep the legacy field synchronized for
# existing Campaign append consumers.
data["sent_folder"] = mapped_sent
elif legacy_sent and legacy_sent != "auto":
data["sent_folder"] = legacy_sent
mappings["sent"] = legacy_sent
if mappings:
data["folder_mappings"] = mappings
return data
@model_validator(mode="after")
def apply_default_port(self) -> "ImapServerConfig":
+753
View File
@@ -0,0 +1,753 @@
from __future__ import annotations
import hashlib
import json
import uuid
from collections import defaultdict
from collections.abc import Iterable, Mapping, Sequence
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import DateTime, Index, Integer, JSON, String, Text
from sqlalchemy.orm import Mapped, Session, mapped_column
from govoplan_core.core.concurrency import RevisionConflictError, strong_resource_etag
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarProvider,
DsarRecordRef,
DsarRequestKind,
DsarSubjectRef,
dsar_provider_names,
)
from govoplan_core.db.base import Base, TimestampMixin
MAX_RECORDS_PER_PROVIDER = 10_000
MAX_PROVIDER_RESULT_BYTES = 10 * 1024 * 1024
class DataSubjectRequest(Base, TimestampMixin):
__tablename__ = "core_data_subject_requests"
__table_args__ = (
Index(
"ix_core_data_subject_requests_tenant_status",
"tenant_id",
"status",
),
)
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
reference: Mapped[str] = mapped_column(String(120), nullable=False)
request_kind: Mapped[str] = mapped_column(String(30), nullable=False)
status: Mapped[str] = mapped_column(
String(30), default="draft", nullable=False, index=True
)
subject: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
purpose: Mapped[str] = mapped_column(String(1000), nullable=False)
legal_basis: Mapped[str | None] = mapped_column(String(1000), nullable=True)
due_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
requested_by_account_id: Mapped[str] = mapped_column(String(36), nullable=False)
search_result: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
erasure_plan: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
execution_result: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
coverage: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
evidence_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
@property
def strong_etag(self) -> str:
return strong_resource_etag(
"data_subject_request",
self.id,
self.resource_revision,
)
def create_data_subject_request(
session: Session,
*,
tenant_id: str,
reference: str,
request_kind: DsarRequestKind,
subject: DsarSubjectRef,
purpose: str,
legal_basis: str | None,
due_at: datetime | None,
requested_by_account_id: str,
notes: str | None = None,
) -> DataSubjectRequest:
if not subject.has_selector():
raise ValueError("At least one data-subject selector is required.")
row = DataSubjectRequest(
tenant_id=tenant_id,
reference=reference.strip(),
request_kind=request_kind,
subject=subject.to_dict(),
purpose=purpose.strip(),
legal_basis=(legal_basis or "").strip() or None,
due_at=due_at,
requested_by_account_id=requested_by_account_id,
notes=(notes or "").strip() or None,
)
session.add(row)
session.flush()
row.evidence_sha256 = _evidence_digest(row)
return row
def get_data_subject_request(
session: Session,
*,
tenant_id: str,
request_id: str,
for_update: bool = False,
) -> DataSubjectRequest:
query = session.query(DataSubjectRequest).filter(
DataSubjectRequest.id == request_id,
DataSubjectRequest.tenant_id == tenant_id,
)
if for_update:
query = query.with_for_update()
row = query.one_or_none()
if row is None or row.tenant_id != tenant_id:
raise LookupError("Data-subject request not found.")
return row
def list_data_subject_requests(
session: Session,
*,
tenant_id: str,
limit: int = 200,
) -> tuple[DataSubjectRequest, ...]:
return tuple(
session.query(DataSubjectRequest)
.filter(DataSubjectRequest.tenant_id == tenant_id)
.order_by(
DataSubjectRequest.created_at.desc(),
DataSubjectRequest.id.desc(),
)
.limit(max(1, min(limit, 500)))
.all()
)
def search_data_subject_request(
session: Session,
*,
registry: object,
row: DataSubjectRequest,
expected_revision: int,
) -> DataSubjectRequest:
_assert_revision(row, expected_revision)
subject = _subject(row.subject)
records: list[dict[str, object]] = []
provider_runs: list[dict[str, object]] = []
providers, discovery = _providers(
registry,
session,
tenant_id=row.tenant_id,
)
for capability_name, provider in providers:
started_at = _now()
try:
with session.begin_nested():
provider_records = tuple(
provider.search_subject(
session,
tenant_id=row.tenant_id,
subject=subject,
)
)
_validate_records(provider, provider_records)
if len(provider_records) > MAX_RECORDS_PER_PROVIDER:
raise ValueError("DSAR provider result exceeds the record limit.")
encoded = _json_bytes([item.to_dict() for item in provider_records])
if len(encoded) > MAX_PROVIDER_RESULT_BYTES:
raise ValueError("DSAR provider result exceeds the payload limit.")
records.extend(item.to_dict() for item in provider_records)
provider_runs.append(
{
"capability": capability_name,
"provider_id": provider.provider_id,
"module_id": provider.module_id,
"status": "complete",
"record_count": len(provider_records),
"started_at": started_at,
"completed_at": _now(),
}
)
except Exception as exc:
provider_runs.append(
{
"capability": capability_name,
"provider_id": getattr(provider, "provider_id", capability_name),
"module_id": getattr(provider, "module_id", "unknown"),
"status": "failed",
"record_count": 0,
"error": _safe_error(exc),
"started_at": started_at,
"completed_at": _now(),
}
)
failures = sum(1 for item in provider_runs if item["status"] == "failed")
row.search_result = {
"schema": "govoplan.dsars.search.v1",
"searched_at": _now(),
"records": records,
"provider_runs": provider_runs,
"record_count": len(records),
}
row.coverage = discovery
row.erasure_plan = {}
row.execution_result = {}
row.status = "search_partial" if failures else "searched"
row.completed_at = None
_advance(row)
return row
def plan_data_subject_erasure(
session: Session,
*,
registry: object,
row: DataSubjectRequest,
expected_revision: int,
) -> DataSubjectRequest:
_assert_revision(row, expected_revision)
if not row.search_result.get("searched_at"):
raise ValueError("Run the data-subject search before planning erasure.")
if row.request_kind == "access":
raise ValueError("This request does not include erasure.")
subject = _subject(row.subject)
records = tuple(_record(item) for item in row.search_result.get("records", []))
by_provider: dict[str, list[DsarRecordRef]] = defaultdict(list)
for record in records:
by_provider[record.provider_id].append(record)
providers, discovery = _providers(
registry,
session,
tenant_id=row.tenant_id,
)
actions: list[DsarErasureActionRef] = []
provider_runs: list[dict[str, object]] = []
for capability_name, provider in providers:
started_at = _now()
try:
with session.begin_nested():
proposed = tuple(
provider.plan_erasure(
session,
tenant_id=row.tenant_id,
subject=subject,
records=tuple(by_provider.get(provider.provider_id, ())),
)
)
_validate_actions(provider, proposed)
actions.extend(proposed)
provider_runs.append(
{
"capability": capability_name,
"provider_id": provider.provider_id,
"module_id": provider.module_id,
"status": "complete",
"action_count": len(proposed),
"started_at": started_at,
"completed_at": _now(),
}
)
except Exception as exc:
provider_runs.append(
{
"capability": capability_name,
"provider_id": getattr(provider, "provider_id", capability_name),
"module_id": getattr(provider, "module_id", "unknown"),
"status": "failed",
"action_count": 0,
"error": _safe_error(exc),
"started_at": started_at,
"completed_at": _now(),
}
)
existing_resources = {
(item.provider_id, item.resource_type, item.resource_id) for item in actions
}
for record in records:
key = (record.provider_id, record.resource_type, record.resource_id)
if not record.immutable_evidence or key in existing_resources:
continue
digest = hashlib.sha256("\0".join(key).encode("utf-8")).hexdigest()[:24]
actions.append(
DsarErasureActionRef(
action_id=f"retain:{digest}",
provider_id=record.provider_id,
module_id=record.module_id,
kind="retain",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Retain {record.title}",
rationale=(
record.retention_reason
or "Immutable institutional evidence must be retained."
),
executable=False,
)
)
duplicate_ids = _duplicates(item.action_id for item in actions)
if duplicate_ids:
raise ValueError(
f"DSAR providers returned duplicate action ids: {', '.join(duplicate_ids)}"
)
failures = sum(1 for item in provider_runs if item["status"] == "failed")
row.erasure_plan = {
"schema": "govoplan.dsars.erasure-plan.v1",
"planned_at": _now(),
"actions": [item.to_dict() for item in actions],
"provider_runs": provider_runs,
"executable_count": sum(1 for item in actions if item.executable),
"retained_count": sum(1 for item in actions if item.kind == "retain"),
}
row.coverage = {**discovery, "plan_provider_runs": provider_runs}
row.execution_result = {}
row.status = "plan_partial" if failures else "plan_ready"
row.completed_at = None
_advance(row)
return row
def execute_data_subject_erasure(
session: Session,
*,
registry: object,
row: DataSubjectRequest,
expected_revision: int,
action_ids: Sequence[str],
) -> DataSubjectRequest:
_assert_revision(row, expected_revision)
raw_actions = row.erasure_plan.get("actions")
if not isinstance(raw_actions, list):
raise ValueError("Create an erasure plan before execution.")
actions_by_id = {
action.action_id: action for action in (_action(item) for item in raw_actions)
}
selected_ids = tuple(dict.fromkeys(str(item) for item in action_ids if str(item)))
if not selected_ids:
raise ValueError("Select at least one executable erasure action.")
missing = [item for item in selected_ids if item not in actions_by_id]
if missing:
raise ValueError("The erasure plan changed; refresh before executing it.")
selected = tuple(actions_by_id[item] for item in selected_ids)
blocked = [item.action_id for item in selected if not item.executable]
if blocked:
raise ValueError("Retained or review-only actions cannot be executed.")
providers, _discovery = _providers(
registry,
session,
tenant_id=row.tenant_id,
)
providers_by_id = {provider.provider_id: provider for _name, provider in providers}
grouped: dict[str, list[DsarErasureActionRef]] = defaultdict(list)
for action in selected:
grouped[action.provider_id].append(action)
previous = {
str(item.get("action_id")): item
for item in row.execution_result.get("results", [])
if isinstance(item, Mapping)
and item.get("status") in {"executed", "unchanged"}
}
results: list[dict[str, object]] = list(previous.values())
for provider_id, provider_actions in grouped.items():
provider = providers_by_id.get(provider_id)
if provider is None:
results.extend(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary="The owning DSAR provider is not currently available.",
).to_dict()
for action in provider_actions
)
continue
pending = tuple(
action for action in provider_actions if action.action_id not in previous
)
if not pending:
continue
try:
with session.begin_nested():
executed = tuple(
provider.execute_erasure(
session,
tenant_id=row.tenant_id,
subject=_subject(row.subject),
actions=pending,
request_id=row.id,
)
)
_validate_execution_results(pending, executed)
results.extend(item.to_dict() for item in executed)
except Exception as exc:
results.extend(
DsarExecutionResultRef(
action_id=action.action_id,
status="failed",
summary=_safe_error(exc),
).to_dict()
for action in pending
)
result_by_id = {str(item["action_id"]): item for item in results}
all_executable = {
action.action_id for action in actions_by_id.values() if action.executable
}
successful = {
action_id
for action_id, item in result_by_id.items()
if item.get("status") in {"executed", "unchanged"}
}
failed = {
action_id
for action_id, item in result_by_id.items()
if item.get("status") in {"failed", "blocked"}
}
row.execution_result = {
"schema": "govoplan.dsars.execution.v1",
"executed_at": _now(),
"results": list(result_by_id.values()),
"successful_count": len(successful),
"failed_count": len(failed),
}
if all_executable.issubset(successful):
row.status = "completed"
row.completed_at = datetime.now(timezone.utc)
elif failed:
row.status = "execution_partial"
row.completed_at = None
else:
row.status = "execution_pending"
row.completed_at = None
_advance(row)
return row
def data_subject_export(row: DataSubjectRequest) -> bytes:
payload: dict[str, object] = {
"schema": "govoplan.dsars.export.v1",
"generated_at": _now(),
"request": data_subject_request_dict(row, include_subject=True),
"search": row.search_result,
"erasure_plan": row.erasure_plan,
"execution": row.execution_result,
"coverage": row.coverage,
"limitations": [
"Only providers listed as complete contributed data.",
"Retained immutable evidence is exported with its stated retention reason.",
"An export does not itself erase or alter source data.",
],
}
digest = hashlib.sha256(_json_bytes(payload)).hexdigest()
payload["manifest_sha256"] = digest
return _json_bytes(payload, pretty=True)
def data_subject_request_dict(
row: DataSubjectRequest,
*,
include_subject: bool,
) -> dict[str, object]:
return {
"id": row.id,
"tenant_id": row.tenant_id,
"reference": row.reference,
"request_kind": row.request_kind,
"status": row.status,
"subject": dict(row.subject) if include_subject else {},
"purpose": row.purpose,
"legal_basis": row.legal_basis,
"due_at": row.due_at.isoformat() if row.due_at else None,
"requested_by_account_id": row.requested_by_account_id,
"record_count": int(row.search_result.get("record_count", 0)),
"executable_action_count": int(
row.erasure_plan.get("executable_count", 0)
),
"coverage": dict(row.coverage),
"evidence_sha256": row.evidence_sha256,
"resource_revision": row.resource_revision,
"etag": row.strong_etag,
"created_at": row.created_at.isoformat(),
"updated_at": row.updated_at.isoformat(),
"completed_at": row.completed_at.isoformat() if row.completed_at else None,
"notes": row.notes,
}
def _providers(
registry: object,
session: Session,
*,
tenant_id: str,
) -> tuple[tuple[tuple[str, DsarProvider], ...], dict[str, object]]:
values: list[tuple[str, DsarProvider]] = []
failures: list[dict[str, str]] = []
active_modules = _effective_module_ids(registry, session, tenant_id=tenant_id)
names = dsar_provider_names(registry)
active_names: list[str] = []
inactive_names: list[str] = []
for name in names:
owner = (
registry.capability_owner(name)
if hasattr(registry, "capability_owner")
else None
)
if owner and owner not in active_modules:
inactive_names.append(name)
continue
active_names.append(name)
try:
if hasattr(registry, "require_tenant_capability"):
candidate = registry.require_tenant_capability(
name,
session,
tenant_id=tenant_id,
work_state="interactive",
)
else:
candidate = registry.require_capability(name)
if not isinstance(candidate, DsarProvider):
raise TypeError("Capability does not implement DsarProvider.")
values.append((name, candidate))
except Exception as exc:
failures.append({"capability": name, "error": _safe_error(exc)})
covered_modules = sorted({provider.module_id for _name, provider in values})
return tuple(values), {
"provider_capabilities": active_names,
"inactive_provider_capabilities": inactive_names,
"covered_modules": covered_modules,
"modules_without_provider": sorted(set(active_modules) - set(covered_modules)),
"provider_discovery_failures": failures,
}
def _effective_module_ids(
registry: object,
session: Session,
*,
tenant_id: str,
) -> list[str]:
resolver_factory = getattr(registry, "tenant_entitlement_resolver", None)
if callable(resolver_factory):
state = resolver_factory().resolve(session, tenant_id)
return sorted(str(item) for item in state.effective_modules)
if hasattr(registry, "manifests"):
return sorted(str(manifest.id) for manifest in registry.manifests())
return []
def _subject(value: Mapping[str, object]) -> DsarSubjectRef:
raw_refs = value.get("external_references")
return DsarSubjectRef(
account_id=_optional_text(value.get("account_id")),
identity_id=_optional_text(value.get("identity_id")),
membership_id=_optional_text(value.get("membership_id")),
email=_optional_text(value.get("email")),
external_references={
str(key): str(item)
for key, item in (raw_refs.items() if isinstance(raw_refs, Mapping) else ())
},
)
def _record(value: object) -> DsarRecordRef:
if not isinstance(value, Mapping):
raise ValueError("Stored DSAR record is invalid.")
observed_at = _parse_datetime(value.get("observed_at"))
data = value.get("data")
return DsarRecordRef(
provider_id=str(value.get("provider_id") or ""),
module_id=str(value.get("module_id") or ""),
resource_type=str(value.get("resource_type") or ""),
resource_id=str(value.get("resource_id") or ""),
category=str(value.get("category") or ""),
title=str(value.get("title") or ""),
data=dict(data) if isinstance(data, Mapping) else {},
observed_at=observed_at,
immutable_evidence=bool(value.get("immutable_evidence")),
retention_reason=_optional_text(value.get("retention_reason")),
source_path=_optional_text(value.get("source_path")),
)
def _action(value: object) -> DsarErasureActionRef:
if not isinstance(value, Mapping):
raise ValueError("Stored DSAR action is invalid.")
metadata = value.get("metadata")
kind = str(value.get("kind") or "manual_review")
if kind not in {
"delete",
"anonymize",
"revoke",
"detach",
"retain",
"manual_review",
}:
raise ValueError("Stored DSAR action kind is invalid.")
return DsarErasureActionRef(
action_id=str(value.get("action_id") or ""),
provider_id=str(value.get("provider_id") or ""),
module_id=str(value.get("module_id") or ""),
kind=kind, # type: ignore[arg-type]
resource_type=str(value.get("resource_type") or ""),
resource_id=str(value.get("resource_id") or ""),
title=str(value.get("title") or ""),
rationale=str(value.get("rationale") or ""),
executable=bool(value.get("executable")),
irreversible=bool(value.get("irreversible")),
metadata=dict(metadata) if isinstance(metadata, Mapping) else {},
)
def _validate_records(
provider: DsarProvider,
records: Sequence[DsarRecordRef],
) -> None:
for record in records:
if record.provider_id != provider.provider_id or record.module_id != provider.module_id:
raise ValueError("DSAR record ownership does not match its provider.")
if not record.resource_type or not record.resource_id or not record.title:
raise ValueError("DSAR records require resource identity and title.")
def _validate_actions(
provider: DsarProvider,
actions: Sequence[DsarErasureActionRef],
) -> None:
for action in actions:
if action.provider_id != provider.provider_id or action.module_id != provider.module_id:
raise ValueError("DSAR action ownership does not match its provider.")
if not action.action_id or not action.resource_type or not action.resource_id:
raise ValueError("DSAR actions require stable identities.")
if action.kind in {"retain", "manual_review"} and action.executable:
raise ValueError("Retain and manual-review actions cannot be executable.")
def _validate_execution_results(
actions: Sequence[DsarErasureActionRef],
results: Sequence[DsarExecutionResultRef],
) -> None:
expected = {action.action_id for action in actions}
returned = {result.action_id for result in results}
if expected != returned or len(returned) != len(results):
raise ValueError("DSAR provider did not return one result per action.")
def _assert_revision(row: DataSubjectRequest, expected_revision: int) -> None:
if row.resource_revision != expected_revision:
raise RevisionConflictError(
resource_type="data_subject_request",
resource_id=row.id,
current_revision=row.resource_revision,
submitted_base_revision=expected_revision,
current_etag=row.strong_etag,
refresh_path=f"/api/v1/admin/privacy/data-subject-requests/{row.id}",
)
def _advance(row: DataSubjectRequest) -> None:
row.resource_revision += 1
row.evidence_sha256 = _evidence_digest(row)
def _evidence_digest(row: DataSubjectRequest) -> str:
return hashlib.sha256(
_json_bytes(
{
"id": row.id,
"tenant_id": row.tenant_id,
"reference": row.reference,
"request_kind": row.request_kind,
"status": row.status,
"subject": row.subject,
"search_result": row.search_result,
"erasure_plan": row.erasure_plan,
"execution_result": row.execution_result,
"coverage": row.coverage,
"resource_revision": row.resource_revision,
}
)
).hexdigest()
def _duplicates(values: Iterable[str]) -> tuple[str, ...]:
seen: set[str] = set()
duplicate: set[str] = set()
for value in values:
if value in seen:
duplicate.add(value)
seen.add(value)
return tuple(sorted(duplicate))
def _json_bytes(value: object, *, pretty: bool = False) -> bytes:
return json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
indent=2 if pretty else None,
separators=None if pretty else (",", ":"),
default=str,
).encode("utf-8")
def _safe_error(exc: Exception) -> str:
text = " ".join(str(exc).split())
return (text or exc.__class__.__name__)[:1000]
def _optional_text(value: object | None) -> str | None:
text = str(value or "").strip()
return text or None
def _parse_datetime(value: object | None) -> datetime | None:
if isinstance(value, datetime):
return value
if isinstance(value, str) and value:
try:
return datetime.fromisoformat(value)
except ValueError:
return None
return None
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
__all__ = [
"DataSubjectRequest",
"create_data_subject_request",
"data_subject_export",
"data_subject_request_dict",
"execute_data_subject_erasure",
"get_data_subject_request",
"list_data_subject_requests",
"plan_data_subject_erasure",
"search_data_subject_request",
]
@@ -0,0 +1,13 @@
{
"generated_at": "2026-07-11T15:18:35.649400Z",
"keyring_version": "1",
"keys": [
{
"key_id": "release-key-1",
"not_before": "2026-07-11T00:00:00Z",
"public_key": "jOXIlZXytoNJCH8tsmrYRklg6ShpjGXRY0uV3jApRiA=",
"status": "active"
}
],
"purpose": "govoplan module package catalog signatures"
}
@@ -37,6 +37,7 @@ LEGACY_TO_MODULE_SCOPES: dict[str, str] = {
"system:access:read": "access:system_role:read",
"system:access:assign": "access:system_role:assign",
"system:audit:read": "access:audit:read",
"system:audit:evidence:export": "audit:system_evidence:export",
"system:settings:read": "access:system_setting:read",
"system:settings:write": "access:system_setting:write",
"system:maintenance:access": "access:maintenance:access",
+2
View File
@@ -10,6 +10,7 @@ from govoplan_core.server.fastapi import create_govoplan_app
from govoplan_core.server.platform import create_platform_router
from govoplan_core.server.bootstrap import create_bootstrap_router
from govoplan_core.server.credentials import router as credential_router
from govoplan_core.server.dsar import router as dsar_router
from govoplan_core.server.ownership import router as ownership_router
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.server.route_validation import validate_no_route_collisions
@@ -72,6 +73,7 @@ def _server_api_router(server_config: GovoplanServerConfig, registry) -> APIRout
api_router.include_router(create_platform_router(settings=server_config.settings))
api_router.include_router(create_bootstrap_router(server_config.settings))
api_router.include_router(credential_router)
api_router.include_router(dsar_router)
api_router.include_router(ownership_router)
for router in server_config.post_module_routers:
api_router.include_router(router)
@@ -7,7 +7,15 @@ from fastapi import Request
from starlette.responses import Response
JSON_CACHE_CONTROL = "private, no-cache"
JSON_ETAG_VARY_HEADERS = ("Authorization", "Cookie", "X-API-Key", "Accept-Language")
JSON_ETAG_VARY_HEADERS = (
"Authorization",
"Cookie",
"X-API-Key",
"Accept-Language",
"X-Govoplan-Validity-Mode",
"X-Govoplan-Valid-At",
"X-Govoplan-Recorded-At",
)
async def conditional_json_get_middleware(
+10 -2
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from importlib.metadata import PackageNotFoundError, version
from fastapi import Depends, FastAPI
from fastapi import HTTPException, status
@@ -20,6 +21,13 @@ from govoplan_core.core.runtime_coordination import (
from govoplan_core.settings import Settings, settings
def _core_distribution_version() -> str:
try:
return version("govoplan-core")
except PackageNotFoundError:
return "development"
def _dev_bootstrap_needs_create_all(database_url: str) -> bool:
try:
return make_url(database_url).get_backend_name() == "sqlite"
@@ -61,7 +69,7 @@ async def lifespan(app: FastAPI):
)
runtime_agent = RuntimeNodeAgent(
settings=settings,
software_version=app.version,
software_version=_core_distribution_version(),
module_ids=module_ids,
metadata={"process": "api"},
identity=configured_identity
@@ -93,7 +101,7 @@ def register_health_details(
):
app.state.govoplan_runtime_identity = runtime_identity(
active_settings,
software_version=app.version,
software_version=_core_distribution_version(),
module_ids=module_ids,
)
bind_process_runtime_identity(app.state.govoplan_runtime_identity)
+406
View File
@@ -0,0 +1,406 @@
from __future__ import annotations
import re
from datetime import datetime
from typing import Any, Literal
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_event
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.concurrency import (
ConcurrencyError,
MissingPreconditionError,
RevisionConflictError,
assert_revision_precondition,
)
from govoplan_core.core.dsar import DsarSubjectRef
from govoplan_core.db.session import get_session
from govoplan_core.privacy.dsar_workflow import (
DataSubjectRequest,
create_data_subject_request,
data_subject_export,
data_subject_request_dict,
execute_data_subject_erasure,
get_data_subject_request,
list_data_subject_requests,
plan_data_subject_erasure,
search_data_subject_request,
)
READ_SCOPE = "access:privacy:read"
MANAGE_SCOPE = "access:privacy:manage"
EXPORT_SCOPE = "access:privacy:export"
ERASE_SCOPE = "access:privacy:erase"
class DataSubjectSelectorRequest(BaseModel):
account_id: str | None = Field(default=None, max_length=36)
identity_id: str | None = Field(default=None, max_length=36)
membership_id: str | None = Field(default=None, max_length=36)
email: str | None = Field(default=None, max_length=320)
external_references: dict[str, str] = Field(default_factory=dict)
@field_validator("external_references")
@classmethod
def validate_external_references(
cls,
value: dict[str, str],
) -> dict[str, str]:
if len(value) > 50:
raise ValueError("At most 50 external subject references are allowed.")
normalized: dict[str, str] = {}
for raw_key, raw_value in value.items():
key = str(raw_key).strip()
item = str(raw_value).strip()
if not key or not item:
continue
if len(key) > 120 or len(item) > 500:
raise ValueError(
"External subject-reference namespaces are limited to 120 "
"characters and values to 500 characters."
)
normalized[key] = item
return normalized
class DataSubjectRequestCreate(BaseModel):
reference: str = Field(min_length=1, max_length=120)
request_kind: Literal["access", "erasure", "access_and_erasure"]
subject: DataSubjectSelectorRequest
purpose: str = Field(min_length=1, max_length=1000)
legal_basis: str | None = Field(default=None, max_length=1000)
due_at: datetime | None = None
notes: str | None = Field(default=None, max_length=10_000)
class RevisionMutationRequest(BaseModel):
base_revision: int = Field(ge=1)
class DataSubjectExecutionRequest(RevisionMutationRequest):
action_ids: list[str] = Field(min_length=1, max_length=10_000)
confirmation: str = Field(min_length=1, max_length=100)
class DataSubjectRequestResponse(BaseModel):
request: dict[str, Any]
search: dict[str, Any] = Field(default_factory=dict)
erasure_plan: dict[str, Any] = Field(default_factory=dict)
execution: dict[str, Any] = Field(default_factory=dict)
class DataSubjectRequestListResponse(BaseModel):
items: list[dict[str, Any]] = Field(default_factory=list)
router = APIRouter(
prefix="/admin/privacy/data-subject-requests",
tags=["data-subject-requests"],
)
@router.get("", response_model=DataSubjectRequestListResponse)
def list_requests(
limit: int = Query(default=200, ge=1, le=500),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> DataSubjectRequestListResponse:
_require(principal, READ_SCOPE)
rows = list_data_subject_requests(
session,
tenant_id=principal.tenant_id,
limit=limit,
)
return DataSubjectRequestListResponse(
items=[
data_subject_request_dict(row, include_subject=True)
for row in rows
]
)
@router.post(
"",
response_model=DataSubjectRequestResponse,
status_code=status.HTTP_201_CREATED,
)
def create_request(
payload: DataSubjectRequestCreate,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> DataSubjectRequestResponse:
_require(principal, MANAGE_SCOPE)
try:
row = create_data_subject_request(
session,
tenant_id=principal.tenant_id,
reference=payload.reference,
request_kind=payload.request_kind,
subject=_subject(payload.subject),
purpose=payload.purpose,
legal_basis=payload.legal_basis,
due_at=payload.due_at,
requested_by_account_id=principal.account_id,
notes=payload.notes,
)
_audit(session, principal, row, "privacy.dsar.created")
session.commit()
session.refresh(row)
return _detail(row)
except ValueError as exc:
session.rollback()
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/{request_id}", response_model=DataSubjectRequestResponse)
def get_request(
request_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> DataSubjectRequestResponse:
_require(principal, READ_SCOPE)
return _detail(_row(session, principal, request_id))
@router.post("/{request_id}/search", response_model=DataSubjectRequestResponse)
def search_request(
request_id: str,
payload: RevisionMutationRequest,
request: Request,
if_match: str | None = Header(default=None, alias="If-Match"),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> DataSubjectRequestResponse:
_require(principal, MANAGE_SCOPE)
return _mutate(
session,
principal,
request_id,
payload.base_revision,
if_match,
lambda row: search_data_subject_request(
session,
registry=_registry(request),
row=row,
expected_revision=payload.base_revision,
),
"privacy.dsar.searched",
)
@router.post("/{request_id}/erasure-plan", response_model=DataSubjectRequestResponse)
def plan_erasure(
request_id: str,
payload: RevisionMutationRequest,
request: Request,
if_match: str | None = Header(default=None, alias="If-Match"),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> DataSubjectRequestResponse:
_require(principal, MANAGE_SCOPE)
return _mutate(
session,
principal,
request_id,
payload.base_revision,
if_match,
lambda row: plan_data_subject_erasure(
session,
registry=_registry(request),
row=row,
expected_revision=payload.base_revision,
),
"privacy.dsar.erasure_planned",
)
@router.post("/{request_id}/execute", response_model=DataSubjectRequestResponse)
def execute_erasure(
request_id: str,
payload: DataSubjectExecutionRequest,
request: Request,
if_match: str | None = Header(default=None, alias="If-Match"),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> DataSubjectRequestResponse:
_require(principal, ERASE_SCOPE)
if payload.confirmation != f"ERASE {request_id}":
raise HTTPException(
status_code=400,
detail=f'Type "ERASE {request_id}" to confirm the selected actions.',
)
return _mutate(
session,
principal,
request_id,
payload.base_revision,
if_match,
lambda row: execute_data_subject_erasure(
session,
registry=_registry(request),
row=row,
expected_revision=payload.base_revision,
action_ids=payload.action_ids,
),
"privacy.dsar.erasure_executed",
details={"action_ids": payload.action_ids},
)
@router.get("/{request_id}/export")
def export_request(
request_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> Response:
_require(principal, EXPORT_SCOPE)
row = _row(session, principal, request_id)
content = data_subject_export(row)
_audit(
session,
principal,
row,
"privacy.dsar.exported",
details={"export_bytes": len(content)},
)
session.commit()
safe_reference = re.sub(r"[^A-Za-z0-9._-]+", "-", row.reference).strip("-")
filename = f"dsar-{safe_reference or row.id}.json"
return Response(
content=content,
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
def _mutate(
session: Session,
principal: ApiPrincipal,
request_id: str,
base_revision: int,
if_match: str | None,
operation: Any,
audit_action: str,
*,
details: dict[str, Any] | None = None,
) -> DataSubjectRequestResponse:
try:
assert_revision_precondition(
if_match,
resource_type="data_subject_request",
resource_id=request_id,
submitted_base_revision=base_revision,
)
row = get_data_subject_request(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
for_update=True,
)
operation(row)
_audit(session, principal, row, audit_action, details=details)
session.commit()
session.refresh(row)
return _detail(row)
except LookupError as exc:
session.rollback()
raise HTTPException(status_code=404, detail=str(exc)) from exc
except MissingPreconditionError as exc:
session.rollback()
raise HTTPException(status_code=428, detail=exc.as_dict()) from exc
except RevisionConflictError as exc:
session.rollback()
raise HTTPException(status_code=409, detail=exc.as_dict()) from exc
except ConcurrencyError as exc:
session.rollback()
raise HTTPException(status_code=412, detail=str(exc)) from exc
except ValueError as exc:
session.rollback()
raise HTTPException(status_code=400, detail=str(exc)) from exc
def _row(
session: Session,
principal: ApiPrincipal,
request_id: str,
) -> DataSubjectRequest:
try:
return get_data_subject_request(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
def _detail(row: DataSubjectRequest) -> DataSubjectRequestResponse:
return DataSubjectRequestResponse(
request=data_subject_request_dict(row, include_subject=True),
search=dict(row.search_result),
erasure_plan=dict(row.erasure_plan),
execution=dict(row.execution_result),
)
def _subject(payload: DataSubjectSelectorRequest) -> DsarSubjectRef:
return DsarSubjectRef(
account_id=_text(payload.account_id),
identity_id=_text(payload.identity_id),
membership_id=_text(payload.membership_id),
email=_text(payload.email),
external_references={
str(key).strip(): str(value).strip()
for key, value in payload.external_references.items()
if str(key).strip() and str(value).strip()
},
)
def _registry(request: Request) -> object:
registry = getattr(request.app.state, "govoplan_registry", None)
if registry is None:
raise HTTPException(status_code=503, detail="Module registry is unavailable.")
return registry
def _require(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
def _audit(
session: Session,
principal: ApiPrincipal,
row: DataSubjectRequest,
action: str,
*,
details: dict[str, Any] | None = None,
) -> None:
audit_event(
session,
tenant_id=principal.tenant_id,
action=action,
user_id=principal.membership_id,
api_key_id=principal.api_key_id,
object_type="data_subject_request",
object_id=row.id,
details={
"reference": row.reference,
"status": row.status,
"resource_revision": row.resource_revision,
**(details or {}),
},
)
def _text(value: str | None) -> str | None:
normalized = (value or "").strip()
return normalized or None
__all__ = ["router"]
+2
View File
@@ -17,6 +17,7 @@ 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
from govoplan_core.server.temporal import temporal_data_context_middleware
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
logger = logging.getLogger("govoplan.request")
@@ -169,6 +170,7 @@ def create_govoplan_app(
return response
app.middleware("http")(conditional_json_get_middleware)
app.middleware("http")(temporal_data_context_middleware)
origins = [item.strip() for item in cors_origins if item.strip()]
if origins:
+235 -12
View File
@@ -7,11 +7,26 @@ from govoplan_core.admin.models import SystemSettings
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
from govoplan_core.auth import ApiPrincipal, get_api_principal, require_any_scope
from govoplan_core.core.maintenance import saved_maintenance_mode
from govoplan_core.core.navigation import (
EffectiveNavigationItem,
navigation_preferences_from_mapping,
navigation_preferences_from_settings,
resolve_navigation_preferences,
)
from govoplan_core.core.module_entitlements import (
module_entitlement_payload,
tenant_module_entitlement_state,
)
from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleManifest, NavItem, PublicFrontendRoute
from govoplan_core.core.modules import (
FrontendModule,
FrontendRoute,
ModuleManifest,
NavItem,
ProductAreaContribution,
PublicFrontendRoute,
QuickAccessTool,
SUPPORTED_PRESENTATION_CONTRACT_VERSION,
)
from govoplan_core.core.platform_interfaces import (
manifest_interface_catalog,
platform_interface_catalog,
@@ -78,21 +93,116 @@ def _effective_manifest_state(
)
def _nav_item_payload(item: NavItem, module_id: str | None = None) -> dict[str, object]:
def _effective_navigation_state(
principal: ApiPrincipal,
manifests: tuple[ModuleManifest, ...],
) -> dict[str, dict[str, EffectiveNavigationItem]]:
default_items: list[tuple[int, str, str]] = []
for manifest in manifests:
frontend_items = (
manifest.frontend.nav_items
if manifest.frontend is not None and manifest.frontend.nav_items
else manifest.nav_items
)
for item in frontend_items:
navigation_id = item.surface_id or navigation_view_surface_id(
manifest.id, item.path
)
default_items.append((item.order, item.label, navigation_id))
default_ids = [
item_id
for _order, _label, item_id in sorted(
default_items, key=lambda item: (item[0], item[1], item[2])
)
]
principal_ref = getattr(principal, "principal", None)
tenant_id = getattr(principal_ref, "tenant_id", None)
system_preferences = None
tenant_preferences = None
if tenant_id is not None:
try:
with get_database().session() as session:
system_item = session.get(SystemSettings, SYSTEM_SETTINGS_ID)
tenant = session.get(Tenant, tenant_id)
system_preferences = navigation_preferences_from_settings(
system_item.settings if system_item is not None else {}
)
tenant_preferences = navigation_preferences_from_settings(
tenant.settings if tenant is not None else {}
)
except (RuntimeError, SQLAlchemyError) as exc:
raise HTTPException(
status_code=503,
detail="Navigation preferences could not be resolved.",
) from exc
user_settings = getattr(getattr(principal, "user", None), "settings", {})
user_ui = user_settings.get("ui") if isinstance(user_settings, dict) else {}
user_navigation = user_ui.get("navigation") if isinstance(user_ui, dict) else None
user_preferences = (
navigation_preferences_from_mapping(user_navigation)
if isinstance(user_navigation, dict)
else None
)
return {
"module": resolve_navigation_preferences(default_ids),
"system": resolve_navigation_preferences(
default_ids,
system=system_preferences,
),
"tenant": resolve_navigation_preferences(
default_ids,
system=system_preferences,
tenant=tenant_preferences,
),
"user": resolve_navigation_preferences(
default_ids,
system=system_preferences,
tenant=tenant_preferences,
user=user_preferences,
),
}
def _nav_item_payload(
item: NavItem,
module_id: str | None = None,
navigation: dict[str, dict[str, EffectiveNavigationItem]] | None = None,
) -> dict[str, object]:
navigation_id = (
item.surface_id or navigation_view_surface_id(module_id, item.path)
if module_id
else item.surface_id
)
effective = (
navigation.get("user", {}).get(navigation_id)
if navigation_id and navigation
else None
)
payload = {
"path": item.path,
"label": item.label,
"icon": item.icon,
"section": item.section,
"required_all": list(item.required_all),
"required_any": list(item.required_any),
"order": item.order,
"surface_id": (
item.surface_id or navigation_view_surface_id(module_id, item.path)
if module_id
else item.surface_id
),
"order": effective.order if effective is not None else item.order,
"surface_id": navigation_id,
}
if effective is not None:
payload.update(effective.as_dict())
payload["navigation_layers"] = {
scope: {
"order": scoped.order,
"visible": scoped.visible,
"locked": scoped.locked,
"lock_source": scoped.lock_source,
}
for scope in ("module", "system", "tenant")
if (scoped := navigation.get(scope, {}).get(navigation_id)) is not None
}
return payload
def _frontend_route_payload(route: FrontendRoute, module_id: str | None = None) -> dict[str, object]:
@@ -132,6 +242,41 @@ def _view_surface_payload(surface: ViewSurface) -> dict[str, object]:
}
def _product_area_payload(area: ProductAreaContribution) -> dict[str, object]:
return {
"id": area.id,
"module_id": area.module_id,
"label": area.label,
"description": area.description,
"icon": area.icon,
"surface_ids": list(area.surface_ids),
"order": area.order,
}
def _quick_access_tool_payload(tool: QuickAccessTool) -> dict[str, object]:
return {
"id": tool.id,
"module_id": tool.module_id,
"category_id": tool.category_id,
"label": tool.label,
"description": tool.description,
"surface_id": tool.surface_id,
"icon": tool.icon,
"full_page_path": tool.full_page_path,
"required_all": list(tool.required_all),
"required_any": list(tool.required_any),
"order": tool.order,
"default_enabled": tool.default_enabled,
"modes": list(tool.modes),
"contract_version": tool.contract_version,
"availability": tool.availability,
"accepted_reference_kinds": list(tool.accepted_reference_kinds),
"returned_reference_kinds": list(tool.returned_reference_kinds),
"help_context_id": tool.help_context_id,
}
def _frontend_view_surfaces(manifest: ModuleManifest) -> list[dict[str, object]]:
return [
_view_surface_payload(surface)
@@ -139,7 +284,68 @@ def _frontend_view_surfaces(manifest: ModuleManifest) -> list[dict[str, object]]
]
def _frontend_payload(manifest: ModuleManifest) -> dict[str, object] | None:
def _documentation_help_contexts(manifest: ModuleManifest) -> list[dict[str, object]]:
contexts: list[dict[str, object]] = []
seen: set[str] = set()
def append_context(context_id: str, topic) -> None:
if not context_id or context_id in seen:
return
seen.add(context_id)
contexts.append(
{
"id": context_id,
"topic_id": topic.id,
"title": topic.title,
"documentation_types": list(topic.documentation_types),
}
)
for topic in manifest.documentation:
raw_contexts = topic.metadata.get("help_contexts", ())
if isinstance(raw_contexts, str) or not isinstance(raw_contexts, (list, tuple, set)):
continue
for raw_context in raw_contexts:
context_id = str(raw_context).strip()
append_context(context_id, topic)
frontend = manifest.frontend
if frontend is None or not manifest.documentation:
return contexts
ordered_topics = sorted(manifest.documentation, key=lambda item: (item.order, item.id))
def baseline_topic(documentation_type: str):
return next(
(
topic
for topic in ordered_topics
if documentation_type in topic.documentation_types
),
ordered_topics[0],
)
user_topic = baseline_topic("user")
admin_topic = baseline_topic("admin")
for route in frontend.routes:
if route.surface_id:
append_context(route.surface_id, user_topic)
for route in frontend.settings_routes:
if route.surface_id:
append_context(route.surface_id, admin_topic)
for item in frontend.nav_items:
if item.surface_id:
append_context(item.surface_id, user_topic)
for surface in frontend.view_surfaces:
topic = admin_topic if ".admin." in surface.id else user_topic
append_context(surface.id, topic)
return contexts
def _frontend_payload(
manifest: ModuleManifest,
navigation: dict[str, dict[str, EffectiveNavigationItem]] | None = None,
) -> dict[str, object] | None:
frontend = manifest.frontend
if frontend is None:
return None
@@ -155,10 +361,20 @@ def _frontend_payload(manifest: ModuleManifest) -> dict[str, object] | None:
"public_routes": [
_public_frontend_route_payload(route) for route in frontend.public_routes
],
"nav": [_nav_item_payload(item, manifest.id) for item in frontend.nav_items],
"nav": [
_nav_item_payload(item, manifest.id, navigation)
for item in frontend.nav_items
],
"settings_routes": [_frontend_route_payload(route, manifest.id) for route in frontend.settings_routes],
"view_surface_contract_version": VIEW_SURFACE_CONTRACT_VERSION,
"view_surfaces": _frontend_view_surfaces(manifest),
"presentation_contract_version": SUPPORTED_PRESENTATION_CONTRACT_VERSION,
"product_areas": [
_product_area_payload(area) for area in frontend.product_areas
],
"quick_access_tools": [
_quick_access_tool_payload(tool) for tool in frontend.quick_access_tools
],
}
@@ -214,6 +430,7 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
request,
principal,
)
navigation_state = _effective_navigation_state(principal, manifests)
principal_ref = getattr(principal, "principal", None)
tenant_id = getattr(principal_ref, "tenant_id", None)
return {
@@ -230,6 +447,7 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
if manifest.architecture is not None
else None
),
"information_governance": manifest.information_governance.to_dict(),
"external_providers": [
declaration.to_dict()
for declaration in manifest.external_providers
@@ -240,8 +458,12 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
for key, value in manifest_interface_catalog(manifest).items()
if key != "declarations"
},
"nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items],
"frontend": _frontend_payload(manifest),
"help_contexts": _documentation_help_contexts(manifest),
"nav": [
_nav_item_payload(item, manifest.id, navigation_state)
for item in manifest.nav_items
],
"frontend": _frontend_payload(manifest, navigation_state),
}
for manifest in manifests
],
@@ -274,6 +496,7 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
"id": manifest.id,
"name": manifest.name,
"version": manifest.version,
"help_contexts": _documentation_help_contexts(manifest),
"frontend": _public_frontend_payload(manifest.frontend),
}
for manifest in registry.manifests()
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from fastapi import Request, Response
from fastapi.responses import JSONResponse
from govoplan_core.core.temporal import (
RECORDED_AT_HEADER,
TEMPORAL_EVALUATED_AT_HEADER,
TEMPORAL_VARY_HEADERS,
VALIDITY_MODE_HEADER,
VALID_AT_HEADER,
TemporalContextError,
TemporalDataContext,
bind_temporal_data_context,
current_temporal_data_context,
parse_temporal_data_context,
reset_temporal_data_context,
)
async def temporal_data_context_middleware(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
try:
context = parse_temporal_data_context(
validity_mode=request.headers.get(VALIDITY_MODE_HEADER),
valid_at=request.headers.get(VALID_AT_HEADER),
recorded_at=request.headers.get(RECORDED_AT_HEADER),
evaluated_at=datetime.now(UTC),
)
except TemporalContextError as exc:
return JSONResponse(status_code=400, content={"detail": str(exc)})
request.state.govoplan_temporal_data_context = context
token = bind_temporal_data_context(context)
try:
response = await call_next(request)
finally:
reset_temporal_data_context(token)
response.headers[VALIDITY_MODE_HEADER] = context.validity_mode
response.headers[TEMPORAL_EVALUATED_AT_HEADER] = _timestamp(
context.evaluated_at
)
if context.valid_at is not None:
response.headers[VALID_AT_HEADER] = _timestamp(context.valid_at)
if context.recorded_at is not None:
response.headers[RECORDED_AT_HEADER] = _timestamp(context.recorded_at)
_merge_vary(response, TEMPORAL_VARY_HEADERS)
return response
def get_temporal_data_context(request: Request) -> TemporalDataContext:
context = getattr(request.state, "govoplan_temporal_data_context", None)
return context if isinstance(context, TemporalDataContext) else current_temporal_data_context()
def _merge_vary(response: Response, names: tuple[str, ...]) -> None:
current = {
item.strip().lower(): item.strip()
for item in response.headers.get("Vary", "").split(",")
if item.strip()
}
for name in names:
current.setdefault(name.lower(), name)
response.headers["Vary"] = ", ".join(current.values())
def _timestamp(value: datetime) -> str:
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
__all__ = ["get_temporal_data_context", "temporal_data_context_middleware"]
+11 -1
View File
@@ -107,7 +107,7 @@ class Settings(BaseSettings):
default=(
"tenancy,organizations,identity,idm,access,admin,dashboard,policy,"
"audit,campaigns,files,mail,calendar,poll,scheduling,connectors,"
"datasources,dataflow,dist_lists,templates,workflow_engine,workflow,views,search,risk_compliance,"
"datasources,dataflow,dist_lists,templates,workflow_engine,workflow,tasks,views,search,risk_compliance,"
"postbox,notifications,docs,ops"
),
alias="ENABLED_MODULES",
@@ -262,6 +262,16 @@ class Settings(BaseSettings):
le=90,
alias="SCHEDULING_CANCELLATION_NOTICE_DAYS",
)
scheduling_public_self_enrollment_enabled: bool = Field(
default=True,
alias="SCHEDULING_PUBLIC_SELF_ENROLLMENT_ENABLED",
)
scheduling_public_self_enrollment_max_capacity: int = Field(
default=10_000,
ge=1,
le=10_000,
alias="SCHEDULING_PUBLIC_SELF_ENROLLMENT_MAX_CAPACITY",
)
mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR")
# Development bootstrap only. Do not use this in production.
+1 -1
View File
@@ -33,7 +33,7 @@ class Tenant:
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False)
default_locale: Mapped[str] = mapped_column(String(20), default="de", nullable=False)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
allow_custom_groups: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
allow_custom_roles: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
+22
View File
@@ -32,6 +32,8 @@ from govoplan_core.core.access import (
AccessDirectory,
AccessDecisionProvenance,
AccessExplanationService,
AccessExplanationSubjectDecision,
AccessExplanationSubjectPolicy,
AccessGovernanceMaterializer,
ResourceAccessExplanationProvider,
AccessSemanticDirectory,
@@ -283,6 +285,22 @@ class _FakeAccessExplanationService:
return self.provenance
class _FakeAccessExplanationSubjectPolicy:
def decide_subject_selection(
self,
session: object,
principal: PrincipalRef,
*,
tenant_id: str,
) -> AccessExplanationSubjectDecision:
del session, principal, tenant_id
return AccessExplanationSubjectDecision(
allow_other_users=True,
reason="Administrator diagnostic permitted.",
source="test",
)
class _FakeTenantAccessProvisioner:
def ensure_default_roles(self, session: object, tenant: object | None = None):
del session, tenant
@@ -612,6 +630,10 @@ class AccessContractTests(unittest.TestCase):
self.assertIsInstance(_FakeResourceAccessExplanationProvider(), ResourceAccessExplanationProvider)
self.assertIsInstance(_FakeFileAccessProvider(), FileAccessProvider)
self.assertIsInstance(_FakeAccessExplanationService(), AccessExplanationService)
self.assertIsInstance(
_FakeAccessExplanationSubjectPolicy(),
AccessExplanationSubjectPolicy,
)
self.assertIsInstance(_FakeTenantAccessProvisioner(), TenantAccessProvisioner)
self.assertIsInstance(_FakeAccessAdministration(), AccessAdministration)
self.assertIsInstance(_FakeAccessGovernanceMaterializer(), AccessGovernanceMaterializer)
+492 -10
View File
@@ -2463,13 +2463,42 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(unchanged["file"]["id"], nextcloud_file["id"])
self.assertEqual(unchanged["current_version_id"], synced["current_version_id"])
with patch("govoplan_files.backend.storage.connector_browse._smbclient_module") as smb_sdk:
smb_browse = self.client.get("/api/v1/files/connectors/profiles/tenant-smb/browse?path=shared", headers=headers)
self.assertEqual(smb_browse.status_code, 400, smb_browse.text)
self.assertIn("redirects/referrals", smb_browse.json()["detail"])
smb_sdk.assert_not_called()
from govoplan_files.backend.storage.connector_browse import (
ConnectorBrowseUnsupported,
)
with patch("govoplan_files.backend.storage.connector_imports._smbclient_module") as smb_import_sdk:
optional_dependency_error = ConnectorBrowseUnsupported(
"SMB connector browsing requires the optional smbprotocol dependency"
)
with patch(
"govoplan_files.backend.storage.connector_browse._smbclient_module",
side_effect=optional_dependency_error,
) as missing_smb_loader:
missing_smb_browse = self.client.get(
"/api/v1/files/connectors/profiles/tenant-smb/browse?path=shared",
headers=headers,
)
self.assertEqual(missing_smb_browse.status_code, 501, missing_smb_browse.text)
self.assertIn("optional smbprotocol", missing_smb_browse.json()["detail"])
missing_smb_loader.assert_called_once_with()
with patch(
"govoplan_files.backend.storage.connector_browse._smbclient_module"
) as available_smb_loader:
available_smb_loader.return_value.scandir.return_value.__enter__.return_value = iter(())
available_smb_browse = self.client.get(
"/api/v1/files/connectors/profiles/tenant-smb/browse?path=shared",
headers=headers,
)
self.assertEqual(available_smb_browse.status_code, 200, available_smb_browse.text)
self.assertEqual([], available_smb_browse.json()["items"])
self.assertNotIn("super-secret-smb", available_smb_browse.text)
available_smb_loader.assert_called_once_with()
with patch(
"govoplan_files.backend.storage.connector_imports._smbclient_module",
side_effect=optional_dependency_error,
) as missing_smb_import_loader:
smb_import = self.client.post(
"/api/v1/files/connectors/profiles/tenant-smb/import",
headers=headers,
@@ -2481,9 +2510,9 @@ class ApiSmokeTests(unittest.TestCase):
"target_folder": "imports",
},
)
self.assertEqual(smb_import.status_code, 400, smb_import.text)
self.assertIn("redirects/referrals", smb_import.json()["detail"])
smb_import_sdk.assert_not_called()
self.assertEqual(smb_import.status_code, 501, smb_import.text)
self.assertIn("optional smbprotocol", smb_import.json()["detail"])
missing_smb_import_loader.assert_called_once_with()
filtered = self.client.get("/api/v1/files/connectors/profiles?provider=smb", headers=headers)
self.assertEqual(filtered.status_code, 200, filtered.text)
@@ -2915,7 +2944,18 @@ class ApiSmokeTests(unittest.TestCase):
updated_campaigns = {item["id"]: item for item in after_update.json()["campaigns"]}
self.assertEqual(updated_campaigns[campaign_id]["name"], "Delta campaign updated")
deleted = self.client.delete(f"/api/v1/campaigns/{campaign_id}", headers=headers)
lifecycle = self.client.get(
f"/api/v1/campaigns/{campaign_id}/lifecycle-policy",
headers=headers,
)
self.assertEqual(lifecycle.status_code, 200, lifecycle.text)
self.assertTrue(lifecycle.json()["actions"]["delete_campaign"]["allowed"])
deleted = self.client.request(
"DELETE",
f"/api/v1/campaigns/{campaign_id}",
headers=headers,
json={"expected_state_token": lifecycle.json()["state_token"]},
)
self.assertEqual(deleted.status_code, 204, deleted.text)
after_delete = self.client.get(
@@ -5261,6 +5301,12 @@ class ApiSmokeTests(unittest.TestCase):
"allow_tenant_api_keys": system_item["allow_tenant_api_keys"],
"available_languages": available_languages,
"enabled_language_codes": enabled_codes,
"navigation": {
"contract_version": "1",
"order": ["mail.navigation.mail", "files.navigation.files"],
"hidden": [],
"locked": ["mail.navigation.mail"],
},
},
)
self.assertEqual(updated_system.status_code, 200, updated_system.text)
@@ -5274,7 +5320,29 @@ class ApiSmokeTests(unittest.TestCase):
system_delta_payload = system_delta.json()
self.assertFalse(system_delta_payload["full"])
self.assertIn("languages", system_delta_payload["changed_sections"])
self.assertIn("navigation", system_delta_payload["changed_sections"])
self.assertIn("fr", system_delta_payload["sections"]["languages"]["enabled_language_codes"])
self.assertEqual(
["mail.navigation.mail"],
system_delta_payload["sections"]["navigation"]["locked"],
)
tenant_item = tenant_initial_payload["item"]
updated_tenant = self.client.patch(
"/api/v1/admin/tenant/settings",
headers=headers,
json={
"default_locale": tenant_item["default_locale"],
"enabled_language_codes": tenant_item["enabled_language_codes"],
"navigation": {
"contract_version": "1",
"order": ["files.navigation.files"],
"hidden": ["files.navigation.files"],
"locked": [],
},
},
)
self.assertEqual(updated_tenant.status_code, 200, updated_tenant.text)
tenant_delta = self.client.get(
"/api/v1/admin/tenant/settings/delta",
@@ -5285,7 +5353,12 @@ class ApiSmokeTests(unittest.TestCase):
tenant_delta_payload = tenant_delta.json()
self.assertFalse(tenant_delta_payload["full"])
self.assertIn("languages", tenant_delta_payload["changed_sections"])
self.assertIn("navigation", tenant_delta_payload["changed_sections"])
self.assertIn("fr", tenant_delta_payload["sections"]["languages"]["system_enabled_language_codes"])
self.assertEqual(
["files.navigation.files"],
tenant_delta_payload["sections"]["navigation"]["hidden"],
)
def test_tenant_admin_delta_tracks_create_and_update(self) -> None:
headers, _ = self._login()
@@ -5433,6 +5506,67 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(deleted_delta.status_code, 200, deleted_delta.text)
self.assertTrue(any(item["id"] == template_id and item["resource_type"] == "governance_template" for item in deleted_delta.json()["deleted"]))
def test_governance_template_bulk_synchronization_previews_and_applies(self) -> None:
headers, login = self._login()
tenant_id = login["tenant"]["id"]
approver_headers = self._create_system_approver(
headers,
tenant_id=tenant_id,
email="governance-sync-approver@example.local",
)
create_payload = {
"kind": "role",
"slug": "governance-sync",
"name": "Governance Sync",
"description": "Bulk reconciliation contract",
"permissions": ["admin:roles:read"],
"is_active": True,
"assignments": [{"tenant_id": tenant_id, "mode": "required"}],
}
change_request_id = self._approved_configuration_change(
headers,
approver_headers,
key="governance_templates",
value=create_payload,
target={"kind": "role", "slug": "governance-sync"},
)
created = self.client.post(
"/api/v1/admin/system/governance-templates",
headers=headers,
json={**create_payload, "change_request_id": change_request_id},
)
self.assertEqual(201, created.status_code, created.text)
template_id = created.json()["id"]
preview = self.client.post(
"/api/v1/admin/system/governance-templates/synchronize",
headers=headers,
json={"template_ids": [template_id], "dry_run": True},
)
self.assertEqual(200, preview.status_code, preview.text)
self.assertTrue(preview.json()["dry_run"])
self.assertEqual({"unchanged": 1}, preview.json()["counts"])
self.assertEqual("1", preview.json()["version"])
self.assertEqual("admin.bulk-synchronization", preview.json()["outcomes"][0]["provenance"]["source"])
applied = self.client.post(
"/api/v1/admin/system/governance-templates/synchronize",
headers=headers,
json={"template_ids": [template_id], "dry_run": False},
)
self.assertEqual(200, applied.status_code, applied.text)
self.assertFalse(applied.json()["dry_run"])
self.assertEqual({"unchanged": 1}, applied.json()["counts"])
audit = self.client.get(
"/api/v1/admin/audit",
headers=headers,
params={"all_tenants": True, "limit": 500},
)
actions = {item["action"] for item in audit.json()["items"]}
self.assertIn("governance_template.synchronization_previewed", actions)
self.assertIn("governance_template.synchronized", actions)
def test_module_installer_history_supports_cursor_windows(self) -> None:
from govoplan_core.core.module_installer import default_installer_runtime_dir
@@ -6085,6 +6219,121 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(campaign_source["path"], f"campaign:{campaign_id}")
self.assertIn("allow_campaign_profiles", campaign_source["applied_fields"])
def test_policy_impact_preview_is_bounded_audited_and_linked_to_commit(self) -> None:
headers, _ = self._login()
proposed_policy = {"visible_surface_ids": []}
preview = self.client.post(
"/api/v1/admin/policy-impact/preview",
headers=headers,
json={
"policy_family": "view",
"scope_type": "tenant",
"proposed_policy": proposed_policy,
"populations": [
{
"provider_id": "views",
"selector": {
"include_views": False,
"include_surfaces": True,
},
"limit": 10,
}
],
"include_details": True,
},
)
self.assertEqual(200, preview.status_code, preview.text)
preview_payload = preview.json()
self.assertEqual(10, preview_payload["counts"]["newly_denied"])
self.assertEqual("truncated", preview_payload["populations"][0]["state"])
self.assertEqual(10, len(preview_payload["effects"]))
unchanged = self.client.get(
"/api/v1/admin/view-policies/tenant",
headers=headers,
)
self.assertEqual(200, unchanged.status_code, unchanged.text)
self.assertEqual({}, unchanged.json()["policy"])
committed = self.client.put(
"/api/v1/admin/view-policies/tenant",
headers=headers,
json={
"policy": proposed_policy,
"impact_preview_id": preview_payload["preview_id"],
"impact_proposal_hash": preview_payload["proposal_hash"],
},
)
self.assertEqual(200, committed.status_code, committed.text)
self.assertEqual([], committed.json()["policy"]["visible_surface_ids"])
from govoplan_audit.backend.db.models import AuditLog
with SessionLocal() as session:
audit_rows = (
session.query(AuditLog)
.filter(
AuditLog.action.in_(
("policy.impact_previewed", "view_policy.updated")
)
)
.all()
)
by_action = {row.action: row for row in audit_rows}
self.assertEqual(
preview_payload["proposal_hash"],
by_action["policy.impact_previewed"].details["proposal_hash"],
)
self.assertEqual(
preview_payload["preview_id"],
by_action["view_policy.updated"].details["impact_preview_id"],
)
stale = self.client.put(
"/api/v1/admin/view-policies/tenant",
headers=headers,
json={
"policy": {"allow_edit": False},
"impact_preview_id": preview_payload["preview_id"],
"impact_proposal_hash": preview_payload["proposal_hash"],
},
)
self.assertEqual(409, stale.status_code, stale.text)
self.assertEqual("policy_impact_preview_stale", stale.json()["detail"]["code"])
inherited_preview = self.client.post(
"/api/v1/admin/policy-impact/preview",
headers=headers,
json={
"policy_family": "view",
"scope_type": "tenant",
"proposed_policy": {},
"populations": [
{
"provider_id": "views",
"selector": {
"include_views": False,
"include_surfaces": True,
},
"limit": 10,
}
],
},
)
self.assertEqual(200, inherited_preview.status_code, inherited_preview.text)
inherited_payload = inherited_preview.json()
removed = self.client.delete(
"/api/v1/admin/view-policies/tenant",
headers=headers,
params={
"impact_preview_id": inherited_payload["preview_id"],
"impact_proposal_hash": inherited_payload["proposal_hash"],
},
)
self.assertEqual(200, removed.status_code, removed.text)
self.assertEqual({}, removed.json()["policy"])
def test_campaign_scoped_mail_profile_policy_is_enforced(self) -> None:
headers, _ = self._login()
created = self.client.post(
@@ -6377,6 +6626,199 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(raw_test.status_code, 403, raw_test.text)
self.assertIn("manage_credentials", raw_test.json()["detail"])
def test_appearance_defaults_precedence_and_policy_lock(self) -> None:
headers, _ = self._login()
system = self.client.get("/api/v1/admin/system/settings", headers=headers)
self.assertEqual(system.status_code, 200, system.text)
system_payload = system.json()
system_saved = self.client.patch(
"/api/v1/admin/system/settings",
headers=headers,
json={
"default_locale": system_payload["default_locale"],
"allow_tenant_custom_groups": system_payload["allow_tenant_custom_groups"],
"allow_tenant_custom_roles": system_payload["allow_tenant_custom_roles"],
"allow_tenant_api_keys": system_payload["allow_tenant_api_keys"],
"appearance_palette": "civic_blue",
"appearance_palette_locked": False,
},
)
self.assertEqual(system_saved.status_code, 200, system_saved.text)
tenant = self.client.get("/api/v1/admin/tenant/settings", headers=headers)
self.assertEqual(tenant.status_code, 200, tenant.text)
tenant_payload = tenant.json()
tenant_saved = self.client.patch(
"/api/v1/admin/tenant/settings",
headers=headers,
json={
"default_locale": tenant_payload["default_locale"],
"appearance_palette": "forest",
"appearance_palette_locked": False,
},
)
self.assertEqual(tenant_saved.status_code, 200, tenant_saved.text)
self.assertEqual(tenant_saved.json()["effective_appearance_palette"], "forest")
inherited = self.client.patch(
"/api/v1/auth/profile",
headers=headers,
json={"ui_preferences": {"palette": None}},
)
self.assertEqual(inherited.status_code, 200, inherited.text)
self.assertEqual(inherited.json()["user"]["appearance"]["palette"], "forest")
self.assertEqual(inherited.json()["user"]["appearance"]["source"], "tenant")
explicit = self.client.patch(
"/api/v1/auth/profile",
headers=headers,
json={"ui_preferences": {"palette": "plum"}},
)
self.assertEqual(explicit.status_code, 200, explicit.text)
self.assertEqual(explicit.json()["user"]["appearance"]["source"], "user")
tenant_locked = self.client.patch(
"/api/v1/admin/tenant/settings",
headers=headers,
json={
"default_locale": tenant_payload["default_locale"],
"appearance_palette": "forest",
"appearance_palette_locked": True,
},
)
self.assertEqual(tenant_locked.status_code, 200, tenant_locked.text)
locked_profile = self.client.get("/api/v1/auth/profile", headers=headers)
self.assertEqual(locked_profile.json()["user"]["appearance"]["source"], "tenant_lock")
self.assertTrue(locked_profile.json()["user"]["appearance"]["locked"])
unchanged_palette = self.client.patch(
"/api/v1/auth/profile",
headers=headers,
json={"ui_preferences": {"palette": "plum", "theme": "dark"}},
)
self.assertEqual(unchanged_palette.status_code, 200, unchanged_palette.text)
self.assertEqual(unchanged_palette.json()["user"]["ui_preferences"]["theme"], "dark")
denied = self.client.patch(
"/api/v1/auth/profile",
headers=headers,
json={"ui_preferences": {"palette": "default"}},
)
self.assertEqual(denied.status_code, 422, denied.text)
system_locked = self.client.patch(
"/api/v1/admin/system/settings",
headers=headers,
json={
"default_locale": system_payload["default_locale"],
"allow_tenant_custom_groups": system_payload["allow_tenant_custom_groups"],
"allow_tenant_custom_roles": system_payload["allow_tenant_custom_roles"],
"allow_tenant_api_keys": system_payload["allow_tenant_api_keys"],
"appearance_palette": "civic_blue",
"appearance_palette_locked": True,
},
)
self.assertEqual(system_locked.status_code, 200, system_locked.text)
blocked_tenant_override = self.client.patch(
"/api/v1/admin/tenant/settings",
headers=headers,
json={
"default_locale": tenant_payload["default_locale"],
"appearance_palette": "plum",
"appearance_palette_locked": False,
},
)
self.assertEqual(blocked_tenant_override.status_code, 422, blocked_tenant_override.text)
final_profile = self.client.get("/api/v1/auth/profile", headers=headers)
self.assertEqual(final_profile.json()["user"]["appearance"]["palette"], "civic_blue")
self.assertEqual(final_profile.json()["user"]["appearance"]["source"], "system_lock")
def test_governed_custom_appearance_overrides_are_atomic_and_removable(self) -> None:
headers, _ = self._login()
document = {
"schema_version": "1",
"light": {
"accent": "#245f91", "accent_foreground": "#ffffff",
"surface": "#ffffff", "surface_foreground": "#303135",
"success": "#d8eee8", "success_foreground": "#315f55",
"info": "#dce9f3", "info_foreground": "#294a61",
"warning": "#ffe1a3", "warning_foreground": "#593700",
"danger": "#f8d1cc", "danger_foreground": "#873c35",
},
"dark": {
"accent": "#7ea6c5", "accent_foreground": "#242424",
"surface": "#262724", "surface_foreground": "#f1f1f1",
"success": "#24473f", "success_foreground": "#d8eee8",
"info": "#243d4e", "info_foreground": "#dce9f3",
"warning": "#5a431f", "warning_foreground": "#ffe1a3",
"danger": "#4f2d2a", "danger_foreground": "#f8d1cc",
},
}
initially_denied = self.client.patch(
"/api/v1/auth/profile",
headers=headers,
json={"ui_preferences": {"appearance_overrides": document}},
)
self.assertEqual(initially_denied.status_code, 422, initially_denied.text)
system = self.client.get("/api/v1/admin/system/settings", headers=headers).json()
enabled = self.client.patch(
"/api/v1/admin/system/settings",
headers=headers,
json={
"default_locale": system["default_locale"],
"allow_tenant_custom_groups": system["allow_tenant_custom_groups"],
"allow_tenant_custom_roles": system["allow_tenant_custom_roles"],
"allow_tenant_api_keys": system["allow_tenant_api_keys"],
"appearance_custom_overrides_allowed": True,
},
)
self.assertEqual(enabled.status_code, 200, enabled.text)
self.assertTrue(enabled.json()["appearance_custom_overrides_allowed"])
tenant = self.client.get("/api/v1/admin/tenant/settings", headers=headers).json()
self.assertIsNone(tenant["appearance_custom_overrides_allowed"])
self.assertTrue(tenant["effective_appearance_custom_overrides_allowed"])
saved = self.client.patch(
"/api/v1/auth/profile",
headers=headers,
json={"ui_preferences": {"appearance_overrides": document}},
)
self.assertEqual(saved.status_code, 200, saved.text)
self.assertEqual(saved.json()["user"]["appearance"]["custom_overrides"], document)
invalid = {
**document,
"light": {**document["light"], "accent_foreground": document["light"]["accent"]},
}
rejected = self.client.patch(
"/api/v1/auth/profile",
headers=headers,
json={"ui_preferences": {"appearance_overrides": invalid}},
)
self.assertEqual(rejected.status_code, 422, rejected.text)
unchanged = self.client.get("/api/v1/auth/profile", headers=headers).json()
self.assertEqual(unchanged["user"]["appearance"]["custom_overrides"], document)
blocked = self.client.patch(
"/api/v1/admin/tenant/settings",
headers=headers,
json={
"default_locale": tenant["default_locale"],
"appearance_custom_overrides_allowed": False,
},
)
self.assertEqual(blocked.status_code, 200, blocked.text)
self.assertFalse(blocked.json()["effective_appearance_custom_overrides_allowed"])
inactive = self.client.get("/api/v1/auth/profile", headers=headers).json()
self.assertIsNone(inactive["user"]["appearance"]["custom_overrides"])
removed = self.client.patch(
"/api/v1/auth/profile",
headers=headers,
json={"ui_preferences": {"appearance_overrides": None}},
)
self.assertEqual(removed.status_code, 200, removed.text)
self.assertIsNone(removed.json()["user"]["ui_preferences"]["appearance_overrides"])
def test_profile_refresh_and_system_role_protection_model(self) -> None:
headers, _ = self._login()
profile = self.client.patch(
@@ -6391,6 +6833,12 @@ class ApiSmokeTests(unittest.TestCase):
"reduce_motion": True,
"sticky_section_sidebars": False,
"theme": "dark",
"palette": "civic_blue",
"navigation": {
"contract_version": "1",
"order": ["files.navigation.files", "mail.navigation.mail"],
"hidden": ["mail.navigation.mail"],
},
},
},
)
@@ -6405,13 +6853,47 @@ class ApiSmokeTests(unittest.TestCase):
"reduce_motion": True,
"sticky_section_sidebars": False,
"theme": "dark",
"palette": "civic_blue",
"appearance_overrides": None,
"navigation": {
"contract_version": "1",
"order": ["files.navigation.files", "mail.navigation.mail"],
"hidden": ["mail.navigation.mail"],
"locked": [],
},
},
)
rejected_lock = self.client.patch(
"/api/v1/auth/profile",
headers=headers,
json={
"ui_preferences": {
**profile.json()["user"]["ui_preferences"],
"navigation": {
"contract_version": "1",
"order": [],
"hidden": [],
"locked": ["files.navigation.files"],
},
},
},
)
self.assertEqual(rejected_lock.status_code, 422, rejected_lock.text)
rejected_palette = self.client.patch(
"/api/v1/auth/profile",
headers=headers,
json={"ui_preferences": {"palette": "low_contrast_custom"}},
)
self.assertEqual(rejected_palette.status_code, 422, rejected_palette.text)
refreshed = self.client.get("/api/v1/auth/me", headers=headers)
self.assertEqual(refreshed.status_code, 200, refreshed.text)
self.assertEqual(refreshed.json()["user"]["display_name"], "Global Account Name")
self.assertEqual(refreshed.json()["user"]["tenant_display_name"], "Tenant Alias")
self.assertEqual(refreshed.json()["user"]["ui_preferences"]["theme"], "dark")
self.assertEqual(
refreshed.json()["user"]["ui_preferences"]["palette"],
"civic_blue",
)
self.assertFalse(refreshed.json()["user"]["ui_preferences"]["show_inline_help_hints"])
roles = self.client.get("/api/v1/admin/system/roles", headers=headers)
+127
View File
@@ -0,0 +1,127 @@
from __future__ import annotations
import pytest
from govoplan_core.core.appearance import (
normalize_appearance_overrides,
resolve_effective_appearance,
update_appearance_custom_overrides_policy,
update_appearance_settings,
)
def _overrides() -> dict[str, object]:
return {
"schema_version": "1",
"light": {
"accent": "#245f91", "accent_foreground": "#ffffff",
"surface": "#ffffff", "surface_foreground": "#303135",
"success": "#d8eee8", "success_foreground": "#315f55",
"info": "#dce9f3", "info_foreground": "#294a61",
"warning": "#ffe1a3", "warning_foreground": "#593700",
"danger": "#f8d1cc", "danger_foreground": "#873c35",
},
"dark": {
"accent": "#7ea6c5", "accent_foreground": "#242424",
"surface": "#262724", "surface_foreground": "#f1f1f1",
"success": "#24473f", "success_foreground": "#d8eee8",
"info": "#243d4e", "info_foreground": "#dce9f3",
"warning": "#5a431f", "warning_foreground": "#ffe1a3",
"danger": "#4f2d2a", "danger_foreground": "#f8d1cc",
},
}
def test_appearance_precedence_and_inheritance() -> None:
decision = resolve_effective_appearance(
system_settings={"appearance": {"default_palette": "civic_blue"}},
tenant_settings={"appearance": {"default_palette": "forest"}},
user_settings={"ui": {"palette": "plum"}},
)
assert (decision.palette, decision.source, decision.locked) == ("plum", "user", False)
inherited = resolve_effective_appearance(
system_settings={"appearance": {"default_palette": "civic_blue"}},
tenant_settings={"appearance": {"default_palette": "forest"}},
user_settings={"ui": {"palette": None}},
)
assert (inherited.palette, inherited.source) == ("forest", "tenant")
def test_system_lock_wins_and_invalid_values_fail_safe() -> None:
decision = resolve_effective_appearance(
system_settings={"appearance": {"default_palette": "civic_blue", "palette_locked": True}},
tenant_settings={"appearance": {"default_palette": "forest", "palette_locked": True}},
user_settings={"ui": {"palette": "plum"}},
)
assert decision.as_dict() == {
"palette": "civic_blue",
"source": "system_lock",
"locked": True,
"system_default_palette": "civic_blue",
"tenant_default_palette": "forest",
"inherited_palette": "civic_blue",
"custom_overrides": None,
"custom_overrides_allowed": False,
}
fallback = resolve_effective_appearance(
system_settings={"appearance": {"default_palette": "unsafe"}},
tenant_settings={},
user_settings={"ui": {"palette": "unknown"}},
)
assert (fallback.palette, fallback.source) == ("default", "system")
def test_appearance_settings_reset_without_touching_neighbors() -> None:
configured = update_appearance_settings(
{"neighbor": {"kept": True}},
default_palette="forest",
palette_locked=True,
)
assert configured["neighbor"] == {"kept": True}
assert configured["appearance"] == {"default_palette": "forest", "palette_locked": True}
assert update_appearance_settings(configured, default_palette=None, palette_locked=False) == {
"neighbor": {"kept": True}
}
def test_custom_overrides_require_system_and_tenant_policy_and_validate_both_modes() -> None:
document = _overrides()
normalized = normalize_appearance_overrides(document)
assert normalized == document
decision = resolve_effective_appearance(
system_settings={"appearance": {"allow_custom_overrides": True}},
tenant_settings={"appearance": {"allow_custom_overrides": True}},
user_settings={"ui": {"appearance_overrides": document}},
)
assert decision.custom_overrides_allowed is True
assert decision.custom_overrides == document
blocked = resolve_effective_appearance(
system_settings={"appearance": {"allow_custom_overrides": True}},
tenant_settings={"appearance": {"allow_custom_overrides": False}},
user_settings={"ui": {"appearance_overrides": document}},
)
assert blocked.custom_overrides_allowed is False
assert blocked.custom_overrides is None
invalid = _overrides()
invalid["dark"]["danger"] = invalid["dark"]["warning"] # type: ignore[index]
with pytest.raises(ValueError, match="visibly distinct"):
normalize_appearance_overrides(invalid)
low_contrast = _overrides()
low_contrast["light"]["accent_foreground"] = "#245f91" # type: ignore[index]
with pytest.raises(ValueError, match="WCAG AA"):
normalize_appearance_overrides(low_contrast)
def test_custom_override_policy_update_preserves_neighboring_appearance_settings() -> None:
configured = update_appearance_custom_overrides_policy(
{"appearance": {"default_palette": "forest"}},
allowed=True,
)
assert configured == {"appearance": {"default_palette": "forest", "allow_custom_overrides": True}}
assert update_appearance_custom_overrides_policy(configured, allowed=None) == {
"appearance": {"default_palette": "forest"}
}
+64
View File
@@ -0,0 +1,64 @@
from datetime import UTC, datetime
import unittest
from govoplan_core.core.application_status import (
CAPABILITY_APPLICATION_STATUS_PROJECTION,
application_status_projection_provider,
)
class _Provider:
def tenant_id_for_tracking_id(self, session, *, tracking_id):
return "tenant-1"
def public_access_challenge(self, session, *, tracking_id):
return {"tracking_id": tracking_id, "mode": "permanent_link"}
def get_authenticated_projection(
self, session, principal, *, tracking_id, observed_at
):
return {"tracking_id": tracking_id, "status": "submitted"}
def get_public_projection(self, session, *, tracking_id, token, observed_at):
return {"tracking_id": tracking_id, "status": "submitted"}
def request_email_link(self, session, *, tracking_id, email, requested_at):
return True
class _Registry:
def __init__(self, provider):
self.provider = provider
def has_capability(self, name):
return name == CAPABILITY_APPLICATION_STATUS_PROJECTION
def capability(self, name):
return self.provider
class ApplicationStatusContractTests(unittest.TestCase):
def test_resolves_only_structurally_complete_provider(self):
provider = _Provider()
resolved = application_status_projection_provider(_Registry(provider))
self.assertIs(provider, resolved)
self.assertEqual(
"tenant-1",
resolved.tenant_id_for_tracking_id(None, tracking_id="tracking-1"),
)
self.assertTrue(
resolved.request_email_link(
None,
tracking_id="tracking-1",
email="resident@example.test",
requested_at=datetime(2026, 8, 19, tzinfo=UTC),
)
)
def test_rejects_incomplete_provider(self):
self.assertIsNone(application_status_projection_provider(_Registry(object())))
if __name__ == "__main__":
unittest.main()
+106
View File
@@ -0,0 +1,106 @@
from __future__ import annotations
import unittest
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from govoplan_core.celery_app import celery, dispatch_campaign_schedules
from tests.worker_test_support import allowed_worker_admissions
class CampaignScheduleWorkerTests(unittest.TestCase):
def test_dispatch_preserves_autonomous_recovery_result(self) -> None:
session = MagicMock()
@contextmanager
def session_scope():
yield session
provider = MagicMock()
provider.dispatch_due.return_value = {
"selected": 1,
"prepared": 0,
"autonomous_prepared": 1,
"failed": 0,
"completed": 0,
"coalesced": 0,
"duplicates": 0,
"deferred": 0,
"campaign_ids": ["campaign-1"],
"operator_actions": [],
"refreshed": {
"checked": 1,
"accepted": 1,
"uncertain": 0,
"failed": 0,
"skipped": 0,
},
}
registry = MagicMock()
registry.has_capability.return_value = True
database = SimpleNamespace(SessionLocal=session_scope)
with (
patch("govoplan_core.celery_app._platform_registry", return_value=registry),
patch("govoplan_core.celery_app._campaign_schedules", return_value=provider),
patch(
"govoplan_core.celery_app._worker_admissions",
side_effect=allowed_worker_admissions,
),
patch("govoplan_core.db.session.get_database", return_value=database),
):
result = dispatch_campaign_schedules.run("tenant-1", 17)
provider.dispatch_due.assert_called_once_with(
session,
tenant_id="tenant-1",
limit=17,
)
session.commit.assert_called_once_with()
self.assertEqual(result["autonomous_prepared"], 1)
self.assertEqual(result["refreshed"]["accepted"], 1)
def test_missing_provider_returns_complete_autonomous_defaults(self) -> None:
session = MagicMock()
@contextmanager
def session_scope():
yield session
registry = MagicMock()
registry.has_capability.return_value = False
database = SimpleNamespace(SessionLocal=session_scope)
with (
patch("govoplan_core.celery_app._platform_registry", return_value=registry),
patch("govoplan_core.db.session.get_database", return_value=database),
):
result = dispatch_campaign_schedules.run("tenant-1", 17)
self.assertEqual(result["autonomous_prepared"], 0)
self.assertEqual(result["duplicates"], 0)
self.assertEqual(result["deferred"], 0)
self.assertEqual(
result["refreshed"],
{
"checked": 0,
"accepted": 0,
"uncertain": 0,
"failed": 0,
"skipped": 0,
},
)
def test_worker_route_and_periodic_dispatch_are_registered(self) -> None:
self.assertEqual(
celery.conf.task_routes["govoplan.campaigns.dispatch_schedules"],
{"queue": "default"},
)
schedule = celery.conf.beat_schedule["campaign-schedules-every-minute"]
self.assertEqual(schedule["task"], "govoplan.campaigns.dispatch_schedules")
self.assertEqual(schedule["schedule"], 60.0)
if __name__ == "__main__":
unittest.main()
+18
View File
@@ -48,6 +48,10 @@ class ConditionalRequestTests(unittest.TestCase):
self.assertIn("private", first.headers.get("cache-control", ""))
self.assertIn("no-cache", first.headers.get("cache-control", ""))
self.assertIn("authorization", first.headers.get("vary", "").lower())
self.assertIn(
"x-govoplan-validity-mode",
first.headers.get("vary", "").lower(),
)
self.assertEqual("request-1", first.headers["X-Correlation-ID"])
second = client.get("/json", headers={"If-None-Match": etag or "", "X-Request-ID": "request-2"})
@@ -56,6 +60,20 @@ class ConditionalRequestTests(unittest.TestCase):
self.assertEqual(etag, second.headers.get("etag"))
self.assertEqual("request-2", second.headers["X-Correlation-ID"])
historical = client.get(
"/json",
headers={
"X-Govoplan-Validity-Mode": "at",
"X-Govoplan-Valid-At": "2025-02-03T10:30:00Z",
},
)
self.assertEqual(200, historical.status_code, historical.text)
self.assertEqual("at", historical.headers["X-Govoplan-Validity-Mode"])
self.assertIn(
"x-govoplan-valid-at",
historical.headers.get("vary", "").lower(),
)
def test_changed_json_body_does_not_match_previous_etag(self) -> None:
with self._client() as client:
first = client.get("/json?value=alpha")
+43
View File
@@ -184,6 +184,12 @@ class DatabaseMigrationTests(unittest.TestCase):
system_settings_count = connection.execute(
text("SELECT COUNT(*) FROM core_system_settings WHERE id = 'global'"),
).scalar_one()
default_locale = connection.execute(
text(
"SELECT default_locale FROM core_system_settings "
"WHERE id = 'global'"
),
).scalar_one()
current = database_migration_heads(connection)
self.assertEqual(
@@ -204,6 +210,7 @@ class DatabaseMigrationTests(unittest.TestCase):
self.assertIn("file_assets", tables)
self.assertIn("mail_server_profiles", tables)
self.assertEqual(system_settings_count, 1)
self.assertEqual(default_locale, "de")
self.assertEqual(
role_flags,
{"system_owner": True, "system_admin": False, "system_auditor": False},
@@ -211,6 +218,42 @@ class DatabaseMigrationTests(unittest.TestCase):
finally:
engine.dispose()
def test_german_reference_migration_preserves_explicit_english(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-locale-migration-test-") as directory:
database = Path(directory) / "locale.db"
url = f"sqlite:///{database}"
config = alembic_config(database_url=url, enabled_modules=())
command.upgrade(config, "f25c9d3e7a01")
engine = create_engine(url)
try:
with engine.begin() as connection:
connection.execute(
text(
"UPDATE core_system_settings "
"SET default_locale = 'en', "
"updated_at = '2040-01-01 00:00:00' "
"WHERE id = 'global'"
)
)
finally:
engine.dispose()
command.upgrade(config, "heads")
engine = create_engine(url)
try:
with engine.connect() as connection:
default_locale = connection.execute(
text(
"SELECT default_locale FROM core_system_settings "
"WHERE id = 'global'"
)
).scalar_one()
self.assertEqual(default_locale, "en")
finally:
engine.dispose()
def test_default_module_baselines_apply_to_fresh_database(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-default-baseline-test-") as directory:
database = Path(directory) / "default.db"
+20
View File
@@ -3,11 +3,14 @@ from __future__ import annotations
import unittest
from govoplan_core.core.datasources import (
CAPABILITY_DATASOURCE_ARTIFACT_BACKENDS,
CAPABILITY_DATASOURCE_CATALOGUE,
CAPABILITY_DATASOURCE_LIFECYCLE,
CAPABILITY_DATASOURCE_ORIGINS,
CAPABILITY_DATASOURCE_PUBLICATION,
CAPABILITY_POLICY_DATASOURCE_VISIBILITY,
DatasourceCatalogueProvider,
DatasourceArtifactBackendProvider,
DatasourceDescriptor,
DatasourceField,
DatasourceLifecycleProvider,
@@ -19,10 +22,14 @@ from govoplan_core.core.datasources import (
DatasourcePublicationResult,
DatasourceReadResult,
DatasourceStage,
DatasourceVisibilityPolicyDecision,
DatasourceVisibilityPolicyProvider,
datasource_catalogue,
datasource_artifact_backend_provider,
datasource_lifecycle,
datasource_origins,
datasource_publication,
datasource_visibility_policy_provider,
)
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
@@ -155,6 +162,13 @@ class _Provider:
truncated=False,
)
def artifact_backends(self):
return ()
def decide_datasource_visibility(self, session, *, request):
del session, request
return DatasourceVisibilityPolicyDecision(allowed=True)
class DatasourceContractTests(unittest.TestCase):
def test_capabilities_are_runtime_checkable_and_resolved_without_modules(self) -> None:
@@ -163,6 +177,8 @@ class DatasourceContractTests(unittest.TestCase):
self.assertIsInstance(provider, DatasourceLifecycleProvider)
self.assertIsInstance(provider, DatasourcePublicationProvider)
self.assertIsInstance(provider, DatasourceOriginProvider)
self.assertIsInstance(provider, DatasourceArtifactBackendProvider)
self.assertIsInstance(provider, DatasourceVisibilityPolicyProvider)
registry = PlatformRegistry()
registry.register(
ModuleManifest(
@@ -174,6 +190,8 @@ class DatasourceContractTests(unittest.TestCase):
CAPABILITY_DATASOURCE_LIFECYCLE: lambda context: provider,
CAPABILITY_DATASOURCE_PUBLICATION: lambda context: provider,
CAPABILITY_DATASOURCE_ORIGINS: lambda context: provider,
CAPABILITY_DATASOURCE_ARTIFACT_BACKENDS: lambda context: provider,
CAPABILITY_POLICY_DATASOURCE_VISIBILITY: lambda context: provider,
},
)
)
@@ -183,6 +201,8 @@ class DatasourceContractTests(unittest.TestCase):
self.assertIs(provider, datasource_lifecycle(registry))
self.assertIs(provider, datasource_publication(registry))
self.assertIs(provider, datasource_origins(registry))
self.assertIs(provider, datasource_artifact_backend_provider(registry))
self.assertIs(provider, datasource_visibility_policy_provider(registry))
self.assertIsNone(datasource_catalogue(PlatformRegistry()))
def test_descriptor_distinguishes_mode_kind_shape_and_materialization(self) -> None:
+162
View File
@@ -0,0 +1,162 @@
from __future__ import annotations
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.dsar import DsarRecordRef
from govoplan_core.db.base import Base
from govoplan_core.db.session import get_session
from govoplan_core.privacy.dsar_workflow import DataSubjectRequest
from govoplan_core.server.dsar import router
class _Provider:
provider_id = "example"
module_id = "example"
def search_subject(self, session, *, tenant_id, subject):
del session, tenant_id, subject
return (
DsarRecordRef(
provider_id="example",
module_id="example",
resource_type="profile",
resource_id="profile-1",
category="profile",
title="Example profile",
),
)
def plan_erasure(self, session, *, tenant_id, subject, records):
del session, tenant_id, subject, records
return ()
def execute_erasure(self, session, *, tenant_id, subject, actions, request_id):
del session, tenant_id, subject, actions, request_id
return ()
class _Registry:
provider = _Provider()
def capability_names(self):
return ("privacy.dsar.example",)
def require_tenant_capability(self, name, session, **kwargs):
del name, session, kwargs
return self.provider
def manifests(self):
return (SimpleNamespace(id="example"),)
class DsarApiTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine(
"sqlite+pysqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
DataSubjectRequest.__table__.create(self.engine)
self.session = sessionmaker(bind=self.engine, expire_on_commit=False)()
self.principal = _principal(
"access:privacy:read",
"access:privacy:manage",
"access:privacy:export",
"access:privacy:erase",
)
app = FastAPI()
app.state.govoplan_registry = _Registry()
app.include_router(router, prefix="/api/v1")
app.dependency_overrides[get_session] = lambda: self.session
app.dependency_overrides[get_api_principal] = lambda: self.principal
self.audit_patch = patch("govoplan_core.server.dsar.audit_event")
self.audit_patch.start()
self.client = TestClient(app)
def tearDown(self) -> None:
self.client.close()
self.audit_patch.stop()
self.session.close()
Base.metadata.drop_all(self.engine, tables=[DataSubjectRequest.__table__])
self.engine.dispose()
def test_mutation_requires_strong_revision_and_export_is_available(self) -> None:
created = self.client.post(
"/api/v1/admin/privacy/data-subject-requests",
json={
"reference": "DSAR-1",
"request_kind": "access",
"subject": {"email": "ada@example.test"},
"purpose": "Verified access request",
},
)
self.assertEqual(201, created.status_code, created.text)
item = created.json()["request"]
missing = self.client.post(
f"/api/v1/admin/privacy/data-subject-requests/{item['id']}/search",
json={"base_revision": item["resource_revision"]},
)
self.assertEqual(428, missing.status_code, missing.text)
searched = self.client.post(
f"/api/v1/admin/privacy/data-subject-requests/{item['id']}/search",
headers={"If-Match": item["etag"]},
json={"base_revision": item["resource_revision"]},
)
self.assertEqual(200, searched.status_code, searched.text)
self.assertEqual(1, searched.json()["search"]["record_count"])
stale = self.client.post(
f"/api/v1/admin/privacy/data-subject-requests/{item['id']}/search",
headers={"If-Match": item["etag"]},
json={"base_revision": item["resource_revision"]},
)
self.assertEqual(409, stale.status_code, stale.text)
exported = self.client.get(
f"/api/v1/admin/privacy/data-subject-requests/{item['id']}/export"
)
self.assertEqual(200, exported.status_code, exported.text)
self.assertIn("govoplan.dsars.export.v1", exported.text)
def test_privacy_scopes_are_independent(self) -> None:
self.principal = _principal("access:privacy:read")
denied = self.client.post(
"/api/v1/admin/privacy/data-subject-requests",
json={
"reference": "DSAR-2",
"request_kind": "access",
"subject": {"email": "ada@example.test"},
"purpose": "Verified access request",
},
)
self.assertEqual(403, denied.status_code, denied.text)
self.assertEqual(200, self.client.get("/api/v1/admin/privacy/data-subject-requests").status_code)
def _principal(*scopes: str) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id="account-operator",
membership_id="membership-operator",
tenant_id="tenant-1",
scopes=frozenset(scopes),
),
account=SimpleNamespace(id="account-operator"),
user=SimpleNamespace(id="membership-operator"),
)
if __name__ == "__main__":
unittest.main()
+236
View File
@@ -0,0 +1,236 @@
from __future__ import annotations
import json
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from govoplan_core.core.concurrency import RevisionConflictError
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
)
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
DataSubjectRequest,
create_data_subject_request,
data_subject_export,
execute_data_subject_erasure,
plan_data_subject_erasure,
search_data_subject_request,
)
class _Provider:
provider_id = "example"
module_id = "example"
def __init__(self) -> None:
self.executions = 0
def search_subject(self, session, *, tenant_id, subject):
del session, tenant_id, subject
return (
DsarRecordRef(
provider_id=self.provider_id,
module_id=self.module_id,
resource_type="profile",
resource_id="profile-1",
category="personal",
title="Example profile",
data={"name": "Ada"},
),
DsarRecordRef(
provider_id=self.provider_id,
module_id=self.module_id,
resource_type="audit_evidence",
resource_id="event-1",
category="evidence",
title="Decision event",
immutable_evidence=True,
retention_reason="Required decision evidence.",
),
)
def plan_erasure(self, session, *, tenant_id, subject, records):
del session, tenant_id, subject, records
return (
DsarErasureActionRef(
action_id="example:anonymize:profile-1",
provider_id=self.provider_id,
module_id=self.module_id,
kind="anonymize",
resource_type="profile",
resource_id="profile-1",
title="Anonymize profile",
rationale="Remove profile data.",
executable=True,
irreversible=True,
),
)
def execute_erasure(self, session, *, tenant_id, subject, actions, request_id):
del session, tenant_id, subject, request_id
self.executions += 1
return tuple(
DsarExecutionResultRef(
action_id=action.action_id,
status="executed",
summary="Profile anonymized.",
)
for action in actions
)
class _Registry:
def __init__(self, provider: _Provider) -> None:
self.provider = provider
def capability_names(self):
return ("privacy.dsar.example", "privacy.dsar.inactive")
def capability_owner(self, name):
return {
"privacy.dsar.example": "example",
"privacy.dsar.inactive": "inactive",
}[name]
def tenant_entitlement_resolver(self):
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type(
"State",
(),
{"effective_modules": ("example", "without-provider")},
)()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del name, session, kwargs
return self.provider
def manifests(self):
return (
type("Manifest", (), {"id": "example"})(),
type("Manifest", (), {"id": "without-provider"})(),
)
class DsarWorkflowTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine(
"sqlite+pysqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
DataSubjectRequest.__table__.create(self.engine)
self.session: Session = sessionmaker(
bind=self.engine,
expire_on_commit=False,
)()
self.provider = _Provider()
self.registry = _Registry(self.provider)
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(self.engine, tables=[DataSubjectRequest.__table__])
self.engine.dispose()
def test_search_plan_export_and_idempotent_execution(self) -> None:
row = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-2026-001",
request_kind="access_and_erasure",
subject=DsarSubjectRef(email="ada@example.test"),
purpose="Respond to a verified data-subject request.",
legal_basis="Article 15 and 17 GDPR",
due_at=None,
requested_by_account_id="account-operator",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=self.registry,
row=row,
expected_revision=1,
)
self.assertEqual("searched", row.status)
self.assertEqual(2, row.search_result["record_count"])
self.assertEqual(["without-provider"], row.coverage["modules_without_provider"])
self.assertEqual(
["privacy.dsar.inactive"],
row.coverage["inactive_provider_capabilities"],
)
plan_data_subject_erasure(
self.session,
registry=self.registry,
row=row,
expected_revision=2,
)
actions = row.erasure_plan["actions"]
self.assertEqual(2, len(actions))
self.assertEqual(1, row.erasure_plan["retained_count"])
executable_id = next(item["action_id"] for item in actions if item["executable"])
execute_data_subject_erasure(
self.session,
registry=self.registry,
row=row,
expected_revision=3,
action_ids=[executable_id],
)
self.assertEqual("completed", row.status)
self.assertEqual(1, self.provider.executions)
export = json.loads(data_subject_export(row))
self.assertEqual("govoplan.dsars.export.v1", export["schema"])
self.assertEqual("DSAR-2026-001", export["request"]["reference"])
self.assertEqual(64, len(export["manifest_sha256"]))
with self.assertRaises(RevisionConflictError):
search_data_subject_request(
self.session,
registry=self.registry,
row=row,
expected_revision=1,
)
def test_access_only_request_cannot_create_erasure_plan(self) -> None:
row = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-2026-002",
request_kind="access",
subject=DsarSubjectRef(account_id="account-1"),
purpose="Provide access information.",
legal_basis=None,
due_at=None,
requested_by_account_id="account-operator",
)
search_data_subject_request(
self.session,
registry=self.registry,
row=row,
expected_revision=1,
)
with self.assertRaisesRegex(ValueError, "does not include erasure"):
plan_data_subject_erasure(
self.session,
registry=self.registry,
row=row,
expected_revision=2,
)
if __name__ == "__main__":
unittest.main()
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
import unittest
from datetime import UTC, datetime
from govoplan_core.core.files import (
CAPABILITY_FILES_TABULAR_CONTENT,
ManagedTabularFile,
ManagedTabularFileContent,
ManagedTabularFileProvider,
managed_tabular_file_provider,
)
class _Provider:
def list_tabular_files(self, session, principal, *, query="", limit=100):
del session, principal, query, limit
return ()
def get_tabular_file(
self,
session,
principal,
*,
file_asset_id,
file_version_id=None,
):
del session, principal, file_asset_id, file_version_id
return None
def read_tabular_file(
self,
session,
principal,
*,
file_asset_id,
file_version_id,
max_bytes,
):
del session, principal, file_asset_id, file_version_id, max_bytes
file = ManagedTabularFile(
file_asset_id="asset-1",
file_version_id="version-1",
filename="source.csv",
display_path="Imports/source.csv",
content_type="text/csv",
size_bytes=8,
sha256="a" * 64,
updated_at=datetime(2026, 8, 21, tzinfo=UTC),
)
return ManagedTabularFileContent(file=file, payload=b"id\n1\n")
class _Registry:
def __init__(self, provider):
self.provider = provider
def has_capability(self, name):
return name == CAPABILITY_FILES_TABULAR_CONTENT
def require_capability(self, name):
if not self.has_capability(name):
raise KeyError(name)
return self.provider
class ManagedTabularFileContractTests(unittest.TestCase):
def test_runtime_protocol_and_registry_resolution(self) -> None:
provider = _Provider()
self.assertIsInstance(provider, ManagedTabularFileProvider)
self.assertIs(provider, managed_tabular_file_provider(_Registry(provider)))
self.assertIsNone(managed_tabular_file_provider(None))
def test_exact_version_content_retains_safe_metadata(self) -> None:
result = _Provider().read_tabular_file(
object(),
object(),
file_asset_id="asset-1",
file_version_id="version-1",
max_bytes=100,
)
self.assertEqual("version-1", result.file.file_version_id)
self.assertEqual("a" * 64, result.file.sha256)
self.assertEqual(b"id\n1\n", result.payload)
if __name__ == "__main__":
unittest.main()
+122
View File
@@ -0,0 +1,122 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import unittest
from govoplan_core.core.form_evidence import (
FormEvidenceContractError,
FormEvidenceGrantRequest,
FormEvidenceInspection,
FormEvidenceInspectionRequest,
form_evidence_capability,
form_evidence_provider,
)
from govoplan_core.core.institutional import EvidenceReference, InstitutionalReference
NOW = datetime(2026, 8, 7, 9, 0, tzinfo=UTC)
class Provider:
provider_id = "files"
def supported_kinds(self):
return ("document",)
def create_upload_grant(self, session, principal, *, request):
raise NotImplementedError
def inspect_evidence(self, session, principal, *, request):
return FormEvidenceInspection(
provider_id=self.provider_id,
reference=request.evidence,
state="accepted",
observed_at=NOW,
)
class Registry:
def __init__(self) -> None:
self.provider = Provider()
def has_capability(self, name: str) -> bool:
return name == "forms_runtime.evidence.files"
def require_capability(self, name: str):
return self.provider
class FormEvidenceContractTests(unittest.TestCase):
def setUp(self) -> None:
self.definition = InstitutionalReference(
kind="form",
owner_module="forms",
object_id="permit",
tenant_id="tenant-1",
version="2",
)
self.evidence = EvidenceReference(
kind="document",
owner_module="files",
evidence_id="asset-1",
tenant_id="tenant-1",
version="version-3",
checksum="a" * 64,
)
def test_grants_and_inspections_are_exact_and_tenant_bound(self) -> None:
grant = FormEvidenceGrantRequest(
tenant_id="tenant-1",
instance_id="form-1",
definition_ref=self.definition,
evidence_kind="document",
purpose="attach application evidence",
idempotency_key="grant-1",
expires_at=NOW + timedelta(minutes=10),
max_size_bytes=1024,
)
inspection = FormEvidenceInspectionRequest(
tenant_id="tenant-1",
instance_id="form-1",
definition_ref=self.definition,
evidence=self.evidence,
purpose="submit application",
final=True,
)
self.assertEqual("2", grant.definition_ref.version)
self.assertEqual("version-3", inspection.evidence.version)
def test_cross_tenant_evidence_is_rejected(self) -> None:
with self.assertRaisesRegex(FormEvidenceContractError, "cross tenants"):
FormEvidenceInspectionRequest(
tenant_id="tenant-2",
instance_id="form-1",
definition_ref=self.definition,
evidence=self.evidence,
purpose="submit",
final=True,
)
def test_provider_resolution_is_optional_and_runtime_checked(self) -> None:
registry = Registry()
self.assertEqual(
"forms_runtime.evidence.files",
form_evidence_capability("files"),
)
self.assertIs(registry.provider, form_evidence_provider(registry, "files"))
self.assertIsNone(form_evidence_provider(registry, "missing"))
def test_accepted_evidence_is_not_retryable(self) -> None:
with self.assertRaisesRegex(FormEvidenceContractError, "cannot require"):
FormEvidenceInspection(
provider_id="files",
reference=self.evidence,
state="accepted",
observed_at=NOW,
retryable=True,
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,61 @@
from __future__ import annotations
import unittest
from govoplan_core.core.access import (
AccessGovernanceProjectionV1,
GovernanceProjectionBatch,
GovernanceProjectionCommand,
GovernanceProjectionResult,
GovernanceTemplateMaterialization,
)
def _command(assignment_id: str) -> GovernanceProjectionCommand:
return GovernanceProjectionCommand(
assignment_id=assignment_id,
operation="upsert",
template=GovernanceTemplateMaterialization(
template_id="template-1",
kind="role",
tenant_id=f"tenant-{assignment_id}",
slug="reader",
name="Reader",
),
provenance={"source": "contract-test"},
)
class _Projection:
def reconcile(self, session: object, batch: GovernanceProjectionBatch) -> GovernanceProjectionResult:
del session
return GovernanceProjectionResult(
operation_id=batch.operation_id,
outcomes=(),
dry_run=batch.dry_run,
)
class GovernanceProjectionContractTests(unittest.TestCase):
def test_protocol_is_runtime_checkable(self) -> None:
self.assertIsInstance(_Projection(), AccessGovernanceProjectionV1)
def test_batch_rejects_duplicate_assignment_ids(self) -> None:
with self.assertRaisesRegex(ValueError, "unique"):
GovernanceProjectionBatch(
operation_id="duplicate",
commands=(_command("same"), _command("same")),
)
def test_batch_enforces_bounds(self) -> None:
with self.assertRaisesRegex(ValueError, "between 1 and 500"):
GovernanceProjectionBatch(operation_id="empty", commands=())
with self.assertRaisesRegex(ValueError, "between 1 and 500"):
GovernanceProjectionBatch(
operation_id="large",
commands=tuple(_command(str(index)) for index in range(501)),
)
if __name__ == "__main__":
unittest.main()

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