Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d9b677c1b | ||
|
|
54178ee56c | ||
|
|
10e7597612 | ||
|
|
142ccbc587 | ||
|
|
f75ad48d78 | ||
|
|
5a9e8f79f9 | ||
|
|
fbea74a74b | ||
|
|
925dc33696 | ||
|
|
4b0737e1cd | ||
|
|
4f4007aff1 | ||
|
|
8e687c4420 | ||
|
|
604f20eed7 | ||
|
|
6a2da94e47 | ||
|
|
e121ca900e | ||
|
|
79629c5a2c | ||
|
|
026e451aa4 | ||
|
|
f11c675d11 | ||
|
|
0fae09ba3c | ||
|
|
8f642bd618 | ||
|
|
6643c8fc1e | ||
|
|
fd90b60430 | ||
|
|
0aae6f0539 | ||
|
|
be7b79612c | ||
|
|
557c77670b | ||
|
|
d277218784 | ||
|
|
cf16a7b27a | ||
|
|
51bf14f376 | ||
|
|
8d9bcfd8b5 | ||
|
|
3c7a593f63 | ||
|
|
9d1352ba30 | ||
|
|
4cf2bfeb3e | ||
|
|
94c94fefb4 | ||
|
|
d600bca374 | ||
|
|
ffaab543d2 | ||
|
|
41db78c201 | ||
|
|
c042244da8 | ||
|
|
5a2e99f496 | ||
|
|
8a925782ab | ||
|
|
7685a103e8 | ||
|
|
ee5c881df9 | ||
|
|
6814a41ae4 | ||
|
|
dd7ad4d9c7 | ||
|
|
934db6d44b | ||
|
|
d307e29145 | ||
|
|
ff88142471 | ||
|
|
887e9beb9e | ||
|
|
e6457b3f6b | ||
|
|
eb0c01c5d2 | ||
|
|
40cc012124 | ||
|
|
44196f5620 | ||
|
|
9ceb1b8c22 | ||
|
|
32c234fbdb | ||
|
|
d65d7a8e5f | ||
|
|
b5f5be15f6 | ||
|
|
f5949427cc | ||
|
|
5d1287735e | ||
|
|
7ea0cb8655 | ||
|
|
b553513c9f | ||
|
|
b5a4eb177a |
@@ -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"
|
||||
/ "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,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")
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -46,10 +46,18 @@ 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -17,9 +17,12 @@ 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. |
|
||||
@@ -41,9 +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. |
|
||||
| Stable platform ideas | `govoplan/docs/PLATFORM_CORE_IDEAS.md` | Cross-product thesis, canonical distinctions, product experience rule, maturity rule, and decision test. |
|
||||
| Current cross-product reconciliation | `govoplan/docs/STRATEGY_STATUS.md` | The only current prose status source; generated evidence and Gitea remain authoritative inputs. |
|
||||
| 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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -13,6 +13,19 @@ 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 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 1–4 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.
|
||||
|
||||
@@ -32,6 +32,28 @@ 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:
|
||||
|
||||
@@ -41,6 +63,14 @@ not a list of controls on which F1 cannot work. It should prioritize:
|
||||
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
|
||||
@@ -58,3 +88,10 @@ The check must report:
|
||||
- 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
|
||||
```
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
@@ -140,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
|
||||
|
||||
|
||||
+34
-6
@@ -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.
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ contestability, responsibility, and traceability at the point of action.
|
||||
| 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
|
||||
|
||||
@@ -163,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.
|
||||
|
||||
@@ -321,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
|
||||
|
||||
@@ -337,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
|
||||
|
||||
|
||||
@@ -1467,6 +1467,810 @@
|
||||
"release": "0.1.16",
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
},
|
||||
{
|
||||
"heads": [
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revision": "6e2f91ab4c70"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revision": "6e7f8a9b0c1d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dashboard",
|
||||
"revision": "7b9d2f4a6c8e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-voting",
|
||||
"revision": "8b9c0d1e2f3a"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revision": "93b4c5d6e7f8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revision": "a36d8e4f9b12"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-forms-runtime",
|
||||
"revision": "a3d5f7b9c1e2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-templates",
|
||||
"revision": "a3f7c9d2e1b4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-organizations",
|
||||
"revision": "a61e4d9c72b8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mandates",
|
||||
"revision": "a8b1c2d3e4f5"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-audit",
|
||||
"revision": "a8d1e4f7b2c5"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-approvals",
|
||||
"revision": "a91c4e72b5d8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-policy",
|
||||
"revision": "a9c4e7b2d5f8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revision": "b1c2d3e4f5a6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-search",
|
||||
"revision": "b2c3d4e5f607"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-datasources",
|
||||
"revision": "b8d2f5a0c3e7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-views",
|
||||
"revision": "b8e4c1f7a2d9"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-risk-compliance",
|
||||
"revision": "b9c0d1e2f3a4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-services",
|
||||
"revision": "b9c2d3e4f5a6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-parties",
|
||||
"revision": "c0d3e4f5a6b7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity-trust",
|
||||
"revision": "c3f5a7b9d1e2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-projects",
|
||||
"revision": "c4a1e8f2d6b9"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revision": "c5d7e8f9a0b1"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revision": "c7e0a3d6f9b2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-reporting",
|
||||
"revision": "c8d5e2f6a9b3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revision": "c9d4e7f1a2b3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-decisions",
|
||||
"revision": "d1e4f5a6b7c8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revision": "d24e5f607182"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-committee",
|
||||
"revision": "d8b9f0a1c2e3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-postbox",
|
||||
"revision": "d8e3f6a9b2c5"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revision": "e3c8f4a5b6d7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-workflow-engine",
|
||||
"revision": "e4a1f8c2d7b6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-encryption",
|
||||
"revision": "e5b7c9d1f3a4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dist-lists",
|
||||
"revision": "e7c3a9d1b5f2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revision": "f1a2b3c4d5e7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dataflow",
|
||||
"revision": "f6c2a9d4e7b1"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-cases",
|
||||
"revision": "f6d3a8b1c4e7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-connectors",
|
||||
"revision": "f7c8d9e0a1b2"
|
||||
}
|
||||
],
|
||||
"owner_heads": [
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revisions": [
|
||||
"c7e0a3d6f9b2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revisions": [
|
||||
"c5d7e8f9a0b1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-approvals",
|
||||
"revisions": [
|
||||
"a91c4e72b5d8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-audit",
|
||||
"revisions": [
|
||||
"a8d1e4f7b2c5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revisions": [
|
||||
"d24e5f607182"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revisions": [
|
||||
"e3c8f4a5b6d7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-cases",
|
||||
"revisions": [
|
||||
"f6d3a8b1c4e7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-committee",
|
||||
"revisions": [
|
||||
"d8b9f0a1c2e3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-connectors",
|
||||
"revisions": [
|
||||
"f7c8d9e0a1b2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revisions": [
|
||||
"a36d8e4f9b12"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dashboard",
|
||||
"revisions": [
|
||||
"7b9d2f4a6c8e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dataflow",
|
||||
"revisions": [
|
||||
"f6c2a9d4e7b1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-datasources",
|
||||
"revisions": [
|
||||
"b8d2f5a0c3e7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-decisions",
|
||||
"revisions": [
|
||||
"d1e4f5a6b7c8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dist-lists",
|
||||
"revisions": [
|
||||
"e7c3a9d1b5f2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-encryption",
|
||||
"revisions": [
|
||||
"e5b7c9d1f3a4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revisions": [
|
||||
"f1a2b3c4d5e7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-forms",
|
||||
"revisions": [
|
||||
"e1f2a3b4c5d6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-forms-runtime",
|
||||
"revisions": [
|
||||
"a3d5f7b9c1e2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity",
|
||||
"revisions": [
|
||||
"5c6d7e8f9a10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity-trust",
|
||||
"revisions": [
|
||||
"c3f5a7b9d1e2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revisions": [
|
||||
"b1c2d3e4f5a6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revisions": [
|
||||
"93b4c5d6e7f8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mandates",
|
||||
"revisions": [
|
||||
"a8b1c2d3e4f5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revisions": [
|
||||
"6e2f91ab4c70"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-organizations",
|
||||
"revisions": [
|
||||
"a61e4d9c72b8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-parties",
|
||||
"revisions": [
|
||||
"c0d3e4f5a6b7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-policy",
|
||||
"revisions": [
|
||||
"a9c4e7b2d5f8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revisions": [
|
||||
"6e7f8a9b0c1d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-postbox",
|
||||
"revisions": [
|
||||
"d8e3f6a9b2c5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-projects",
|
||||
"revisions": [
|
||||
"c4a1e8f2d6b9"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-reporting",
|
||||
"revisions": [
|
||||
"c8d5e2f6a9b3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-risk-compliance",
|
||||
"revisions": [
|
||||
"b9c0d1e2f3a4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revisions": [
|
||||
"c9d4e7f1a2b3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-search",
|
||||
"revisions": [
|
||||
"b2c3d4e5f607"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-services",
|
||||
"revisions": [
|
||||
"b9c2d3e4f5a6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-templates",
|
||||
"revisions": [
|
||||
"a3f7c9d2e1b4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-views",
|
||||
"revisions": [
|
||||
"b8e4c1f7a2d9"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-voting",
|
||||
"revisions": [
|
||||
"8b9c0d1e2f3a"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-workflow-engine",
|
||||
"revisions": [
|
||||
"e4a1f8c2d7b6"
|
||||
]
|
||||
}
|
||||
],
|
||||
"recorded_at": "2026-08-05T18:33:33Z",
|
||||
"release": "0.1.17",
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
},
|
||||
{
|
||||
"heads": [
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revision": "6e2f91ab4c70"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revision": "6e7f8a9b0c1d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dashboard",
|
||||
"revision": "7b9d2f4a6c8e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-voting",
|
||||
"revision": "8b9c0d1e2f3a"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revision": "93b4c5d6e7f8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revision": "a36d8e4f9b12"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-forms-runtime",
|
||||
"revision": "a3d5f7b9c1e2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-templates",
|
||||
"revision": "a3f7c9d2e1b4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-organizations",
|
||||
"revision": "a61e4d9c72b8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mandates",
|
||||
"revision": "a8b1c2d3e4f5"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-audit",
|
||||
"revision": "a8d1e4f7b2c5"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-approvals",
|
||||
"revision": "a91c4e72b5d8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-policy",
|
||||
"revision": "a9c4e7b2d5f8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revision": "b1c2d3e4f5a6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-search",
|
||||
"revision": "b2c3d4e5f607"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-datasources",
|
||||
"revision": "b8d2f5a0c3e7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-views",
|
||||
"revision": "b8e4c1f7a2d9"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-risk-compliance",
|
||||
"revision": "b9c0d1e2f3a4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-services",
|
||||
"revision": "b9c2d3e4f5a6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-parties",
|
||||
"revision": "c0d3e4f5a6b7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity-trust",
|
||||
"revision": "c3f5a7b9d1e2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-projects",
|
||||
"revision": "c4a1e8f2d6b9"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revision": "c5d7e8f9a0b1"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revision": "c7e0a3d6f9b2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-reporting",
|
||||
"revision": "c8d5e2f6a9b3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revision": "c9d4e7f1a2b3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-decisions",
|
||||
"revision": "d1e4f5a6b7c8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revision": "d24e5f607182"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-committee",
|
||||
"revision": "d8b9f0a1c2e3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-postbox",
|
||||
"revision": "d8e3f6a9b2c5"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revision": "e3c8f4a5b6d7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-workflow-engine",
|
||||
"revision": "e4a1f8c2d7b6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-encryption",
|
||||
"revision": "e5b7c9d1f3a4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dist-lists",
|
||||
"revision": "e7c3a9d1b5f2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revision": "f1a2b3c4d5e7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dataflow",
|
||||
"revision": "f6c2a9d4e7b1"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-cases",
|
||||
"revision": "f6d3a8b1c4e7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-connectors",
|
||||
"revision": "f7c8d9e0a1b2"
|
||||
}
|
||||
],
|
||||
"owner_heads": [
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revisions": [
|
||||
"c7e0a3d6f9b2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revisions": [
|
||||
"c5d7e8f9a0b1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-approvals",
|
||||
"revisions": [
|
||||
"a91c4e72b5d8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-audit",
|
||||
"revisions": [
|
||||
"a8d1e4f7b2c5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revisions": [
|
||||
"d24e5f607182"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revisions": [
|
||||
"e3c8f4a5b6d7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-cases",
|
||||
"revisions": [
|
||||
"f6d3a8b1c4e7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-committee",
|
||||
"revisions": [
|
||||
"d8b9f0a1c2e3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-connectors",
|
||||
"revisions": [
|
||||
"f7c8d9e0a1b2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revisions": [
|
||||
"a36d8e4f9b12"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dashboard",
|
||||
"revisions": [
|
||||
"7b9d2f4a6c8e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dataflow",
|
||||
"revisions": [
|
||||
"f6c2a9d4e7b1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-datasources",
|
||||
"revisions": [
|
||||
"b8d2f5a0c3e7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-decisions",
|
||||
"revisions": [
|
||||
"d1e4f5a6b7c8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dist-lists",
|
||||
"revisions": [
|
||||
"e7c3a9d1b5f2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-encryption",
|
||||
"revisions": [
|
||||
"e5b7c9d1f3a4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revisions": [
|
||||
"f1a2b3c4d5e7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-forms",
|
||||
"revisions": [
|
||||
"e1f2a3b4c5d6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-forms-runtime",
|
||||
"revisions": [
|
||||
"a3d5f7b9c1e2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity",
|
||||
"revisions": [
|
||||
"5c6d7e8f9a10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity-trust",
|
||||
"revisions": [
|
||||
"c3f5a7b9d1e2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revisions": [
|
||||
"b1c2d3e4f5a6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revisions": [
|
||||
"93b4c5d6e7f8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mandates",
|
||||
"revisions": [
|
||||
"a8b1c2d3e4f5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revisions": [
|
||||
"6e2f91ab4c70"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-organizations",
|
||||
"revisions": [
|
||||
"a61e4d9c72b8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-parties",
|
||||
"revisions": [
|
||||
"c0d3e4f5a6b7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-policy",
|
||||
"revisions": [
|
||||
"a9c4e7b2d5f8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revisions": [
|
||||
"6e7f8a9b0c1d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-postbox",
|
||||
"revisions": [
|
||||
"d8e3f6a9b2c5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-projects",
|
||||
"revisions": [
|
||||
"c4a1e8f2d6b9"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-reporting",
|
||||
"revisions": [
|
||||
"c8d5e2f6a9b3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-risk-compliance",
|
||||
"revisions": [
|
||||
"b9c0d1e2f3a4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revisions": [
|
||||
"c9d4e7f1a2b3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-search",
|
||||
"revisions": [
|
||||
"b2c3d4e5f607"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-services",
|
||||
"revisions": [
|
||||
"b9c2d3e4f5a6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-templates",
|
||||
"revisions": [
|
||||
"a3f7c9d2e1b4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-views",
|
||||
"revisions": [
|
||||
"b8e4c1f7a2d9"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-voting",
|
||||
"revisions": [
|
||||
"8b9c0d1e2f3a"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-workflow-engine",
|
||||
"revisions": [
|
||||
"e4a1f8c2d7b6"
|
||||
]
|
||||
}
|
||||
],
|
||||
"recorded_at": "2026-08-05T19:07:19Z",
|
||||
"release": "0.1.18",
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
}
|
||||
],
|
||||
"version": 1
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-core"
|
||||
version = "0.1.16"
|
||||
version = "0.1.22"
|
||||
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"]
|
||||
|
||||
@@ -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):
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -29,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",
|
||||
@@ -36,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)
|
||||
@@ -101,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,
|
||||
*,
|
||||
@@ -323,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())
|
||||
@@ -632,7 +655,27 @@ 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:
|
||||
@@ -736,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"
|
||||
@@ -806,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
|
||||
@@ -969,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
|
||||
|
||||
@@ -15,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"]
|
||||
@@ -34,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"]
|
||||
|
||||
|
||||
@@ -75,8 +82,6 @@ class NavItem:
|
||||
surface_id: str | None = None
|
||||
|
||||
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FrontendRoute:
|
||||
path: str
|
||||
@@ -96,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
|
||||
@@ -110,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)
|
||||
@@ -147,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)
|
||||
@@ -204,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)
|
||||
@@ -224,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",
|
||||
@@ -289,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 ()
|
||||
@@ -370,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]]
|
||||
@@ -436,16 +490,25 @@ 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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
+577
-121
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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"]
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -197,7 +342,10 @@ def _documentation_help_contexts(manifest: ModuleManifest) -> list[dict[str, obj
|
||||
return contexts
|
||||
|
||||
|
||||
def _frontend_payload(manifest: ModuleManifest) -> dict[str, object] | None:
|
||||
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
|
||||
@@ -213,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
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -272,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 {
|
||||
@@ -300,8 +459,11 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
||||
if key != "declarations"
|
||||
},
|
||||
"help_contexts": _documentation_help_contexts(manifest),
|
||||
"nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items],
|
||||
"frontend": _frontend_payload(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
|
||||
],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_core.core.infrastructure_capabilities import (
|
||||
InfrastructureCapabilityReceiptError,
|
||||
deployment_capability_status,
|
||||
infrastructure_capability_receipt_from_mapping,
|
||||
load_infrastructure_capability_receipt,
|
||||
)
|
||||
|
||||
|
||||
def _receipt_payload() -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"installation_id": "govoplan-test",
|
||||
"profile": "evaluation",
|
||||
"capabilities": [
|
||||
{
|
||||
"id": "mail.smtp",
|
||||
"label": "SMTP delivery",
|
||||
"state": "available_unconfigured",
|
||||
"source": "installer-managed-test",
|
||||
"detail": "Mail owns the profile binding.",
|
||||
"endpoint": {"scheme": "smtp", "host": "test-mail", "port": 3025},
|
||||
"secret_refs": [],
|
||||
"dependent_modules": ["mail"],
|
||||
}
|
||||
],
|
||||
"post_install_tasks": [
|
||||
{
|
||||
"id": "mail.smtp-profile",
|
||||
"resume_key": "govoplan-test:mail.smtp-profile:v1",
|
||||
"capability_id": "mail.smtp",
|
||||
"state": "pending",
|
||||
"owner_module": "mail",
|
||||
"summary": "Create a Mail SMTP profile.",
|
||||
"required_inputs": ["credential envelope reference when required"],
|
||||
"secret_boundary": "credential-envelope-reference-only",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class InfrastructureCapabilityReceiptTests(unittest.TestCase):
|
||||
def test_parses_typed_capability_and_task_lookup(self) -> None:
|
||||
receipt = infrastructure_capability_receipt_from_mapping(_receipt_payload())
|
||||
|
||||
capability = receipt.capability("mail.smtp")
|
||||
self.assertIsNotNone(capability)
|
||||
assert capability is not None
|
||||
self.assertEqual("test-mail", capability.endpoint["host"])
|
||||
self.assertEqual(
|
||||
"govoplan-test:mail.smtp-profile:v1",
|
||||
receipt.tasks_for(capability_id="mail.smtp", owner_module="mail")[0].resume_key,
|
||||
)
|
||||
self.assertEqual(_receipt_payload(), receipt.to_dict())
|
||||
|
||||
def test_loader_uses_configured_path_and_status_remains_non_secret(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-capability-receipt-") as root:
|
||||
path = Path(root) / "capabilities.json"
|
||||
path.write_text(json.dumps(_receipt_payload()), encoding="utf-8")
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH": str(path)},
|
||||
clear=False,
|
||||
):
|
||||
receipt = load_infrastructure_capability_receipt()
|
||||
status = deployment_capability_status()
|
||||
|
||||
self.assertIsNotNone(receipt)
|
||||
self.assertTrue(status["available"])
|
||||
self.assertNotIn("password", json.dumps(status).casefold())
|
||||
|
||||
def test_rejects_inline_secret_endpoint_and_non_environment_secret_reference(self) -> None:
|
||||
for endpoint, secret_refs in (
|
||||
({"password": "inline"}, []),
|
||||
({}, ["plaintext-secret"]),
|
||||
):
|
||||
with self.subTest(endpoint=endpoint, secret_refs=secret_refs):
|
||||
payload = _receipt_payload()
|
||||
capability = dict(payload["capabilities"][0]) # type: ignore[index]
|
||||
capability["endpoint"] = endpoint
|
||||
capability["secret_refs"] = secret_refs
|
||||
payload["capabilities"] = [capability]
|
||||
|
||||
with self.assertRaises(InfrastructureCapabilityReceiptError):
|
||||
infrastructure_capability_receipt_from_mapping(payload)
|
||||
|
||||
def test_rejects_post_install_task_for_unknown_capability(self) -> None:
|
||||
payload = _receipt_payload()
|
||||
task = dict(payload["post_install_tasks"][0]) # type: ignore[index]
|
||||
task["capability_id"] = "unknown.capability"
|
||||
payload["post_install_tasks"] = [task]
|
||||
|
||||
with self.assertRaises(InfrastructureCapabilityReceiptError):
|
||||
infrastructure_capability_receipt_from_mapping(payload)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -76,6 +76,26 @@ class InstallConfigTests(unittest.TestCase):
|
||||
with self.subTest(invalid=invalid), self.assertRaises(ValidationError):
|
||||
Settings(SCHEDULING_CANCELLATION_NOTICE_DAYS=invalid)
|
||||
|
||||
def test_scheduling_public_self_enrollment_policy_is_configurable(self) -> None:
|
||||
defaults = Settings()
|
||||
self.assertTrue(defaults.scheduling_public_self_enrollment_enabled)
|
||||
self.assertEqual(
|
||||
defaults.scheduling_public_self_enrollment_max_capacity,
|
||||
10_000,
|
||||
)
|
||||
configured = Settings(
|
||||
SCHEDULING_PUBLIC_SELF_ENROLLMENT_ENABLED="false",
|
||||
SCHEDULING_PUBLIC_SELF_ENROLLMENT_MAX_CAPACITY="250",
|
||||
)
|
||||
self.assertFalse(configured.scheduling_public_self_enrollment_enabled)
|
||||
self.assertEqual(
|
||||
configured.scheduling_public_self_enrollment_max_capacity,
|
||||
250,
|
||||
)
|
||||
for invalid in ("0", "10001"):
|
||||
with self.subTest(invalid=invalid), self.assertRaises(ValidationError):
|
||||
Settings(SCHEDULING_PUBLIC_SELF_ENROLLMENT_MAX_CAPACITY=invalid)
|
||||
|
||||
def test_self_hosted_validation_reports_actionable_missing_settings(self) -> None:
|
||||
result = validate_runtime_configuration({}, profile="self-hosted")
|
||||
|
||||
@@ -246,6 +266,8 @@ class InstallConfigTests(unittest.TestCase):
|
||||
self.assertIn("MASTER_KEY_B64=<generate", self_hosted)
|
||||
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", self_hosted)
|
||||
self.assertIn("SCHEDULING_CANCELLATION_NOTICE_DAYS=30", self_hosted)
|
||||
self.assertIn("SCHEDULING_PUBLIC_SELF_ENROLLMENT_ENABLED=true", self_hosted)
|
||||
self.assertIn("SCHEDULING_PUBLIC_SELF_ENROLLMENT_MAX_CAPACITY=10000", self_hosted)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false", self_hosted)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", self_hosted)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", self_hosted)
|
||||
@@ -258,6 +280,8 @@ class InstallConfigTests(unittest.TestCase):
|
||||
self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
|
||||
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", production_like)
|
||||
self.assertIn("SCHEDULING_CANCELLATION_NOTICE_DAYS=30", production_like)
|
||||
self.assertIn("SCHEDULING_PUBLIC_SELF_ENROLLMENT_ENABLED=true", production_like)
|
||||
self.assertIn("SCHEDULING_PUBLIC_SELF_ENROLLMENT_MAX_CAPACITY=10000", production_like)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true", production_like)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", production_like)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", production_like)
|
||||
|
||||
@@ -2,10 +2,39 @@ from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.mail.config import normalize_split_transport_credentials
|
||||
from govoplan_core.mail.config import ImapServerConfig, normalize_split_transport_credentials
|
||||
|
||||
|
||||
class MailConfigTests(unittest.TestCase):
|
||||
def test_legacy_sent_folder_populates_standard_mapping(self) -> None:
|
||||
config = ImapServerConfig.model_validate(
|
||||
{"host": "imap.example.test", "sent_folder": " Sent Items "}
|
||||
)
|
||||
|
||||
self.assertEqual("Sent Items", config.sent_folder)
|
||||
self.assertIsNotNone(config.folder_mappings)
|
||||
assert config.folder_mappings is not None
|
||||
self.assertEqual("Sent Items", config.folder_mappings.sent)
|
||||
|
||||
def test_standard_sent_mapping_remains_legacy_append_default(self) -> None:
|
||||
config = ImapServerConfig.model_validate(
|
||||
{
|
||||
"host": "imap.example.test",
|
||||
"sent_folder": "Old Sent",
|
||||
"folder_mappings": {
|
||||
"inbox": " INBOX ",
|
||||
"sent": "Sent Items",
|
||||
"junk": " ",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual("Sent Items", config.sent_folder)
|
||||
assert config.folder_mappings is not None
|
||||
self.assertEqual("INBOX", config.folder_mappings.inbox)
|
||||
self.assertEqual("Sent Items", config.folder_mappings.sent)
|
||||
self.assertIsNone(config.folder_mappings.junk)
|
||||
|
||||
def test_normalize_split_transport_credentials_moves_legacy_auth_fields(self) -> None:
|
||||
payload = normalize_split_transport_credentials(
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ from fastapi import APIRouter, Depends, FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.celery_app import _run_tenant_worker_batches
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.lifecycle import require_module_active
|
||||
@@ -26,6 +27,7 @@ from govoplan_core.core.module_entitlements import (
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.db.session import configure_database, get_database
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.server.platform import create_platform_router
|
||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
|
||||
|
||||
@@ -257,6 +259,10 @@ class TenantModuleEntitlementRouteTests(unittest.TestCase):
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-entitlement-test-"))
|
||||
configure_database(f"sqlite:///{root / 'test.db'}")
|
||||
create_scope_tables(get_database().engine)
|
||||
Base.metadata.create_all(
|
||||
bind=get_database().engine,
|
||||
tables=[SystemSettings.__table__],
|
||||
)
|
||||
self.manifests = (
|
||||
ModuleManifest(id="access", name="Access", version="test"),
|
||||
ModuleManifest(
|
||||
|
||||
+439
-72
@@ -148,6 +148,49 @@ def configure_database(database_url: str):
|
||||
return configure_database_handle(database_url, dispose_previous=True)
|
||||
|
||||
|
||||
def _bound_catalog_plan(
|
||||
plan: ModuleInstallPlan,
|
||||
entries: list[dict[str, object]],
|
||||
) -> tuple[ModuleInstallPlan, dict[str, object]]:
|
||||
"""Build the signed catalog snapshot expected by catalog-plan preflight."""
|
||||
|
||||
source = "https://catalog.example.test/stable.json"
|
||||
snapshot = {
|
||||
"source": source,
|
||||
"channel": "stable",
|
||||
"sequence": 42,
|
||||
"signed": True,
|
||||
"trusted": True,
|
||||
"key_id": "release-key-1",
|
||||
}
|
||||
items = {item.module_id: item for item in plan.items}
|
||||
bound_entries: list[dict[str, object]] = []
|
||||
for raw in entries:
|
||||
entry = dict(raw)
|
||||
item = items[str(entry["module_id"])]
|
||||
entry.setdefault("action", "install")
|
||||
for attribute in ("python_package", "python_ref", "webui_package", "webui_ref"):
|
||||
value = getattr(item, attribute)
|
||||
if value is not None:
|
||||
entry.setdefault(attribute, value)
|
||||
if item.artifact_integrity is not None:
|
||||
entry.setdefault("artifact_integrity", item.artifact_integrity)
|
||||
bound_entries.append(entry)
|
||||
return (
|
||||
replace(
|
||||
plan,
|
||||
items=tuple(replace(item, catalog=snapshot) for item in plan.items),
|
||||
),
|
||||
{
|
||||
"configured": True,
|
||||
"valid": True,
|
||||
"warnings": [],
|
||||
**snapshot,
|
||||
"modules": bound_entries,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _join_route_path(prefix: str, path: str) -> str:
|
||||
if not prefix:
|
||||
return path
|
||||
@@ -299,7 +342,25 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
self.assertTrue(scopes_grant_compatible(["admin:users:read"], "access:membership:read"))
|
||||
self.assertTrue(scopes_grant_compatible(["access:tenant:read"], "system:tenants:read"))
|
||||
self.assertTrue(scopes_grant_compatible(["system:*"], "access:tenant:read"))
|
||||
self.assertTrue(
|
||||
scopes_grant_compatible(
|
||||
["system:*"],
|
||||
"audit:system_evidence:export",
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
scopes_grant_compatible(
|
||||
["system:audit:evidence:export"],
|
||||
"audit:system_evidence:export",
|
||||
)
|
||||
)
|
||||
self.assertFalse(scopes_grant_compatible(["tenant:*"], "system:tenants:read"))
|
||||
self.assertFalse(
|
||||
scopes_grant_compatible(
|
||||
["tenant:*"],
|
||||
"audit:system_evidence:export",
|
||||
)
|
||||
)
|
||||
self.assertFalse(scopes_grant_compatible(["tenant:*"], "access:tenant:read"))
|
||||
|
||||
def test_core_webui_retired_legacy_admin_api_surface(self) -> None:
|
||||
@@ -1813,22 +1874,18 @@ finally:
|
||||
data_safety_acknowledged=True,
|
||||
),
|
||||
))
|
||||
plan, validation = _bound_catalog_plan(plan, [{
|
||||
"module_id": "files",
|
||||
"version": "2.0.0",
|
||||
"migration_safety": "forward_only",
|
||||
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
|
||||
"recovery_tested": True,
|
||||
"recovery_notes": "Snapshot restore was rehearsed on the staging dataset.",
|
||||
}])
|
||||
|
||||
with patch(
|
||||
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
||||
return_value={
|
||||
"configured": True,
|
||||
"valid": True,
|
||||
"warnings": [],
|
||||
"modules": [{
|
||||
"module_id": "files",
|
||||
"version": "2.0.0",
|
||||
"migration_safety": "forward_only",
|
||||
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
|
||||
"recovery_tested": True,
|
||||
"recovery_notes": "Snapshot restore was rehearsed on the staging dataset.",
|
||||
}],
|
||||
},
|
||||
return_value=validation,
|
||||
):
|
||||
preflight = module_install_preflight(
|
||||
plan=plan,
|
||||
@@ -1918,18 +1975,14 @@ finally:
|
||||
python_ref="govoplan-mail==1.0.0",
|
||||
),
|
||||
))
|
||||
plan, validation = _bound_catalog_plan(plan, [
|
||||
{"module_id": "files", "version": "0.9.0", "allow_downgrade": True},
|
||||
{"module_id": "mail", "version": "1.0.0", "allow_same_version": True},
|
||||
])
|
||||
|
||||
with patch(
|
||||
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
||||
return_value={
|
||||
"configured": True,
|
||||
"valid": True,
|
||||
"warnings": [],
|
||||
"modules": [
|
||||
{"module_id": "files", "version": "0.9.0", "allow_downgrade": True},
|
||||
{"module_id": "mail", "version": "1.0.0", "allow_same_version": True},
|
||||
],
|
||||
},
|
||||
return_value=validation,
|
||||
):
|
||||
preflight = module_install_preflight(
|
||||
plan=plan,
|
||||
@@ -1996,20 +2049,16 @@ finally:
|
||||
python_ref="govoplan-files==2.0.0",
|
||||
),
|
||||
))
|
||||
plan, validation = _bound_catalog_plan(plan, [{
|
||||
"module_id": "files",
|
||||
"version": "2.0.0",
|
||||
"bridge_release": True,
|
||||
"bridge_notes": "Keeps both 1.x and 2.x attachment interfaces available.",
|
||||
}])
|
||||
|
||||
with patch(
|
||||
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
||||
return_value={
|
||||
"configured": True,
|
||||
"valid": True,
|
||||
"warnings": [],
|
||||
"modules": [{
|
||||
"module_id": "files",
|
||||
"version": "2.0.0",
|
||||
"bridge_release": True,
|
||||
"bridge_notes": "Keeps both 1.x and 2.x attachment interfaces available.",
|
||||
}],
|
||||
},
|
||||
return_value=validation,
|
||||
):
|
||||
preflight = module_install_preflight(
|
||||
plan=plan,
|
||||
@@ -2073,21 +2122,17 @@ finally:
|
||||
data_safety_acknowledged=True,
|
||||
),
|
||||
))
|
||||
plan, validation = _bound_catalog_plan(plan, [{
|
||||
"module_id": "files",
|
||||
"migration_safety": "destructive",
|
||||
"migration_notes": "Drops obsolete cache tables after exporting the retained documents.",
|
||||
"recovery_tested": True,
|
||||
"recovery_notes": "Restore and forward-recovery path verified on staging.",
|
||||
}])
|
||||
|
||||
with patch(
|
||||
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
||||
return_value={
|
||||
"configured": True,
|
||||
"valid": True,
|
||||
"warnings": [],
|
||||
"modules": [{
|
||||
"module_id": "files",
|
||||
"migration_safety": "destructive",
|
||||
"migration_notes": "Drops obsolete cache tables after exporting the retained documents.",
|
||||
"recovery_tested": True,
|
||||
"recovery_notes": "Restore and forward-recovery path verified on staging.",
|
||||
}],
|
||||
},
|
||||
return_value=validation,
|
||||
):
|
||||
preflight = module_install_preflight(
|
||||
plan=plan,
|
||||
@@ -2127,28 +2172,24 @@ finally:
|
||||
python_ref="govoplan-campaign==2.0.0",
|
||||
),
|
||||
))
|
||||
plan, validation = _bound_catalog_plan(plan, [
|
||||
{
|
||||
"module_id": "files",
|
||||
"provides_interfaces": [{"name": "files.attachments", "version": "2.0.0"}],
|
||||
},
|
||||
{
|
||||
"module_id": "campaigns",
|
||||
"requires_interfaces": [{
|
||||
"name": "files.attachments",
|
||||
"version_min": "2.0.0",
|
||||
"version_max_exclusive": "3.0.0",
|
||||
}],
|
||||
},
|
||||
])
|
||||
|
||||
with patch(
|
||||
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
||||
return_value={
|
||||
"configured": True,
|
||||
"valid": True,
|
||||
"warnings": [],
|
||||
"modules": [
|
||||
{
|
||||
"module_id": "files",
|
||||
"provides_interfaces": [{"name": "files.attachments", "version": "2.0.0"}],
|
||||
},
|
||||
{
|
||||
"module_id": "campaigns",
|
||||
"requires_interfaces": [{
|
||||
"name": "files.attachments",
|
||||
"version_min": "2.0.0",
|
||||
"version_max_exclusive": "3.0.0",
|
||||
}],
|
||||
},
|
||||
],
|
||||
},
|
||||
return_value=validation,
|
||||
):
|
||||
preflight = module_install_preflight(
|
||||
plan=plan,
|
||||
@@ -2914,6 +2955,144 @@ finally:
|
||||
self.assertFalse(preflight.allowed)
|
||||
self.assertIn("artifact_integrity_required", {issue.code for issue in preflight.issues})
|
||||
|
||||
def test_trusted_catalog_artifact_is_acquired_and_installed_from_verified_cache(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-artifact-download-", dir=_TEST_ROOT))
|
||||
encoded = b"verified wheel artifact"
|
||||
digest = hashlib.sha256(encoded).hexdigest()
|
||||
plan = ModuleInstallPlan(items=(ModuleInstallPlanItem(
|
||||
module_id="files",
|
||||
action="install",
|
||||
source="catalog",
|
||||
catalog={"signed": True, "trusted": True, "channel": "stable"},
|
||||
python_package="govoplan-files",
|
||||
python_ref=f"govoplan-files @ https://packages.example.test/govoplan_files.whl#sha256={digest}",
|
||||
artifact_integrity={
|
||||
"python": {
|
||||
"ref": f"govoplan-files @ https://packages.example.test/govoplan_files.whl#sha256={digest}",
|
||||
"url": "https://packages.example.test/govoplan_files.whl",
|
||||
"filename": "govoplan_files-0.1.4-py3-none-any.whl",
|
||||
"sha256": digest,
|
||||
"size": len(encoded),
|
||||
},
|
||||
},
|
||||
),))
|
||||
|
||||
with patch.dict(os.environ, {"GOVOPLAN_MODULE_INSTALLER_REQUIRE_ARTIFACT_INTEGRITY": "true"}), patch(
|
||||
"govoplan_core.core.module_installer._package_catalog_preflight_issues",
|
||||
return_value=(),
|
||||
):
|
||||
preflight = module_install_preflight(
|
||||
plan=plan,
|
||||
available=available_module_manifests(),
|
||||
current_enabled=("tenancy", "access"),
|
||||
desired_enabled=("tenancy", "access"),
|
||||
maintenance_mode=True,
|
||||
)
|
||||
self.assertTrue(preflight.allowed, [issue.as_dict() for issue in preflight.issues])
|
||||
self.assertIn("artifact_acquisition_pending", {issue.code for issue in preflight.issues})
|
||||
|
||||
with patch(
|
||||
"govoplan_core.core.module_installer.fetch_http",
|
||||
return_value=SimpleNamespace(status=200, body=encoded),
|
||||
):
|
||||
acquired = module_installer_module.acquire_catalog_package_artifacts(
|
||||
plan,
|
||||
runtime_dir=root / "installer",
|
||||
)
|
||||
|
||||
artifact_path = Path(acquired.items[0].artifact_integrity["python"]["artifact_path"])
|
||||
self.assertEqual(encoded, artifact_path.read_bytes())
|
||||
self.assertEqual(0o600, stat.S_IMODE(artifact_path.stat().st_mode))
|
||||
commands = structured_install_commands(acquired, webui_root=None)
|
||||
self.assertEqual(str(artifact_path), commands[0]["argv"][-1])
|
||||
|
||||
def test_installer_revalidates_the_bundled_official_catalog_when_no_override_is_configured(self) -> None:
|
||||
from govoplan_core.core.module_package_catalog import OFFICIAL_MODULE_PACKAGE_CATALOG_URL
|
||||
|
||||
item = ModuleInstallPlanItem(
|
||||
module_id="files",
|
||||
action="install",
|
||||
source="catalog",
|
||||
catalog={"source": OFFICIAL_MODULE_PACKAGE_CATALOG_URL, "signed": True, "trusted": True},
|
||||
python_package="govoplan-files",
|
||||
python_ref="govoplan-files==0.1.18",
|
||||
)
|
||||
official = {"configured": True, "valid": True, "modules": []}
|
||||
with patch(
|
||||
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
||||
return_value={"configured": False, "valid": True, "modules": []},
|
||||
), patch(
|
||||
"govoplan_core.core.module_package_catalog.validate_official_module_package_catalog",
|
||||
return_value=official,
|
||||
) as validate_official:
|
||||
result = module_installer_module._validate_catalog_for_plan((item,))
|
||||
|
||||
self.assertIs(official, result)
|
||||
validate_official.assert_called_once_with()
|
||||
|
||||
def test_catalog_plan_is_bound_to_the_exact_validated_artifact(self) -> None:
|
||||
digest = "a" * 64
|
||||
python_ref = f"govoplan-files @ https://packages.example.test/files.whl#sha256={digest}"
|
||||
integrity = {
|
||||
"python": {
|
||||
"ref": python_ref,
|
||||
"url": "https://packages.example.test/files.whl",
|
||||
"filename": "govoplan_files-1.2.3-py3-none-any.whl",
|
||||
"sha256": digest,
|
||||
"size": 123,
|
||||
"registry_identity": "govoplan-files@1.2.3",
|
||||
"git_ref": "v1.2.3",
|
||||
"source_commit": "b" * 40,
|
||||
},
|
||||
}
|
||||
validation = {
|
||||
"configured": True,
|
||||
"valid": True,
|
||||
"source": "https://catalog.example.test/stable.json",
|
||||
"channel": "stable",
|
||||
"sequence": 42,
|
||||
"signed": True,
|
||||
"trusted": True,
|
||||
"key_id": "release-key-1",
|
||||
"modules": [{
|
||||
"module_id": "files",
|
||||
"action": "install",
|
||||
"python_package": "govoplan-files",
|
||||
"python_ref": python_ref,
|
||||
"artifact_integrity": integrity,
|
||||
}],
|
||||
}
|
||||
item = ModuleInstallPlanItem(
|
||||
module_id="files",
|
||||
action="install",
|
||||
source="catalog",
|
||||
catalog={
|
||||
"source": validation["source"],
|
||||
"channel": "stable",
|
||||
"sequence": 42,
|
||||
"signed": True,
|
||||
"trusted": True,
|
||||
"key_id": "release-key-1",
|
||||
},
|
||||
python_package="govoplan-files",
|
||||
python_ref=python_ref,
|
||||
artifact_integrity=integrity,
|
||||
)
|
||||
|
||||
self.assertEqual((), module_installer_module._catalog_plan_binding_issues((item,), validation))
|
||||
|
||||
tampered = replace(
|
||||
item,
|
||||
artifact_integrity={
|
||||
"python": {
|
||||
**integrity["python"],
|
||||
"url": "https://attacker.example.test/files.whl",
|
||||
},
|
||||
},
|
||||
)
|
||||
issues = module_installer_module._catalog_plan_binding_issues((tampered,), validation)
|
||||
self.assertEqual(["catalog_plan_binding_mismatch"], [issue.code for issue in issues])
|
||||
|
||||
def test_supervised_module_install_rollback_restores_desired_modules(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-desired-rollback-", dir=_TEST_ROOT))
|
||||
settings = _settings(root)
|
||||
@@ -3341,6 +3520,26 @@ finally:
|
||||
"version": "0.1.4",
|
||||
"python_package": "govoplan-files",
|
||||
"python_ref": "govoplan-files==0.1.4",
|
||||
"availability": "available",
|
||||
"configuration_requirements": ["Object storage binding"],
|
||||
"permissions": [{
|
||||
"scope": "files:file:read",
|
||||
"label": "Read files",
|
||||
"description": "Read managed files.",
|
||||
"category": "Files",
|
||||
"level": "tenant",
|
||||
"resource": "file",
|
||||
"action": "read",
|
||||
"deprecated": False,
|
||||
}],
|
||||
"release_notes_url": "https://git.example.test/modules/files/releases/v0.1.4",
|
||||
"source": {
|
||||
"repository": "govoplan-files",
|
||||
"tag": "v0.1.4",
|
||||
"commit": "a" * 40,
|
||||
"repository_url": "https://git.example.test/modules/govoplan-files",
|
||||
"revision_url": "https://git.example.test/modules/govoplan-files/commit/" + "a" * 40,
|
||||
},
|
||||
"dependencies": ["access"],
|
||||
"optional_dependencies": ["mail"],
|
||||
"migration_safety": "forward_only",
|
||||
@@ -3370,7 +3569,10 @@ finally:
|
||||
"artifact_integrity": {
|
||||
"python": {
|
||||
"ref": "govoplan-files==0.1.4",
|
||||
"url": "https://packages.example.test/govoplan-files-0.1.4.whl",
|
||||
"filename": "govoplan-files-0.1.4.whl",
|
||||
"sha256": "0" * 64,
|
||||
"size": 123,
|
||||
"sbom_url": "https://govoplan.example/sbom/files-0.1.4.spdx.json",
|
||||
"provenance_url": "https://govoplan.example/provenance/files-0.1.4.intoto.jsonl",
|
||||
}
|
||||
@@ -3419,11 +3621,110 @@ finally:
|
||||
],
|
||||
)
|
||||
self.assertEqual("0" * 64, catalog[0]["artifact_integrity"]["python"]["sha256"])
|
||||
self.assertEqual(123, catalog[0]["artifact_integrity"]["python"]["size"])
|
||||
self.assertEqual("available", catalog[0]["availability"])
|
||||
self.assertEqual(["Object storage binding"], catalog[0]["configuration_requirements"])
|
||||
self.assertEqual("files:file:read", catalog[0]["permissions"][0]["scope"])
|
||||
self.assertEqual("tenant", catalog[0]["permissions"][0]["level"])
|
||||
self.assertEqual("v0.1.4", catalog[0]["source"]["tag"])
|
||||
self.assertEqual("a" * 40, catalog[0]["source"]["commit"])
|
||||
self.assertEqual(
|
||||
"https://git.example.test/modules/files/releases/v0.1.4",
|
||||
catalog[0]["release_notes_url"],
|
||||
)
|
||||
|
||||
validation = validate_module_package_catalog(catalog_path)
|
||||
self.assertTrue(validation["valid"])
|
||||
self.assertEqual("files", validation["modules"][0]["module_id"])
|
||||
|
||||
def test_module_package_catalog_rejects_duplicate_permission_scopes(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-permissions-", dir=_TEST_ROOT))
|
||||
catalog_path = root / "catalog.json"
|
||||
permission = {
|
||||
"scope": "files:file:read",
|
||||
"label": "Read files",
|
||||
"description": "Read managed files.",
|
||||
"category": "Files",
|
||||
"level": "tenant",
|
||||
"resource": "file",
|
||||
"action": "read",
|
||||
"deprecated": False,
|
||||
}
|
||||
catalog_path.write_text(
|
||||
json.dumps({
|
||||
"modules": [{
|
||||
"module_id": "files",
|
||||
"version": "0.1.4",
|
||||
"permissions": [permission, permission],
|
||||
}],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
validation = validate_module_package_catalog(catalog_path)
|
||||
|
||||
self.assertFalse(validation["valid"])
|
||||
self.assertIn("more than once", str(validation["error"]))
|
||||
|
||||
def test_module_package_catalog_requires_reason_for_withdrawn_release(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-withdrawn-", dir=_TEST_ROOT))
|
||||
catalog_path = root / "catalog.json"
|
||||
catalog_path.write_text(json.dumps({
|
||||
"modules": [{
|
||||
"module_id": "files",
|
||||
"version": "0.1.4",
|
||||
"availability": "withdrawn",
|
||||
"python_package": "govoplan-files",
|
||||
"python_ref": "govoplan-files==0.1.4",
|
||||
}],
|
||||
}), encoding="utf-8")
|
||||
|
||||
validation = validate_module_package_catalog(catalog_path)
|
||||
|
||||
self.assertFalse(validation["valid"])
|
||||
self.assertIn("availability_reason", str(validation["error"]))
|
||||
|
||||
def test_module_package_catalog_rejects_invalid_source_provenance(self) -> None:
|
||||
invalid_sources = (
|
||||
{
|
||||
"repository": "../govoplan-files",
|
||||
"tag": "v0.1.4",
|
||||
"commit": "a" * 40,
|
||||
},
|
||||
{
|
||||
"repository": "govoplan-files",
|
||||
"tag": "v0.1.4",
|
||||
"commit": "not-a-commit",
|
||||
},
|
||||
{
|
||||
"repository": "govoplan-files",
|
||||
"tag": "v0.1.4",
|
||||
"commit": "a" * 40,
|
||||
"repository_url": "http://git.example.test/govoplan-files",
|
||||
},
|
||||
)
|
||||
for index, source in enumerate(invalid_sources):
|
||||
with self.subTest(source=source):
|
||||
root = Path(tempfile.mkdtemp(
|
||||
prefix=f"govoplan-module-package-catalog-source-{index}-",
|
||||
dir=_TEST_ROOT,
|
||||
))
|
||||
catalog_path = root / "catalog.json"
|
||||
catalog_path.write_text(json.dumps({
|
||||
"modules": [{
|
||||
"module_id": "files",
|
||||
"version": "0.1.4",
|
||||
"python_package": "govoplan-files",
|
||||
"python_ref": "govoplan-files==0.1.4",
|
||||
"source": source,
|
||||
}],
|
||||
}), encoding="utf-8")
|
||||
|
||||
validation = validate_module_package_catalog(catalog_path)
|
||||
|
||||
self.assertFalse(validation["valid"])
|
||||
self.assertIn("source", str(validation["error"]).lower())
|
||||
|
||||
def test_module_package_catalog_warns_about_interface_range_mismatch(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-interfaces-", dir=_TEST_ROOT))
|
||||
catalog_path = root / "catalog.json"
|
||||
@@ -3591,15 +3892,73 @@ finally:
|
||||
str(Path(__file__).resolve().parents[2] / "govoplan" / "tools" / "release" / "generate-release-catalog.py"),
|
||||
run_name="govoplan_release_catalog_contract_test",
|
||||
)
|
||||
workspace = Path(__file__).resolve().parents[2]
|
||||
version = "0.1.18"
|
||||
repositories = {
|
||||
"govoplan-core": ("govoplan-core", "@govoplan/core-webui", ["server"]),
|
||||
"govoplan-files": ("govoplan-files", "@govoplan/files-webui", []),
|
||||
"govoplan-mail": ("govoplan-mail", "@govoplan/mail-webui", []),
|
||||
"govoplan-campaign": ("govoplan-campaign", "@govoplan/campaign-webui", []),
|
||||
}
|
||||
python_packages = []
|
||||
webui_packages = []
|
||||
python_lock = []
|
||||
webui_lock = []
|
||||
for repository, (package, webui_package, extras) in repositories.items():
|
||||
commit = subprocess.check_output(
|
||||
["git", "-C", str(workspace / repository), "rev-parse", f"v{version}^{{commit}}"],
|
||||
text=True,
|
||||
).strip()
|
||||
source = {
|
||||
"version": version,
|
||||
"repository": repository,
|
||||
"tag": f"v{version}",
|
||||
"commit": commit,
|
||||
}
|
||||
python_packages.append({"name": package, "extras": extras, **source})
|
||||
webui_packages.append({"name": webui_package, **source})
|
||||
python_lock.append({
|
||||
"name": package,
|
||||
"extras": extras,
|
||||
"filename": f"{package.replace('-', '_')}-{version}-py3-none-any.whl",
|
||||
"url": f"https://packages.example.test/{package}-{version}.whl",
|
||||
"sha256": "a" * 64,
|
||||
"size": 100,
|
||||
**source,
|
||||
})
|
||||
webui_lock.append({
|
||||
"name": webui_package,
|
||||
"filename": f"{repository}-{version}.tgz",
|
||||
"url": f"https://packages.example.test/{repository}-{version}.tgz",
|
||||
"sha256": "b" * 64,
|
||||
"size": 100,
|
||||
"integrity": "sha512-test",
|
||||
**source,
|
||||
})
|
||||
generated_at = generator["datetime"].now(tz=generator["UTC"])
|
||||
catalog = generator["_catalog_payload"](
|
||||
version="0.1.9",
|
||||
tag="v0.1.9",
|
||||
package_set={
|
||||
"schema_version": "1",
|
||||
"release_version": version,
|
||||
"profile": "base",
|
||||
"package_set_sha256": "c" * 64,
|
||||
"python": python_packages,
|
||||
"webui": webui_packages,
|
||||
},
|
||||
package_lock={
|
||||
"schema_version": "1",
|
||||
"release_version": version,
|
||||
"profile": "base",
|
||||
"package_set_sha256": "c" * 64,
|
||||
"lock_sha256": "d" * 64,
|
||||
"python": python_lock,
|
||||
"webui": webui_lock,
|
||||
},
|
||||
channel="test",
|
||||
sequence=1,
|
||||
generated_at=generated_at,
|
||||
expires_at=generated_at + generator["timedelta"](days=1),
|
||||
repository_base="git+ssh://git@example.test/add-ideas",
|
||||
workspace=workspace,
|
||||
public_base_url="https://example.test",
|
||||
)
|
||||
modules = {item["module_id"]: item for item in catalog["modules"]}
|
||||
@@ -3682,11 +4041,19 @@ finally:
|
||||
)
|
||||
self.assertEqual("requires_review", modules["files"]["migration_safety"])
|
||||
self.assertIn("migration", modules["files"]["migration_notes"].lower())
|
||||
files_version = importlib.import_module(
|
||||
"govoplan_files.backend.manifest"
|
||||
).get_manifest().version
|
||||
self.assertEqual(files_version, modules["files"]["version"])
|
||||
self.assertIn(f"@v{files_version}", modules["files"]["python_ref"])
|
||||
self.assertEqual(version, modules["files"]["version"])
|
||||
self.assertEqual(
|
||||
f"govoplan-files @ https://packages.example.test/govoplan-files-{version}.whl#sha256={'a' * 64}",
|
||||
modules["files"]["python_ref"],
|
||||
)
|
||||
self.assertEqual(f"v{version}", modules["files"]["source"]["tag"])
|
||||
self.assertEqual(
|
||||
subprocess.check_output(
|
||||
["git", "-C", str(workspace / "govoplan-files"), "rev-parse", f"v{version}^{{commit}}"],
|
||||
text=True,
|
||||
).strip(),
|
||||
modules["files"]["source"]["commit"],
|
||||
)
|
||||
|
||||
def test_module_package_catalog_validates_remote_url_and_cache_fallback(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-remote-", dir=_TEST_ROOT))
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.navigation import (
|
||||
NavigationPreferences,
|
||||
navigation_preferences_from_settings,
|
||||
resolve_navigation_preferences,
|
||||
update_navigation_preferences,
|
||||
)
|
||||
|
||||
|
||||
class NavigationPreferenceTests(unittest.TestCase):
|
||||
def test_user_order_overrides_tenant_and_system_order(self) -> None:
|
||||
resolved = resolve_navigation_preferences(
|
||||
("dashboard", "files", "mail", "campaign"),
|
||||
system=NavigationPreferences(order=("mail", "files")),
|
||||
tenant=NavigationPreferences(order=("campaign", "mail")),
|
||||
user=NavigationPreferences(order=("files", "campaign")),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
["files", "campaign", "mail", "dashboard"],
|
||||
[item.id for item in sorted(resolved.values(), key=lambda item: item.order)],
|
||||
)
|
||||
self.assertEqual("user", resolved["files"].order_source)
|
||||
self.assertEqual("user", resolved["campaign"].order_source)
|
||||
|
||||
def test_lower_scope_cannot_hide_locked_item(self) -> None:
|
||||
resolved = resolve_navigation_preferences(
|
||||
("dashboard", "files", "mail"),
|
||||
system=NavigationPreferences(locked=("dashboard",)),
|
||||
tenant=NavigationPreferences(hidden=("dashboard", "files"), locked=("mail",)),
|
||||
user=NavigationPreferences(hidden=("dashboard", "mail")),
|
||||
)
|
||||
|
||||
self.assertTrue(resolved["dashboard"].visible)
|
||||
self.assertTrue(resolved["dashboard"].locked)
|
||||
self.assertEqual("system", resolved["dashboard"].lock_source)
|
||||
self.assertTrue(resolved["mail"].visible)
|
||||
self.assertEqual("tenant", resolved["mail"].lock_source)
|
||||
self.assertTrue(resolved["files"].visible)
|
||||
self.assertEqual("user", resolved["files"].visibility_source)
|
||||
|
||||
def test_higher_scope_visibility_replaces_inherited_preference(self) -> None:
|
||||
resolved = resolve_navigation_preferences(
|
||||
("files", "mail"),
|
||||
system=NavigationPreferences(hidden=("mail",)),
|
||||
tenant=NavigationPreferences(hidden=("files",)),
|
||||
)
|
||||
|
||||
self.assertTrue(resolved["mail"].visible)
|
||||
self.assertFalse(resolved["files"].visible)
|
||||
self.assertEqual("tenant", resolved["mail"].visibility_source)
|
||||
|
||||
def test_settings_round_trip_is_bounded_and_normalized(self) -> None:
|
||||
settings = update_navigation_preferences(
|
||||
{"unrelated": {"preserved": True}},
|
||||
NavigationPreferences(order=(" files ", "files", "mail"), hidden=("mail",)),
|
||||
)
|
||||
parsed = navigation_preferences_from_settings(settings)
|
||||
|
||||
self.assertEqual(("files", "mail"), parsed.order if parsed else ())
|
||||
self.assertEqual(("mail",), parsed.hidden if parsed else ())
|
||||
self.assertEqual({"preserved": True}, settings["unrelated"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,83 @@
|
||||
from datetime import UTC, datetime
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.institutional import EvidenceReference
|
||||
from govoplan_core.core.payments import (
|
||||
CAPABILITY_PAYMENT_REQUESTS,
|
||||
ManualPaymentReconciliationCommand,
|
||||
PaymentRequestCommand,
|
||||
payment_request_provider,
|
||||
)
|
||||
|
||||
|
||||
class _Provider:
|
||||
def request_payment(self, session, command):
|
||||
return {"payment_id": "payment-1", "status": "requested"}
|
||||
|
||||
def get_payment(self, session, *, tenant_id, payment_id):
|
||||
return {"payment_id": payment_id, "status": "requested"}
|
||||
|
||||
def reconcile_manual_payment(self, session, command):
|
||||
return {"payment_id": command.payment_id, "status": "paid"}
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, provider):
|
||||
self.provider = provider
|
||||
|
||||
def has_capability(self, name):
|
||||
return name == CAPABILITY_PAYMENT_REQUESTS
|
||||
|
||||
def capability(self, name):
|
||||
return self.provider
|
||||
|
||||
|
||||
class PaymentsContractTests(unittest.TestCase):
|
||||
def test_provider_preserves_request_and_evidence_contracts(self):
|
||||
provider = payment_request_provider(_Registry(_Provider()))
|
||||
now = datetime(2026, 8, 19, tzinfo=UTC)
|
||||
requested = provider.request_payment(
|
||||
None,
|
||||
PaymentRequestCommand(
|
||||
tenant_id="tenant-1",
|
||||
source_module="cases",
|
||||
source_resource_type="case",
|
||||
source_resource_id="case-1",
|
||||
amount_minor=3000,
|
||||
currency="EUR",
|
||||
subject="Resident parking permit fee",
|
||||
idempotency_key="case-1-fee",
|
||||
requested_at=now,
|
||||
requested_by_ref="account:officer-1",
|
||||
),
|
||||
)
|
||||
paid = provider.reconcile_manual_payment(
|
||||
None,
|
||||
ManualPaymentReconciliationCommand(
|
||||
tenant_id="tenant-1",
|
||||
payment_id=str(requested["payment_id"]),
|
||||
amount_minor=3000,
|
||||
currency="EUR",
|
||||
transaction_reference="BANK-2026-1",
|
||||
evidence_ref=EvidenceReference(
|
||||
kind="document",
|
||||
owner_module="files",
|
||||
evidence_id="file-1",
|
||||
tenant_id="tenant-1",
|
||||
version="1",
|
||||
checksum="a" * 64,
|
||||
),
|
||||
idempotency_key="bank-2026-1",
|
||||
received_at=now,
|
||||
recorded_at=now,
|
||||
recorded_by_ref="account:officer-1",
|
||||
),
|
||||
)
|
||||
self.assertEqual("paid", paid["status"])
|
||||
|
||||
def test_rejects_incomplete_provider(self):
|
||||
self.assertIsNone(payment_request_provider(_Registry(object())))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.policy import (
|
||||
CAPABILITY_POLICY_IMPACT_SUBJECT_PREFIX,
|
||||
PolicyImpactPopulationRequest,
|
||||
PolicyImpactSubject,
|
||||
PolicyImpactSubjectBatch,
|
||||
PolicyImpactSubjectProvider,
|
||||
policy_impact_subject_provider,
|
||||
)
|
||||
|
||||
|
||||
class _Provider:
|
||||
provider_id = "example"
|
||||
supported_policy_families = ("view",)
|
||||
|
||||
def collect_policy_impact_subjects(
|
||||
self,
|
||||
session: object | None = None,
|
||||
*,
|
||||
request: PolicyImpactPopulationRequest,
|
||||
) -> PolicyImpactSubjectBatch:
|
||||
del session, request
|
||||
return PolicyImpactSubjectBatch(
|
||||
provider_id=self.provider_id,
|
||||
subjects=(
|
||||
PolicyImpactSubject(
|
||||
module_id="example",
|
||||
resource_type="record",
|
||||
resource_id="record-1",
|
||||
action="view",
|
||||
),
|
||||
),
|
||||
total_available=1,
|
||||
)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, capability: object) -> None:
|
||||
self._capability = capability
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name == f"{CAPABILITY_POLICY_IMPACT_SUBJECT_PREFIX}example"
|
||||
|
||||
def capability(self, name: str) -> object:
|
||||
del name
|
||||
return self._capability
|
||||
|
||||
|
||||
class PolicyImpactContractTests(unittest.TestCase):
|
||||
def test_provider_contract_is_runtime_checkable_and_discoverable(self) -> None:
|
||||
provider = _Provider()
|
||||
self.assertIsInstance(provider, PolicyImpactSubjectProvider)
|
||||
self.assertIs(
|
||||
policy_impact_subject_provider(_Registry(provider), "example"),
|
||||
provider,
|
||||
)
|
||||
|
||||
def test_population_and_provider_batches_are_bounded(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "between 1 and 500"):
|
||||
PolicyImpactPopulationRequest(
|
||||
tenant_id="tenant-1",
|
||||
policy_family="view",
|
||||
limit=501,
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "at most 500"):
|
||||
PolicyImpactSubjectBatch(
|
||||
provider_id="example",
|
||||
subjects=tuple(
|
||||
PolicyImpactSubject(
|
||||
module_id="example",
|
||||
resource_type="record",
|
||||
resource_id=str(index),
|
||||
action="view",
|
||||
)
|
||||
for index in range(501)
|
||||
),
|
||||
)
|
||||
|
||||
def test_unavailable_batches_explain_the_gap(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "need an explanation"):
|
||||
PolicyImpactSubjectBatch(
|
||||
provider_id="example",
|
||||
state="unavailable",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -25,6 +25,16 @@ class _RoutingProvider:
|
||||
del session, tenant_id, limit
|
||||
return {"selected": 0}
|
||||
|
||||
def reconcile_notification_lifecycle(
|
||||
self,
|
||||
session,
|
||||
*,
|
||||
tenant_id=None,
|
||||
limit=50,
|
||||
):
|
||||
del session, tenant_id, limit
|
||||
return {"scanned": 0}
|
||||
|
||||
|
||||
class PostboxRoutingWorkerTests(unittest.TestCase):
|
||||
def test_contract_is_runtime_checkable_and_resolved(self) -> None:
|
||||
@@ -59,6 +69,13 @@ class PostboxRoutingWorkerTests(unittest.TestCase):
|
||||
"selected": 1,
|
||||
"delivered": 1,
|
||||
}
|
||||
provider.reconcile_notification_lifecycle.return_value = {
|
||||
"scanned": 2,
|
||||
"changed": 1,
|
||||
"events": 2,
|
||||
"notifications": 1,
|
||||
"notification_failures": 0,
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
@@ -81,8 +98,15 @@ class PostboxRoutingWorkerTests(unittest.TestCase):
|
||||
tenant_id="tenant-1",
|
||||
limit=25,
|
||||
)
|
||||
provider.reconcile_notification_lifecycle.assert_called_once_with(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
limit=25,
|
||||
)
|
||||
session.commit.assert_called_once_with()
|
||||
self.assertEqual(1, result["delivered"])
|
||||
self.assertEqual(2, result["lifecycle_scanned"])
|
||||
self.assertEqual(1, result["lifecycle_notifications"])
|
||||
|
||||
def test_route_and_periodic_recovery_are_registered(self) -> None:
|
||||
self.assertEqual(
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from govoplan_core.auth import get_api_principal
|
||||
from govoplan_core.core.modules import (
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
ModuleManifest,
|
||||
ProductAreaContribution,
|
||||
QuickAccessTool,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.server.platform import create_platform_router
|
||||
|
||||
|
||||
def presentation_manifest() -> ModuleManifest:
|
||||
return ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="test",
|
||||
frontend=FrontendModule(
|
||||
module_id="example",
|
||||
routes=(
|
||||
FrontendRoute(
|
||||
path="/example",
|
||||
component="ExamplePage",
|
||||
surface_id="example.route.main",
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="example.quick.summary",
|
||||
module_id="example",
|
||||
kind="quick_access",
|
||||
label="Example summary",
|
||||
),
|
||||
),
|
||||
product_areas=(
|
||||
ProductAreaContribution(
|
||||
id="work",
|
||||
module_id="example",
|
||||
label="Work",
|
||||
icon="list-checks",
|
||||
surface_ids=("example.route.main",),
|
||||
),
|
||||
),
|
||||
quick_access_tools=(
|
||||
QuickAccessTool(
|
||||
id="example.summary",
|
||||
module_id="example",
|
||||
category_id="work",
|
||||
label="Example summary",
|
||||
surface_id="example.quick.summary",
|
||||
icon="list-checks",
|
||||
full_page_path="/example",
|
||||
required_any=("example:item:read",),
|
||||
returned_reference_kinds=("example.item",),
|
||||
help_context_id="example.quick.summary",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class PresentationContractTests(unittest.TestCase):
|
||||
def test_registry_accepts_owned_presentation_contributions(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(presentation_manifest())
|
||||
|
||||
snapshot = registry.validate()
|
||||
|
||||
self.assertEqual("example", snapshot.manifests[0].id)
|
||||
|
||||
def test_registry_rejects_unknown_tool_surface(self) -> None:
|
||||
manifest = presentation_manifest()
|
||||
frontend = manifest.frontend
|
||||
assert frontend is not None
|
||||
invalid = ModuleManifest(
|
||||
id=manifest.id,
|
||||
name=manifest.name,
|
||||
version=manifest.version,
|
||||
frontend=FrontendModule(
|
||||
module_id="example",
|
||||
routes=frontend.routes,
|
||||
quick_access_tools=(
|
||||
QuickAccessTool(
|
||||
id="example.summary",
|
||||
module_id="example",
|
||||
category_id="work",
|
||||
label="Example summary",
|
||||
surface_id="example.missing",
|
||||
icon="list-checks",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(invalid)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "unknown surface"):
|
||||
registry.validate()
|
||||
|
||||
def test_platform_payload_exposes_presentation_catalogue(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(presentation_manifest())
|
||||
registry.validate()
|
||||
app = FastAPI()
|
||||
app.state.govoplan_registry = registry
|
||||
app.include_router(create_platform_router(), prefix="/api/v1")
|
||||
app.dependency_overrides[get_api_principal] = lambda: object()
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/v1/platform/modules")
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
frontend = response.json()["modules"][0]["frontend"]
|
||||
self.assertEqual("1", frontend["presentation_contract_version"])
|
||||
self.assertEqual("work", frontend["product_areas"][0]["id"])
|
||||
self.assertEqual("example.summary", frontend["quick_access_tools"][0]["id"])
|
||||
self.assertEqual("1", frontend["quick_access_tools"][0]["contract_version"])
|
||||
self.assertEqual(
|
||||
["example.item"],
|
||||
frontend["quick_access_tools"][0]["returned_reference_kinds"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"example.quick.summary",
|
||||
frontend["quick_access_tools"][0]["help_context_id"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -14,11 +14,22 @@ from govoplan_core.core.principal_cache import (
|
||||
auth_principal_revision,
|
||||
invalidate_auth_principals,
|
||||
)
|
||||
from govoplan_core.core.runtime import (
|
||||
clear_runtime,
|
||||
configure_runtime,
|
||||
get_runtime_context,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
class AuthPrincipalRevisionTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
# These tests create only the Core change-sequence tables. Keep them
|
||||
# independent of a registry configured by an earlier discovery test;
|
||||
# that registry may expose an optional durable Audit outbox whose
|
||||
# tables intentionally are not part of this fixture.
|
||||
self._runtime_context = get_runtime_context()
|
||||
clear_runtime()
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
bind=self.engine,
|
||||
@@ -31,6 +42,10 @@ class AuthPrincipalRevisionTests(unittest.TestCase):
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.engine.dispose()
|
||||
if self._runtime_context is None:
|
||||
clear_runtime()
|
||||
else:
|
||||
configure_runtime(self._runtime_context)
|
||||
|
||||
def test_revisions_are_tenant_scoped_and_include_system_changes(self) -> None:
|
||||
with self.SessionLocal() as session:
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.records import (
|
||||
RecordArchiveProviderState,
|
||||
RecordArchiveReceipt,
|
||||
RecordArchiveTransferRequest,
|
||||
RecordContractError,
|
||||
RecordFilingRequest,
|
||||
RecordSourceLocator,
|
||||
RecordSourceReference,
|
||||
RecordTransferPackage,
|
||||
record_archive_capabilities,
|
||||
record_archive_capability,
|
||||
record_source_capabilities,
|
||||
record_source_capability,
|
||||
)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def capability_names(self) -> tuple[str, ...]:
|
||||
return (
|
||||
"mail.delivery",
|
||||
"records.archive.simulation",
|
||||
"records.source.cases",
|
||||
"records.source.files",
|
||||
)
|
||||
|
||||
|
||||
class RecordsContractTests(unittest.TestCase):
|
||||
def test_exact_source_and_filing_request_are_tenant_bound(self) -> None:
|
||||
locator = RecordSourceLocator(
|
||||
tenant_id="tenant-1",
|
||||
source_module="files",
|
||||
resource_type="file_version",
|
||||
resource_id="asset-1",
|
||||
source_revision="version-7",
|
||||
)
|
||||
reference = RecordSourceReference(
|
||||
locator=locator,
|
||||
label="Evidence.pdf",
|
||||
content_sha256="a" * 64,
|
||||
size_bytes=12,
|
||||
recorded_at=datetime(2026, 8, 6, tzinfo=UTC),
|
||||
)
|
||||
request = RecordFilingRequest(
|
||||
tenant_id="tenant-1",
|
||||
record_id="record-1",
|
||||
source=locator,
|
||||
purpose="process application",
|
||||
filing_reason="Evidence received with the application.",
|
||||
idempotency_key="filing-1",
|
||||
)
|
||||
|
||||
self.assertEqual("version-7", reference.locator.source_revision)
|
||||
self.assertEqual("record-1", request.record_id)
|
||||
|
||||
def test_cross_tenant_filing_and_inexact_sources_are_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(RecordContractError, "source_revision"):
|
||||
RecordSourceLocator(
|
||||
tenant_id="tenant-1",
|
||||
source_module="files",
|
||||
resource_type="file_version",
|
||||
resource_id="asset-1",
|
||||
source_revision="",
|
||||
)
|
||||
|
||||
locator = RecordSourceLocator(
|
||||
tenant_id="tenant-1",
|
||||
source_module="cases",
|
||||
resource_type="case_revision",
|
||||
resource_id="case-1",
|
||||
source_revision="4",
|
||||
)
|
||||
with self.assertRaisesRegex(RecordContractError, "cross tenants"):
|
||||
RecordFilingRequest(
|
||||
tenant_id="tenant-2",
|
||||
record_id="record-1",
|
||||
source=locator,
|
||||
purpose="audit",
|
||||
filing_reason="Preserve the decision basis.",
|
||||
idempotency_key="filing-2",
|
||||
)
|
||||
|
||||
def test_source_capabilities_are_discoverable_without_module_imports(self) -> None:
|
||||
self.assertEqual("records.source.files", record_source_capability("files"))
|
||||
self.assertEqual(
|
||||
"records.source.forms_runtime",
|
||||
record_source_capability("forms_runtime"),
|
||||
)
|
||||
self.assertEqual(
|
||||
("records.source.cases", "records.source.files"),
|
||||
record_source_capabilities(_Registry()),
|
||||
)
|
||||
|
||||
def test_archive_contract_is_digest_bound_and_discoverable(self) -> None:
|
||||
package = RecordTransferPackage(
|
||||
tenant_id="tenant-1",
|
||||
package_id="package-1",
|
||||
record_id="record-1",
|
||||
record_revision=3,
|
||||
profile="govoplan-simulation-v1",
|
||||
manifest_sha256="a" * 64,
|
||||
manifest={"record_id": "record-1", "items": []},
|
||||
)
|
||||
request = RecordArchiveTransferRequest(
|
||||
package=package,
|
||||
purpose="archive appraisal",
|
||||
idempotency_key="transfer-1",
|
||||
)
|
||||
state = RecordArchiveProviderState(
|
||||
provider_id="simulation",
|
||||
label="Archive simulation",
|
||||
profiles=("govoplan-simulation-v1",),
|
||||
authority_modes=("linked_reference",),
|
||||
healthy=True,
|
||||
checked_at=datetime(2026, 8, 6, tzinfo=UTC),
|
||||
simulated=True,
|
||||
)
|
||||
|
||||
self.assertEqual("package-1", request.package.package_id)
|
||||
self.assertTrue(state.simulated)
|
||||
self.assertEqual(
|
||||
"records.archive.simulation",
|
||||
record_archive_capability("simulation"),
|
||||
)
|
||||
self.assertEqual(
|
||||
("records.archive.simulation",),
|
||||
record_archive_capabilities(_Registry()),
|
||||
)
|
||||
|
||||
def test_unknown_archive_outcome_cannot_be_retry_safe(self) -> None:
|
||||
with self.assertRaisesRegex(RecordContractError, "retry-safe"):
|
||||
RecordArchiveReceipt(
|
||||
provider_id="archive-1",
|
||||
package_id="package-1",
|
||||
outcome="outcome_unknown",
|
||||
observed_at=datetime(2026, 8, 6, tzinfo=UTC),
|
||||
receipt_sha256="b" * 64,
|
||||
retry_safe=True,
|
||||
)
|
||||
|
||||
def test_archive_provider_runtime_values_are_validated(self) -> None:
|
||||
with self.assertRaisesRegex(RecordContractError, "invalid authority mode"):
|
||||
RecordArchiveProviderState(
|
||||
provider_id="archive",
|
||||
label="Archive",
|
||||
profiles=("profile",),
|
||||
authority_modes=("untrusted",), # type: ignore[arg-type]
|
||||
healthy=True,
|
||||
checked_at=datetime.now(UTC),
|
||||
)
|
||||
with self.assertRaisesRegex(RecordContractError, "outcome is invalid"):
|
||||
RecordArchiveReceipt(
|
||||
provider_id="archive",
|
||||
package_id="package-1",
|
||||
outcome="maybe", # type: ignore[arg-type]
|
||||
observed_at=datetime.now(UTC),
|
||||
receipt_sha256="a" * 64,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from govoplan_core.core.modules import ModuleManifest
|
||||
from govoplan_core.core.operations import (
|
||||
RuntimeWorkStatusContext,
|
||||
RuntimeWorkStatusProviderRegistration,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||
from govoplan_core.core.runtime_work import collect_celery_runtime_work_status
|
||||
|
||||
|
||||
class Inspector:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
replies: dict[str, object] | None = None,
|
||||
queues: dict[str, list[dict[str, str]]] | None = None,
|
||||
active: dict[str, list[object]] | None = None,
|
||||
reserved: dict[str, list[object]] | None = None,
|
||||
) -> None:
|
||||
self._replies = replies or {}
|
||||
self._queues = queues or {}
|
||||
self._active = active or {}
|
||||
self._reserved = reserved or {}
|
||||
|
||||
def ping(self):
|
||||
return self._replies
|
||||
|
||||
def active_queues(self):
|
||||
return self._queues
|
||||
|
||||
def active(self):
|
||||
return self._active
|
||||
|
||||
def reserved(self):
|
||||
return self._reserved
|
||||
|
||||
|
||||
def _context(*, stale: bool = False, worker: bool = True) -> RuntimeWorkStatusContext:
|
||||
now = datetime.now(UTC)
|
||||
nodes = (
|
||||
{
|
||||
"role": "worker",
|
||||
"stale": stale,
|
||||
"last_heartbeat_at": (now - timedelta(seconds=12)).isoformat(),
|
||||
},
|
||||
) if worker else ()
|
||||
return RuntimeWorkStatusContext(
|
||||
profile="production",
|
||||
observed_at=now,
|
||||
stale_after_seconds=60,
|
||||
runtime_nodes=nodes,
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_queue_depth_is_healthy_not_inferred_idle() -> None:
|
||||
status = collect_celery_runtime_work_status(
|
||||
_context(),
|
||||
inspector=Inspector(
|
||||
replies={"worker-1": {"ok": "pong"}},
|
||||
queues={"worker-1": [{"name": "mail"}]},
|
||||
),
|
||||
queues=["mail"],
|
||||
queue_depth_reader=lambda queues: {},
|
||||
)
|
||||
|
||||
assert status.state == "healthy"
|
||||
assert status.queue_depths == {"mail": None}
|
||||
assert status.failures is None
|
||||
assert status.last_heartbeat_at is not None
|
||||
|
||||
|
||||
def test_busy_and_idle_require_measured_evidence() -> None:
|
||||
busy = collect_celery_runtime_work_status(
|
||||
_context(),
|
||||
inspector=Inspector(
|
||||
replies={"worker-1": {"ok": "pong"}},
|
||||
queues={"worker-1": [{"name": "mail"}]},
|
||||
active={"worker-1": [{"id": "task-1"}]},
|
||||
),
|
||||
queues=["mail"],
|
||||
queue_depth_reader=lambda queues: {"mail": 2},
|
||||
)
|
||||
idle = collect_celery_runtime_work_status(
|
||||
_context(),
|
||||
inspector=Inspector(
|
||||
replies={"worker-1": {"ok": "pong"}},
|
||||
queues={"worker-1": [{"name": "mail"}]},
|
||||
),
|
||||
queues=["mail"],
|
||||
queue_depth_reader=lambda queues: {"mail": 0},
|
||||
)
|
||||
|
||||
assert busy.state == "busy"
|
||||
assert busy.active_work == 1
|
||||
assert idle.state == "idle"
|
||||
|
||||
|
||||
def test_partial_and_stale_worker_evidence_are_not_healthy() -> None:
|
||||
degraded = collect_celery_runtime_work_status(
|
||||
_context(),
|
||||
inspector=Inspector(
|
||||
replies={"worker-1": {"ok": "pong"}},
|
||||
queues={"worker-1": [{"name": "mail"}]},
|
||||
),
|
||||
queues=["mail", "calendar"],
|
||||
queue_depth_reader=lambda queues: {queue: 0 for queue in queues},
|
||||
)
|
||||
stale = collect_celery_runtime_work_status(
|
||||
_context(stale=True),
|
||||
inspector=Inspector(
|
||||
replies={"worker-1": {"ok": "pong"}},
|
||||
queues={"worker-1": [{"name": "mail"}]},
|
||||
),
|
||||
queues=["mail"],
|
||||
queue_depth_reader=lambda queues: {"mail": 0},
|
||||
)
|
||||
|
||||
assert degraded.state == "degraded"
|
||||
assert stale.state == "stale"
|
||||
|
||||
|
||||
def test_no_reply_distinguishes_starting_from_unreachable() -> None:
|
||||
starting = collect_celery_runtime_work_status(
|
||||
_context(),
|
||||
inspector=Inspector(),
|
||||
queues=["mail"],
|
||||
queue_depth_reader=lambda queues: {},
|
||||
)
|
||||
unreachable = collect_celery_runtime_work_status(
|
||||
_context(worker=False),
|
||||
inspector=Inspector(),
|
||||
queues=["mail"],
|
||||
queue_depth_reader=lambda queues: {},
|
||||
)
|
||||
|
||||
assert starting.state == "starting"
|
||||
assert unreachable.state == "unreachable"
|
||||
|
||||
|
||||
def test_registry_rejects_duplicate_runtime_work_provider_ids() -> None:
|
||||
def provider(context: RuntimeWorkStatusContext):
|
||||
raise AssertionError(context)
|
||||
|
||||
def registration(module_id: str) -> RuntimeWorkStatusProviderRegistration:
|
||||
return RuntimeWorkStatusProviderRegistration(
|
||||
module_id=module_id,
|
||||
provider_id="example.queue",
|
||||
provider=provider,
|
||||
)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="alpha",
|
||||
name="Alpha",
|
||||
version="1",
|
||||
runtime_work_status_providers=(registration("alpha"),),
|
||||
)
|
||||
)
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="beta",
|
||||
name="Beta",
|
||||
version="1",
|
||||
runtime_work_status_providers=(registration("beta"),),
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(RegistryError, match="Duplicate runtime-work provider"):
|
||||
registry.validate()
|
||||
@@ -0,0 +1,378 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationTopic,
|
||||
ModuleContext,
|
||||
ModuleManifest,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||
from govoplan_core.core.semantic_documentation import (
|
||||
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
|
||||
SemanticDocumentationBreadcrumb,
|
||||
SemanticDocumentationContractError,
|
||||
SemanticDocumentationSubjectAnchor,
|
||||
SemanticDocumentationSubjectDescriptor,
|
||||
SemanticDocumentationSubjectPage,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def reference(
|
||||
*,
|
||||
tenant_id: str = "tenant-1",
|
||||
revision: str | None = "4",
|
||||
fingerprint: str | None = None,
|
||||
anchor: SemanticDocumentationSubjectAnchor | None = None,
|
||||
) -> SemanticDocumentationSubjectReference:
|
||||
return SemanticDocumentationSubjectReference(
|
||||
module_id="forms",
|
||||
tenant_id=tenant_id,
|
||||
subject_kind="form",
|
||||
subject_id="permit-application",
|
||||
anchor=anchor,
|
||||
observed_revision=revision,
|
||||
observed_fingerprint=fingerprint
|
||||
or semantic_documentation_fingerprint({"revision": revision}),
|
||||
)
|
||||
|
||||
|
||||
def descriptor(
|
||||
*,
|
||||
tenant_id: str = "tenant-1",
|
||||
revision: str = "4",
|
||||
fingerprint: str | None = None,
|
||||
label: str = "Permit application",
|
||||
anchor: SemanticDocumentationSubjectAnchor | None = None,
|
||||
) -> SemanticDocumentationSubjectDescriptor:
|
||||
return SemanticDocumentationSubjectDescriptor(
|
||||
reference=reference(
|
||||
tenant_id=tenant_id,
|
||||
revision=revision,
|
||||
fingerprint=fingerprint,
|
||||
anchor=anchor,
|
||||
),
|
||||
labels={"de": "Antrag auf Parkerlaubnis", "en": label},
|
||||
descriptions={"de": "Konfiguriertes Antragsformular."},
|
||||
breadcrumbs=(
|
||||
SemanticDocumentationBreadcrumb(
|
||||
label="Forms",
|
||||
subject_kind="form",
|
||||
subject_id="permit-application",
|
||||
),
|
||||
),
|
||||
route="/forms/permit-application",
|
||||
route_anchor=anchor.id if anchor else None,
|
||||
audience=("case_worker",),
|
||||
classification="internal",
|
||||
required_scopes=("forms:definition:read",),
|
||||
)
|
||||
|
||||
|
||||
class Provider:
|
||||
provider_id = "forms.semantic_subjects"
|
||||
module_id = "forms"
|
||||
contract_version = SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.list_calls = 0
|
||||
self.resolve_calls = 0
|
||||
|
||||
def list_subjects(self, _session, principal, *, request):
|
||||
self.list_calls += 1
|
||||
if getattr(principal, "account_id", "") == "denied":
|
||||
return SemanticDocumentationSubjectPage()
|
||||
return SemanticDocumentationSubjectPage(subjects=(descriptor(),))
|
||||
|
||||
def resolve_subject(self, _session, principal, *, reference):
|
||||
self.resolve_calls += 1
|
||||
if getattr(principal, "account_id", "") == "denied":
|
||||
return None
|
||||
current = descriptor()
|
||||
availability = (
|
||||
"changed"
|
||||
if reference.observed_fingerprint
|
||||
!= current.reference.observed_fingerprint
|
||||
else "available"
|
||||
)
|
||||
return SemanticDocumentationSubjectResolution(
|
||||
requested_reference=reference,
|
||||
availability=availability,
|
||||
subject=current,
|
||||
)
|
||||
|
||||
|
||||
class SemanticDocumentationContractTests(unittest.TestCase):
|
||||
def test_reference_identity_is_stable_across_labels_and_revisions(self) -> None:
|
||||
original = descriptor(label="Old label")
|
||||
renamed = descriptor(revision="5", label="New label")
|
||||
|
||||
self.assertEqual(
|
||||
original.reference.stable_key,
|
||||
renamed.reference.stable_key,
|
||||
)
|
||||
self.assertNotEqual(
|
||||
original.reference.observed_fingerprint,
|
||||
renamed.reference.observed_fingerprint,
|
||||
)
|
||||
|
||||
def test_nested_anchor_is_typed_and_changes_subject_identity(self) -> None:
|
||||
form = reference()
|
||||
field = reference(
|
||||
anchor=SemanticDocumentationSubjectAnchor(
|
||||
kind="field",
|
||||
id="vehicle-registration",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertNotEqual(form.stable_key, field.stable_key)
|
||||
restored = SemanticDocumentationSubjectReference.from_mapping(
|
||||
field.to_dict()
|
||||
)
|
||||
self.assertEqual(restored, field)
|
||||
|
||||
def test_reference_mapping_rejects_unbounded_provider_metadata(self) -> None:
|
||||
payload = reference().to_dict()
|
||||
payload["credentials"] = {"token": "must-not-cross"}
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
SemanticDocumentationContractError,
|
||||
"unsupported fields: credentials",
|
||||
):
|
||||
SemanticDocumentationSubjectReference.from_mapping(payload)
|
||||
|
||||
def test_descriptors_reject_remote_routes_and_missing_localization(self) -> None:
|
||||
with self.assertRaisesRegex(
|
||||
SemanticDocumentationContractError,
|
||||
"local absolute paths",
|
||||
):
|
||||
SemanticDocumentationSubjectDescriptor(
|
||||
reference=reference(),
|
||||
labels={"de": "Formular"},
|
||||
route="https://provider.example/form",
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
SemanticDocumentationContractError,
|
||||
"labels are required",
|
||||
):
|
||||
SemanticDocumentationSubjectDescriptor(
|
||||
reference=reference(),
|
||||
labels={},
|
||||
)
|
||||
|
||||
def test_resolution_distinguishes_change_supersession_and_absence(self) -> None:
|
||||
old = reference(
|
||||
revision="3",
|
||||
fingerprint=semantic_documentation_fingerprint({"revision": "3"}),
|
||||
)
|
||||
current = descriptor()
|
||||
changed = SemanticDocumentationSubjectResolution(
|
||||
requested_reference=old,
|
||||
availability="changed",
|
||||
subject=current,
|
||||
)
|
||||
superseded = SemanticDocumentationSubjectResolution(
|
||||
requested_reference=old,
|
||||
availability="superseded",
|
||||
superseded_by=SemanticDocumentationSubjectReference(
|
||||
module_id="forms",
|
||||
tenant_id="tenant-1",
|
||||
subject_kind="form",
|
||||
subject_id="permit-application-v2",
|
||||
),
|
||||
)
|
||||
missing = SemanticDocumentationSubjectResolution(
|
||||
requested_reference=old,
|
||||
availability="missing",
|
||||
reason_code="subject_removed",
|
||||
)
|
||||
|
||||
self.assertEqual(changed.availability, "changed")
|
||||
self.assertEqual(superseded.superseded_by.subject_id, "permit-application-v2")
|
||||
self.assertEqual(missing.reason_code, "subject_removed")
|
||||
with self.assertRaisesRegex(
|
||||
SemanticDocumentationContractError,
|
||||
"require a revision or fingerprint change",
|
||||
):
|
||||
SemanticDocumentationSubjectResolution(
|
||||
requested_reference=current.reference,
|
||||
availability="changed",
|
||||
subject=current,
|
||||
)
|
||||
|
||||
def test_subject_pages_reject_duplicate_stable_identities(self) -> None:
|
||||
with self.assertRaisesRegex(
|
||||
SemanticDocumentationContractError,
|
||||
"duplicate identities",
|
||||
):
|
||||
SemanticDocumentationSubjectPage(
|
||||
subjects=(descriptor(), descriptor(label="Renamed")),
|
||||
)
|
||||
|
||||
def test_registry_discovers_optional_providers_without_direct_imports(self) -> None:
|
||||
provider = Provider()
|
||||
registry = provider_registry(provider)
|
||||
|
||||
self.assertEqual(
|
||||
semantic_documentation_subject_provider_names(registry),
|
||||
("documentation.semantic_subjects.forms",),
|
||||
)
|
||||
self.assertEqual(
|
||||
semantic_documentation_subject_providers(registry),
|
||||
(("forms", provider),),
|
||||
)
|
||||
|
||||
def test_listing_and_resolution_are_tenant_and_principal_aware(self) -> None:
|
||||
provider = Provider()
|
||||
registry = provider_registry(provider)
|
||||
allowed = SimpleNamespace(tenant_id="tenant-1", account_id="account-1")
|
||||
wrong_tenant = SimpleNamespace(
|
||||
tenant_id="tenant-2", account_id="account-1"
|
||||
)
|
||||
denied = SimpleNamespace(tenant_id="tenant-1", account_id="denied")
|
||||
|
||||
pages = list_semantic_documentation_subjects(
|
||||
registry,
|
||||
object(),
|
||||
allowed,
|
||||
request=SemanticDocumentationSubjectQuery(tenant_id="tenant-1"),
|
||||
)
|
||||
self.assertEqual(len(pages), 1)
|
||||
self.assertEqual(pages[0][1].subjects[0].reference.tenant_id, "tenant-1")
|
||||
self.assertEqual(
|
||||
list_semantic_documentation_subjects(
|
||||
registry,
|
||||
object(),
|
||||
wrong_tenant,
|
||||
request=SemanticDocumentationSubjectQuery(tenant_id="tenant-1"),
|
||||
),
|
||||
(),
|
||||
)
|
||||
self.assertIsNone(
|
||||
resolve_semantic_documentation_subject(
|
||||
registry,
|
||||
object(),
|
||||
denied,
|
||||
reference=reference(),
|
||||
)
|
||||
)
|
||||
self.assertEqual(provider.list_calls, 1)
|
||||
self.assertEqual(provider.resolve_calls, 1)
|
||||
|
||||
def test_missing_optional_module_resolves_without_loading_feature_code(self) -> None:
|
||||
principal = SimpleNamespace(tenant_id="tenant-1", account_id="account-1")
|
||||
result = resolve_semantic_documentation_subject(
|
||||
PlatformRegistry(),
|
||||
object(),
|
||||
principal,
|
||||
reference=reference(),
|
||||
)
|
||||
|
||||
self.assertEqual(result.availability, "temporarily_unavailable")
|
||||
self.assertEqual(result.reason_code, "provider_unavailable")
|
||||
|
||||
def test_manifest_requires_exact_capability_docs_and_static_baselines(self) -> None:
|
||||
provider = Provider()
|
||||
capability = semantic_documentation_subject_capability("forms")
|
||||
missing_baseline = PlatformRegistry()
|
||||
missing_baseline.register(
|
||||
ModuleManifest(
|
||||
id="forms",
|
||||
name="Forms",
|
||||
version="1.0.0",
|
||||
capability_factories={capability: lambda _context: provider},
|
||||
capability_documentation={
|
||||
capability: CapabilityDocumentation(
|
||||
label="Form semantics",
|
||||
summary="Lists documentation-safe configured form subjects.",
|
||||
contract_version=(
|
||||
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
|
||||
),
|
||||
)
|
||||
},
|
||||
documentation=(static_topic("admin"),),
|
||||
)
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "static user and administrator"):
|
||||
missing_baseline.validate()
|
||||
|
||||
wrong_capability = PlatformRegistry()
|
||||
wrong_capability.register(
|
||||
ModuleManifest(
|
||||
id="forms",
|
||||
name="Forms",
|
||||
version="1.0.0",
|
||||
capability_factories={
|
||||
semantic_documentation_subject_capability("workflow"): (
|
||||
lambda _context: provider
|
||||
)
|
||||
},
|
||||
)
|
||||
)
|
||||
with self.assertRaisesRegex(RegistryError, "must be"):
|
||||
wrong_capability.validate()
|
||||
|
||||
def test_fingerprint_is_canonical_and_rejects_non_json_values(self) -> None:
|
||||
self.assertEqual(
|
||||
semantic_documentation_fingerprint({"b": 2, "a": 1}),
|
||||
semantic_documentation_fingerprint({"a": 1, "b": 2}),
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
SemanticDocumentationContractError,
|
||||
"canonical JSON",
|
||||
):
|
||||
semantic_documentation_fingerprint({"unsafe": object()})
|
||||
|
||||
|
||||
def static_topic(documentation_type: str) -> DocumentationTopic:
|
||||
return DocumentationTopic(
|
||||
id=f"forms.semantic.{documentation_type}",
|
||||
title="Form semantics",
|
||||
summary="Static form semantics baseline.",
|
||||
documentation_types=(documentation_type,), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def provider_registry(provider: Provider) -> PlatformRegistry:
|
||||
capability = semantic_documentation_subject_capability("forms")
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="forms",
|
||||
name="Forms",
|
||||
version="1.0.0",
|
||||
capability_factories={capability: lambda _context: provider},
|
||||
capability_documentation={
|
||||
capability: CapabilityDocumentation(
|
||||
label="Form semantic subjects",
|
||||
summary="Lists safe configured forms and fields for Docs.",
|
||||
contract_version=(
|
||||
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
|
||||
),
|
||||
documentation_types=("admin", "user"),
|
||||
)
|
||||
},
|
||||
documentation=(static_topic("admin"), static_topic("user")),
|
||||
)
|
||||
)
|
||||
registry.validate()
|
||||
registry.configure_capability_context(
|
||||
ModuleContext(registry=registry, settings=object())
|
||||
)
|
||||
return registry
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -10,8 +10,12 @@ from govoplan_core.core.files import (
|
||||
)
|
||||
from govoplan_core.core.templates import (
|
||||
CAPABILITY_TEMPLATE_CATALOG,
|
||||
CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
|
||||
CAPABILITY_TEMPLATE_RENDERER,
|
||||
TemplateCatalogProvider,
|
||||
TemplateContentDraftRequest,
|
||||
TemplateContentLibraryProvider,
|
||||
TemplateFieldRequirement,
|
||||
TemplateRenderRequest,
|
||||
TemplateRendererProvider,
|
||||
)
|
||||
@@ -37,6 +41,12 @@ class _Renderer:
|
||||
return None
|
||||
|
||||
|
||||
class _ContentLibrary:
|
||||
def create_content_draft(self, session, principal, *, request):
|
||||
del session, principal, request
|
||||
return None
|
||||
|
||||
|
||||
class _Store:
|
||||
def store_artifact(self, session, principal, *, request):
|
||||
del session, principal
|
||||
@@ -54,10 +64,14 @@ class _Store:
|
||||
class TemplateContractTests(unittest.TestCase):
|
||||
def test_capability_names_and_runtime_protocols_are_stable(self) -> None:
|
||||
self.assertEqual("templates.catalog", CAPABILITY_TEMPLATE_CATALOG)
|
||||
self.assertEqual(
|
||||
"templates.content_library", CAPABILITY_TEMPLATE_CONTENT_LIBRARY
|
||||
)
|
||||
self.assertEqual("templates.renderer", CAPABILITY_TEMPLATE_RENDERER)
|
||||
self.assertEqual("files.artifact_store", CAPABILITY_FILES_ARTIFACT_STORE)
|
||||
self.assertIsInstance(_Catalog(), TemplateCatalogProvider)
|
||||
self.assertIsInstance(_Renderer(), TemplateRendererProvider)
|
||||
self.assertIsInstance(_ContentLibrary(), TemplateContentLibraryProvider)
|
||||
self.assertIsInstance(_Store(), ManagedArtifactStore)
|
||||
|
||||
def test_requests_do_not_expose_consumer_or_files_models(self) -> None:
|
||||
@@ -71,6 +85,18 @@ class TemplateContractTests(unittest.TestCase):
|
||||
self.assertEqual("preview", render.mode)
|
||||
self.assertEqual("Generated", artifact.folder)
|
||||
|
||||
content = TemplateContentDraftRequest(
|
||||
name="Closing paragraph",
|
||||
template_type="content_fragment",
|
||||
usages=("campaign.content",),
|
||||
content_text="Kind regards",
|
||||
required_fields=(
|
||||
TemplateFieldRequirement(path="local.display_name", label="Display name"),
|
||||
),
|
||||
)
|
||||
self.assertEqual("de", content.locale)
|
||||
self.assertEqual("local.display_name", content.required_fields[0].path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -40,8 +40,17 @@ class WheelRuntimeTests(unittest.TestCase):
|
||||
"pip",
|
||||
"install",
|
||||
"--no-deps",
|
||||
"--force-reinstall",
|
||||
str(core_wheels[0]),
|
||||
cwd=temporary_root,
|
||||
# Full editable discovery exports every sibling src directory.
|
||||
# Do not let pip mistake that importable Core metadata for an
|
||||
# installation inside this fresh wheel-only environment.
|
||||
env={
|
||||
key: value
|
||||
for key, value in os.environ.items()
|
||||
if key != "PYTHONPATH"
|
||||
},
|
||||
)
|
||||
|
||||
database_path = temporary_root / "wheel-runtime.db"
|
||||
@@ -69,7 +78,7 @@ class WheelRuntimeTests(unittest.TestCase):
|
||||
self.assertNotEqual(repository_root, runtime_root)
|
||||
self.assertTrue((runtime_root / "alembic.ini").is_file())
|
||||
self.assertTrue((runtime_root / "alembic" / "env.py").is_file())
|
||||
self.assertEqual(["e14b8c2d6f90"], result["heads"])
|
||||
self.assertEqual(["b47e6f809a13"], result["heads"])
|
||||
self.assertIn("core_scopes", result["tables"])
|
||||
self.assertIn("core_system_settings", result["tables"])
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||
from govoplan_core.core.tasks import (
|
||||
WorkAssignmentRef,
|
||||
WorkItem,
|
||||
WorkItemPage,
|
||||
WorkItemProviderRegistration,
|
||||
WorkItemQuery,
|
||||
WorkSourceRef,
|
||||
)
|
||||
|
||||
|
||||
class _Provider:
|
||||
def list_items(self, session, principal, *, query):
|
||||
del session, principal
|
||||
return WorkItemPage(
|
||||
items=(
|
||||
WorkItem(
|
||||
id="task-1",
|
||||
provider_id="example.items",
|
||||
owner_module="example",
|
||||
tenant_id=query.tenant_id,
|
||||
title="Review request",
|
||||
assignments=(WorkAssignmentRef(kind="account", id="account-1"),),
|
||||
sources=(
|
||||
WorkSourceRef(
|
||||
module_id="cases",
|
||||
resource_type="case",
|
||||
resource_id="case-1",
|
||||
),
|
||||
),
|
||||
due_at=datetime(2026, 8, 7, tzinfo=UTC),
|
||||
),
|
||||
),
|
||||
total=1,
|
||||
)
|
||||
|
||||
|
||||
class WorkItemContractTests(unittest.TestCase):
|
||||
def test_provider_registration_is_lazy_and_stable(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1",
|
||||
work_item_providers=(
|
||||
WorkItemProviderRegistration(
|
||||
id="example.items",
|
||||
factory=lambda _context: _Provider(),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
registry.validate()
|
||||
registry.configure_capability_context(
|
||||
ModuleContext(registry=registry, settings=object())
|
||||
)
|
||||
registered, provider = registry.work_item_providers()[0]
|
||||
self.assertEqual("example.items", registered.registration.id)
|
||||
page = provider.list_items(
|
||||
object(),
|
||||
object(),
|
||||
query=WorkItemQuery(tenant_id="tenant-1"),
|
||||
)
|
||||
self.assertEqual("Review request", page.items[0].title)
|
||||
|
||||
def test_provider_ids_must_be_namespaced(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1",
|
||||
work_item_providers=(
|
||||
WorkItemProviderRegistration(
|
||||
id="items",
|
||||
factory=lambda _context: _Provider(),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
with self.assertRaisesRegex(RegistryError, "namespaced"):
|
||||
registry.validate()
|
||||
|
||||
def test_contract_rejects_unbounded_or_incomplete_values(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "tenant"):
|
||||
WorkItemQuery(tenant_id="")
|
||||
with self.assertRaisesRegex(ValueError, "assignment"):
|
||||
WorkAssignmentRef(kind="account", id="")
|
||||
with self.assertRaisesRegex(ValueError, "module"):
|
||||
WorkSourceRef(module_id="", resource_type="case", resource_id="1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,354 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { FileText, GitBranch, Inbox, Search, ShieldCheck } from "lucide-react";
|
||||
import { useLocation } from "react-router";
|
||||
import QuickAccessRail from "../../../govoplan-quick-access/webui/src/components/QuickAccessRail";
|
||||
import ActionToolbar from "../src/components/ActionToolbar";
|
||||
import Button from "../src/components/Button";
|
||||
import Card from "../src/components/Card";
|
||||
import ContentGrid, { FormGrid } from "../src/components/ContentGrid";
|
||||
import ContentSection from "../src/components/ContentSection";
|
||||
import CountBadge from "../src/components/CountBadge";
|
||||
import DefinitionNodeIcon from "../src/components/DefinitionNodeIcon";
|
||||
import DefinitionPalette, { DefinitionPaletteGroup, DefinitionPaletteItem } from "../src/components/DefinitionPalette";
|
||||
import DescriptionList, { DescriptionItem } from "../src/components/DescriptionList";
|
||||
import Dialog from "../src/components/Dialog";
|
||||
import { DialogForm, DialogSection } from "../src/components/DialogAnatomy";
|
||||
import FilterBar from "../src/components/FilterBar";
|
||||
import FloatingStatus from "../src/components/FloatingStatus";
|
||||
import FormSection from "../src/components/FormSection";
|
||||
import MetricCard from "../src/components/MetricCard";
|
||||
import MetricGrid from "../src/components/MetricGrid";
|
||||
import PageLayout from "../src/components/PageLayout";
|
||||
import PageActionBar from "../src/components/PageActionBar";
|
||||
import SelectionList, { SelectionListItem, SelectionListItemContent } from "../src/components/SelectionList";
|
||||
import StatePanel from "../src/components/StatePanel";
|
||||
import WorkspaceFrame from "../src/components/WorkspaceFrame";
|
||||
import WorkspaceLayout from "../src/components/WorkspaceLayout";
|
||||
import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
|
||||
import BreadcrumbBar from "../src/layout/BreadcrumbBar";
|
||||
import HelpMenu from "../src/layout/HelpMenu";
|
||||
import { useGuardedNavigate } from "../src/components/UnsavedChangesGuard";
|
||||
import {
|
||||
createQuickAccessLaunchContext,
|
||||
quickAccessLaunchState
|
||||
} from "../src/platform/launchContext";
|
||||
import type { ApiSettings, AuthInfo, EffectiveViewProjection, QuickAccessToolMetadata } from "../src/types";
|
||||
|
||||
export default function ConformanceApp() {
|
||||
const location = useLocation();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editorDirty, setEditorDirty] = useState(true);
|
||||
const [metricDrilldown, setMetricDrilldown] = useState("");
|
||||
|
||||
return (
|
||||
<main className="conformance-root" data-conformance-id="shared-ui-lab">
|
||||
<PageLayout
|
||||
archetype="overview"
|
||||
mode="embedded"
|
||||
title="Zentrale GovOPlaN-Oberflächen"
|
||||
description="Diese Prüfseite rendert reale gemeinsame Komponenten mit langen deutschen Beschriftungen, Zuständen und responsiven Zusammensetzungen."
|
||||
actions={<PageActionBar variant="overview" primaryActions={<Button variant="primary" data-testid="open-dialog" onClick={() => setDialogOpen(true)}>Prüfdialog öffnen</Button>} />}
|
||||
>
|
||||
<section className="conformance-section" aria-labelledby="actions-heading">
|
||||
<h2 id="actions-heading">Aktionen, Filter und Status</h2>
|
||||
<PageActionBar
|
||||
variant="editor"
|
||||
refreshable
|
||||
state={editorDirty ? "dirty" : "clean"}
|
||||
label="Bearbeitungsaktionen"
|
||||
reloadAction={{ onReload: () => undefined, label: "Neu laden" }}
|
||||
contextActions={<Button>Vorschau öffnen</Button>}
|
||||
destructiveActions={<Button variant="danger" disabledReason="Nur die federführende Stelle darf diesen Vorgang endgültig löschen.">Löschen</Button>}
|
||||
discardAction={{ label: "Verwerfen", onClick: () => setEditorDirty(false) }}
|
||||
saveAction={{ label: "Änderungen speichern", onClick: () => setEditorDirty(false) }}
|
||||
/>
|
||||
<FilterBar surface="control" aria-label="Vorgangsliste filtern">
|
||||
<label>Suche<input type="search" placeholder="Aktenzeichen oder verantwortliche Stelle" /></label>
|
||||
<label>Status<select defaultValue="open"><option value="open">Offen</option><option value="done">Abgeschlossen</option></select></label>
|
||||
<Button><Search size={16} aria-hidden="true" /> Anwenden</Button>
|
||||
<CountBadge aria-label="27 Treffer">27</CountBadge>
|
||||
</FilterBar>
|
||||
<ContentGrid columns={3} collapseAt="standard" spacing="block">
|
||||
<StatePanel title="Keine Einträge" description="Für den gewählten Zeitraum liegen noch keine Einträge vor." surface="dashed" size="compact" icon={<Inbox size={22} />} />
|
||||
<StatePanel title="Berechtigung erforderlich" description="Die zuständige Administration kann den fehlenden Zugriff prüfen." tone="warning" surface="subtle" size="compact" icon={<ShieldCheck size={22} />} />
|
||||
<StatePanel title="Verbindung unterbrochen" description="Gespeicherte Daten bleiben erhalten. Erneut versuchen, sobald die Verbindung verfügbar ist." tone="danger" surface="subtle" size="compact" actions={<Button>Erneut versuchen</Button>} />
|
||||
</ContentGrid>
|
||||
</section>
|
||||
|
||||
<section className="conformance-section" aria-labelledby="metrics-heading">
|
||||
<h2 id="metrics-heading">Kennzahlen und Eigenschaften</h2>
|
||||
<MetricGrid columns={4} collapseAt="standard">
|
||||
<MetricCard label="Offene Aufgaben" value="18" detail="3 heute fällig" drilldown={{ label: "Offene Aufgaben prüfen", onActivate: () => setMetricDrilldown("18 offene Aufgaben") }} />
|
||||
<MetricCard label="Fristgerecht" value="94 %" tone="good" detail="Letzte 30 Tage" />
|
||||
<MetricCard label="Klärung erforderlich" value="4" tone="warning" density="compact" />
|
||||
<MetricCard label="Fehlgeschlagen" value="1" tone="danger" surface="subtle" />
|
||||
</MetricGrid>
|
||||
<p data-testid="metric-drilldown-result" role="status">{metricDrilldown}</p>
|
||||
<ContentSection density="default" surface="subtle">
|
||||
<h3>Nachvollziehbare Entscheidung</h3>
|
||||
<DescriptionList columns={3} collapseAt="standard">
|
||||
<DescriptionItem term="Aktenzeichen">A-2026-004218</DescriptionItem>
|
||||
<DescriptionItem term="Verantwortliche Stelle">Fachbereich Öffentlicher Raum und nachhaltige Mobilität</DescriptionItem>
|
||||
<DescriptionItem term="Letzte Änderung">18. August 2026, 14:42 Uhr</DescriptionItem>
|
||||
</DescriptionList>
|
||||
</ContentSection>
|
||||
</section>
|
||||
|
||||
<section className="conformance-section" aria-labelledby="workspace-heading">
|
||||
<h2 id="workspace-heading">Listen- und Arbeitsbereich</h2>
|
||||
<WorkspaceFrame className="conformance-workspace" label="Vorgangsauswahl">
|
||||
<WorkspaceLayout
|
||||
variant="split"
|
||||
primarySize="compact"
|
||||
surface="contained"
|
||||
primaryLabel="Vorgänge"
|
||||
contentLabel="Ausgewählter Vorgang"
|
||||
primary={
|
||||
<>
|
||||
<WorkspaceActionBar scope="collection-pane" variant="collection" refreshable reloadAction={{ onReload: () => undefined, label: "Vorgänge neu laden" }} contextActions={<strong>Vorgänge</strong>} createAction={<Button variant="primary">Neu</Button>} />
|
||||
<SelectionList label="Vorgänge" variant="navigation">
|
||||
<SelectionListItem selected>
|
||||
<SelectionListItemContent leading={<FileText size={18} />} title="Anwohnerparkausweis" description="A-2026-004218 · Prüfung läuft" />
|
||||
</SelectionListItem>
|
||||
<SelectionListItem selected={false}>
|
||||
<SelectionListItemContent leading={<FileText size={18} />} title="Sondernutzung öffentlicher Fläche" description="A-2026-004219 · Rückfrage offen" />
|
||||
</SelectionListItem>
|
||||
</SelectionList>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<WorkspaceActionBar scope="detail-pane" variant="detail" contextActions={<strong>Anwohnerparkausweis</strong>} primaryActions={<Button variant="primary">Bearbeiten</Button>} destructiveActions={<Button variant="danger">Schließen</Button>} />
|
||||
<Card title="Anwohnerparkausweis">
|
||||
<p>Die Identität wurde geprüft. Ein aktueller Wohnsitznachweis muss noch bestätigt werden.</p>
|
||||
<FormSection title="Nächster Arbeitsschritt" description="Die Entscheidung bleibt nachvollziehbar und kann vor Abschluss korrigiert werden." variant="panel">
|
||||
<FormGrid columns={2}>
|
||||
<label>Zuständigkeit<input defaultValue="Bürgerdienste Mitte" /></label>
|
||||
<label>Bearbeitungsfrist<input type="date" defaultValue="2026-08-28" /></label>
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
</Card>
|
||||
</>
|
||||
</WorkspaceLayout>
|
||||
</WorkspaceFrame>
|
||||
</section>
|
||||
|
||||
<section className="conformance-section" aria-labelledby="definition-heading">
|
||||
<h2 id="definition-heading">Definitionsarbeitsbereich</h2>
|
||||
<div className="conformance-definition">
|
||||
<DefinitionPalette label="Bausteine" description="Mit Tastatur oder Schaltfläche einfügen">
|
||||
<DefinitionPaletteGroup label="Verarbeitung">
|
||||
<DefinitionPaletteItem icon={<DefinitionNodeIcon><GitBranch size={16} /></DefinitionNodeIcon>} label="Bedingung prüfen" />
|
||||
<DefinitionPaletteItem icon={<DefinitionNodeIcon><ShieldCheck size={16} /></DefinitionNodeIcon>} label="Freigabe anfordern" />
|
||||
</DefinitionPaletteGroup>
|
||||
</DefinitionPalette>
|
||||
<ContentSection className="conformance-canvas" density="default" spacing="none">
|
||||
<StatePanel title="Definition auswählen" description="Wählen Sie links einen Baustein oder öffnen Sie eine vorhandene Definition." size="fill" />
|
||||
<FloatingStatus>Automatische Prüfung läuft …</FloatingStatus>
|
||||
</ContentSection>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<LaunchContextScenario />
|
||||
{new URLSearchParams(location.search).has("help") ? <HelpConformanceScenario /> : null}
|
||||
</PageLayout>
|
||||
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
title="Konsequenz vor Ausführung prüfen"
|
||||
description="Die Änderung betrifft nachgelagerte Aufgaben und wird mit Verantwortlichkeit und Zeitpunkt protokolliert."
|
||||
size="default"
|
||||
onClose={() => setDialogOpen(false)}
|
||||
footer={<><Button onClick={() => setDialogOpen(false)}>Abbrechen</Button><Button variant="primary" onClick={() => setDialogOpen(false)}>Änderung bestätigen</Button></>}
|
||||
>
|
||||
<DialogForm onSubmit={(event) => event.preventDefault()}>
|
||||
<DialogSection title="Begründung">
|
||||
<label>Begründung<textarea defaultValue="Die vorliegenden Nachweise wurden vollständig geprüft." /></label>
|
||||
</DialogSection>
|
||||
</DialogForm>
|
||||
</Dialog>
|
||||
{new URLSearchParams(location.search).has("quick-access") ? <QuickAccessScenario /> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpConformanceScenario() {
|
||||
return (
|
||||
<section className="conformance-section" aria-labelledby="help-heading">
|
||||
<h2 id="help-heading">Kontextsensitive Hilfe</h2>
|
||||
<p>F1 löst den Hilfekontext des fokussierten Bedienelements auf und kehrt nach dem Schließen dorthin zurück.</p>
|
||||
<ActionToolbar surface="subtle">
|
||||
<Button
|
||||
variant="danger"
|
||||
data-testid="f1-retention-action"
|
||||
helpContextId="policy.retention.action.apply"
|
||||
helpModuleId="policy"
|
||||
>
|
||||
Aufbewahrungsregeln anwenden
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="f1-fallback-action"
|
||||
interfaceId="core.conformance.action.review"
|
||||
>
|
||||
Allgemeine Aktion prüfen
|
||||
</Button>
|
||||
<HelpMenu auth={null} />
|
||||
</ActionToolbar>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickAccessScenario() {
|
||||
const location = useLocation();
|
||||
const mode = new URLSearchParams(location.search).get("quick-access");
|
||||
const viewContext = useMemo(
|
||||
() => mode === "focused" || mode === "stale-focus"
|
||||
? {
|
||||
...CONFORMANCE_VIEW_PROJECTION,
|
||||
presentation: {
|
||||
quickAccessFocusedToolIds: mode === "focused"
|
||||
? ["files.recent"]
|
||||
: ["mail.compose"]
|
||||
}
|
||||
}
|
||||
: null,
|
||||
[mode]
|
||||
);
|
||||
const launchContext = useMemo(() => createQuickAccessLaunchContext({
|
||||
pathname: location.pathname,
|
||||
search: location.search,
|
||||
hash: location.hash,
|
||||
historyIndex: browserHistoryIndex(),
|
||||
auth: CONFORMANCE_AUTH,
|
||||
activeObject: {
|
||||
ownerModule: "cases",
|
||||
kind: "case",
|
||||
objectId: "case-1",
|
||||
tenantId: "tenant-1",
|
||||
label: "RPP-2026-0001 · Anwohnerparkausweis"
|
||||
},
|
||||
temporalContext: { validityMode: "current", validAt: null, recordedAt: null },
|
||||
viewContext
|
||||
}), [location.hash, location.pathname, location.search, viewContext]);
|
||||
return <QuickAccessRail
|
||||
settings={CONFORMANCE_SETTINGS}
|
||||
auth={CONFORMANCE_AUTH}
|
||||
tools={CONFORMANCE_QUICK_ACCESS_TOOLS}
|
||||
launchContext={launchContext}
|
||||
/>;
|
||||
}
|
||||
|
||||
function LaunchContextScenario() {
|
||||
const location = useLocation();
|
||||
const navigate = useGuardedNavigate();
|
||||
const launchContext = useMemo(() => createQuickAccessLaunchContext({
|
||||
pathname: location.pathname,
|
||||
search: location.search,
|
||||
hash: location.hash,
|
||||
historyIndex: browserHistoryIndex(),
|
||||
auth: CONFORMANCE_AUTH,
|
||||
activeObject: {
|
||||
ownerModule: "cases",
|
||||
kind: "case",
|
||||
objectId: "case-1",
|
||||
tenantId: "tenant-1",
|
||||
label: "RPP-2026-0001 · Anwohnerparkausweis",
|
||||
version: "4",
|
||||
path: "/cases/case-1"
|
||||
},
|
||||
temporalContext: { validityMode: "current", validAt: null, recordedAt: null }
|
||||
}), [location.hash, location.pathname, location.search]);
|
||||
|
||||
return (
|
||||
<section className="conformance-section" aria-labelledby="launch-heading">
|
||||
<h2 id="launch-heading">Start- und Rücksprungkontext</h2>
|
||||
<p>Ein begrenzter Objektverweis öffnet das vollständige Werkzeug und erhält einen eindeutigen Rücksprung zum Vorgang.</p>
|
||||
<BreadcrumbBar
|
||||
pathname={location.pathname === "/" ? "/cases/case-1" : location.pathname}
|
||||
locationState={location.state}
|
||||
/>
|
||||
<ActionToolbar surface="subtle">
|
||||
<Button
|
||||
data-testid="launch-full-page"
|
||||
onClick={() => navigate("/files", { state: quickAccessLaunchState(launchContext) })}
|
||||
>
|
||||
Vollständige Dateiansicht öffnen
|
||||
</Button>
|
||||
</ActionToolbar>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const CONFORMANCE_AUTH = {
|
||||
user: { id: "user-1", account_id: "account-1", email: "case@example.test" },
|
||||
tenant: { id: "tenant-1", slug: "reference", name: "Referenzkommune" },
|
||||
scopes: [],
|
||||
roles: [],
|
||||
groups: [],
|
||||
profile_loaded: true,
|
||||
roles_loaded: true,
|
||||
groups_loaded: true
|
||||
} satisfies AuthInfo;
|
||||
|
||||
const CONFORMANCE_SETTINGS: ApiSettings = {
|
||||
apiBaseUrl: "",
|
||||
apiKey: "",
|
||||
accessToken: ""
|
||||
};
|
||||
|
||||
const CONFORMANCE_QUICK_ACCESS_TOOLS: QuickAccessToolMetadata[] = [{
|
||||
contractVersion: "1",
|
||||
id: "files.recent",
|
||||
moduleId: "files",
|
||||
categoryId: "files",
|
||||
label: "Recent files",
|
||||
description: "Select an authorized file without leaving this case.",
|
||||
iconName: "files",
|
||||
surfaceId: "files.route.files",
|
||||
fullPagePath: "/files",
|
||||
allOf: [],
|
||||
anyOf: [],
|
||||
order: 10,
|
||||
defaultEnabled: true,
|
||||
modes: ["select"],
|
||||
availability: "global",
|
||||
acceptedReferenceKinds: [],
|
||||
returnedReferenceKinds: ["files.file-version"],
|
||||
helpContextId: "files.quick_access.files"
|
||||
}, {
|
||||
contractVersion: "1",
|
||||
id: "postbox.unread",
|
||||
moduleId: "postbox",
|
||||
categoryId: "messages",
|
||||
label: "Unread messages",
|
||||
description: "Open an authorized unread message.",
|
||||
iconName: "messages",
|
||||
surfaceId: "postbox.route.postbox",
|
||||
fullPagePath: "/postbox",
|
||||
allOf: [],
|
||||
anyOf: [],
|
||||
order: 10,
|
||||
defaultEnabled: true,
|
||||
modes: ["view"],
|
||||
availability: "global",
|
||||
acceptedReferenceKinds: [],
|
||||
returnedReferenceKinds: ["postbox.message"],
|
||||
helpContextId: "postbox.quick_access.messages"
|
||||
}];
|
||||
|
||||
const CONFORMANCE_VIEW_PROJECTION: EffectiveViewProjection = {
|
||||
activeViewId: "case-processing",
|
||||
activeRevisionId: "case-processing-r4",
|
||||
activeViewName: "Case processing",
|
||||
visibleSurfaceIds: [],
|
||||
projectionActive: true,
|
||||
locked: false,
|
||||
availableViews: [],
|
||||
provenance: [{ source: "tenant-default", scopeType: "tenant", scopeId: "tenant-1" }],
|
||||
diagnostics: []
|
||||
};
|
||||
|
||||
function browserHistoryIndex(): number | null {
|
||||
const value = (window.history.state as { idx?: unknown } | null)?.idx;
|
||||
return typeof value === "number" && Number.isInteger(value) ? value : null;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Narrow facade used only by the conformance build. It lets the optional
|
||||
// Quick Access module exercise its real rail without pulling the composed
|
||||
// application's generated module catalogue into this isolated test bundle.
|
||||
export { apiFetch } from "../src/api/client";
|
||||
export { default as DismissibleAlert } from "../src/components/DismissibleAlert";
|
||||
export { default as DocumentationHelpLink } from "../src/components/help/DocumentationHelpLink";
|
||||
export { default as IconButton } from "../src/components/IconButton";
|
||||
export { default as LoadingFrame } from "../src/components/LoadingFrame";
|
||||
export { useGuardedNavigate } from "../src/components/UnsavedChangesGuard";
|
||||
export { usePlatformLanguage } from "../src/i18n/LanguageContext";
|
||||
export {
|
||||
dispatchQuickAccessResult,
|
||||
quickAccessLaunchState
|
||||
} from "../src/platform/launchContext";
|
||||
export { i18nMessage } from "../src/i18n/LanguageContext";
|
||||
export type {
|
||||
ApiSettings,
|
||||
QuickAccessRailProps,
|
||||
QuickAccessToolsUiCapability
|
||||
} from "../src/types";
|
||||
|
||||
export function usePlatformUiCapabilities<T = unknown>(capabilityName: string): T[] {
|
||||
void capabilityName;
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; background: var(--bg); }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
|
||||
.conformance-root { min-height: 100vh; }
|
||||
.conformance-section { display: grid; gap: 14px; padding-block: 20px; border-top: var(--border-line); }
|
||||
.conformance-section:first-child { border-top: 0; }
|
||||
.conformance-section > h2, .conformance-section h3 { margin: 0; color: var(--text-strong); }
|
||||
.conformance-section p { margin-block: 0 12px; }
|
||||
.conformance-section label { display: grid; gap: 5px; color: var(--text-label); font-size: 13px; font-weight: 700; }
|
||||
.conformance-workspace { height: 430px; }
|
||||
.conformance-definition { display: grid; grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); min-height: 330px; gap: 12px; }
|
||||
.conformance-canvas { position: relative; min-height: 330px; overflow: hidden; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.conformance-definition { grid-template-columns: 1fr; }
|
||||
.conformance-workspace { height: auto; min-height: 560px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.conformance-root *, .conformance-root *::before, .conformance-root *::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: .01ms !important;
|
||||
animation-duration: .01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user