32 Commits

Author SHA1 Message Date
51d4032b86 docs(core): define compatibility retention policy 2026-07-30 01:03:39 +02:00
0beb9ffea9 build: reject duplicate generated translations 2026-07-30 00:40:25 +02:00
9e6a6b5fdc feat(webui): center optional global search 2026-07-29 22:00:45 +02:00
48fb953b93 feat: add durable auth principal cache revisions 2026-07-29 19:23:52 +02:00
4bde0495f7 feat: expose workflow view resolution input 2026-07-29 19:09:03 +02:00
a80caf7933 feat: support focused dev reload scopes 2026-07-29 18:52:55 +02:00
790790ab37 feat: expose sanctions screening integration 2026-07-29 18:46:53 +02:00
920e3c9834 feat: version search source contracts 2026-07-29 18:08:52 +02:00
13893c80cd feat: version automation principal subjects 2026-07-29 17:48:36 +02:00
a192a2215f Add durable platform event delivery contract 2026-07-29 17:34:52 +02:00
e8fed6d25a fix: resolve postbox inbox icon 2026-07-29 16:01:45 +02:00
d9b5708df0 feat: add search and external integration contracts 2026-07-29 15:50:08 +02:00
68328f3d8e feat: strengthen module contracts and shared WebUI runtime 2026-07-29 14:16:28 +02:00
53e947935a fix: standardize direct page scroll viewports 2026-07-28 22:50:11 +02:00
324c26da78 fix: allow fallback dashboard scrolling 2026-07-28 22:13:22 +02:00
389f98e349 Keep normalized View roots acyclic 2026-07-28 21:32:20 +02:00
ce9ef8d88f Add governed View surface runtime 2026-07-28 21:04:54 +02:00
13bc3d3b4e Add shared credential envelope infrastructure 2026-07-28 19:32:41 +02:00
3f5870281a Color all shared alert tones 2026-07-28 18:35:53 +02:00
a46df85479 Resolve XyFlow styles for linked modules 2026-07-28 15:47:15 +02:00
c31581b1b9 Prebundle XyFlow for linked module development 2026-07-28 15:39:00 +02:00
26ae034153 Add governed automation contracts 2026-07-28 15:02:42 +02:00
baa2143a26 feat: add dataflow publication contracts and workflow webui 2026-07-28 13:47:50 +02:00
8b1910b5b7 feat: add datasource and definition graph contracts 2026-07-28 12:42:49 +02:00
d36bb94335 Add provider-neutral tabular source contracts 2026-07-28 11:12:24 +02:00
74034947c6 Update vulnerable PostCSS dependency 2026-07-28 01:36:39 +02:00
c7183fe7f1 Integrate Dataflow module into core 2026-07-28 01:33:37 +02:00
139a352c80 chore: update GovOPlaN repository references 2026-07-27 15:46:51 +02:00
336c94137f chore(release): align Mail bundle with Campaign contract 2026-07-23 00:47:34 +02:00
93225b6487 docs: move system status badges to meta repository 2026-07-22 23:45:42 +02:00
e11ea81008 chore(release): prepare Core 0.1.14 2026-07-22 20:31:49 +02:00
bc8afeb139 test(campaign): align synchronous send security contract 2026-07-22 20:30:00 +02:00
128 changed files with 13161 additions and 553 deletions

View File

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

View File

@@ -0,0 +1,87 @@
"""add reusable core credential envelopes
Revision ID: c91f0a72be34
Revises: 0f1e2d3c4b5a
Create Date: 2026-07-23 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c91f0a72be34"
down_revision = "0f1e2d3c4b5a"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "core_credential_envelopes" in inspector.get_table_names():
return
op.create_table(
"core_credential_envelopes",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("scope_type", sa.String(length=20), nullable=False),
sa.Column("scope_id", sa.String(length=255), nullable=True),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("credential_kind", sa.String(length=40), nullable=False),
sa.Column("public_data", sa.JSON(), nullable=False),
sa.Column("secret_data_encrypted", sa.Text(), nullable=True),
sa.Column("secret_keys", sa.JSON(), nullable=False),
sa.Column("allowed_modules", sa.JSON(), nullable=False),
sa.Column("allowed_server_refs", sa.JSON(), nullable=False),
sa.Column("inherit_to_lower_scopes", sa.Boolean(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("revision", sa.String(length=36), nullable=False),
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
sa.Column("updated_by_user_id", sa.String(length=36), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["tenant_id"],
["core_scopes.id"],
name=op.f("fk_core_credential_envelopes_tenant_id_core_scopes"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_credential_envelopes")),
)
op.create_index(
"ix_core_credential_envelopes_scope",
"core_credential_envelopes",
["tenant_id", "scope_type", "scope_id"],
unique=False,
)
op.create_index(
"ix_core_credential_envelopes_active",
"core_credential_envelopes",
["tenant_id", "is_active", "deleted_at"],
unique=False,
)
for column in (
"tenant_id",
"scope_type",
"scope_id",
"credential_kind",
"is_active",
"created_by_user_id",
"updated_by_user_id",
"deleted_at",
):
op.create_index(
op.f(f"ix_core_credential_envelopes_{column}"),
"core_credential_envelopes",
[column],
unique=False,
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "core_credential_envelopes" in inspector.get_table_names():
op.drop_table("core_credential_envelopes")

View File

@@ -12,6 +12,7 @@ except ModuleNotFoundError as exc:
raise
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
from govoplan_core.core.migrations import migration_metadata_plan
from govoplan_core.db.base import Base
from govoplan_core.server.default_config import get_server_config

View File

@@ -0,0 +1,119 @@
"""add reusable core credential envelopes
Revision ID: c91f0a72be34
Revises: 4f2a9c8e7b6d
Create Date: 2026-07-23 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c91f0a72be34"
down_revision = "4f2a9c8e7b6d"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "core_credential_envelopes" in inspector.get_table_names():
return
op.create_table(
"core_credential_envelopes",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("scope_type", sa.String(length=20), nullable=False),
sa.Column("scope_id", sa.String(length=255), nullable=True),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("credential_kind", sa.String(length=40), nullable=False),
sa.Column("public_data", sa.JSON(), nullable=False),
sa.Column("secret_data_encrypted", sa.Text(), nullable=True),
sa.Column("secret_keys", sa.JSON(), nullable=False),
sa.Column("allowed_modules", sa.JSON(), nullable=False),
sa.Column("allowed_server_refs", sa.JSON(), nullable=False),
sa.Column("inherit_to_lower_scopes", sa.Boolean(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("revision", sa.String(length=36), nullable=False),
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
sa.Column("updated_by_user_id", sa.String(length=36), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["tenant_id"],
["core_scopes.id"],
name=op.f("fk_core_credential_envelopes_tenant_id_core_scopes"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_credential_envelopes")),
)
op.create_index(
"ix_core_credential_envelopes_scope",
"core_credential_envelopes",
["tenant_id", "scope_type", "scope_id"],
unique=False,
)
op.create_index(
"ix_core_credential_envelopes_active",
"core_credential_envelopes",
["tenant_id", "is_active", "deleted_at"],
unique=False,
)
op.create_index(
op.f("ix_core_credential_envelopes_tenant_id"),
"core_credential_envelopes",
["tenant_id"],
unique=False,
)
op.create_index(
op.f("ix_core_credential_envelopes_scope_type"),
"core_credential_envelopes",
["scope_type"],
unique=False,
)
op.create_index(
op.f("ix_core_credential_envelopes_scope_id"),
"core_credential_envelopes",
["scope_id"],
unique=False,
)
op.create_index(
op.f("ix_core_credential_envelopes_credential_kind"),
"core_credential_envelopes",
["credential_kind"],
unique=False,
)
op.create_index(
op.f("ix_core_credential_envelopes_is_active"),
"core_credential_envelopes",
["is_active"],
unique=False,
)
op.create_index(
op.f("ix_core_credential_envelopes_created_by_user_id"),
"core_credential_envelopes",
["created_by_user_id"],
unique=False,
)
op.create_index(
op.f("ix_core_credential_envelopes_updated_by_user_id"),
"core_credential_envelopes",
["updated_by_user_id"],
unique=False,
)
op.create_index(
op.f("ix_core_credential_envelopes_deleted_at"),
"core_credential_envelopes",
["deleted_at"],
unique=False,
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "core_credential_envelopes" in inspector.get_table_names():
op.drop_table("core_credential_envelopes")

View File

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

View File

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

View File

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

View File

@@ -157,13 +157,15 @@ release evidence.
| --- | --- | --- |
| `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. |
| `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. |
| `CELERY_QUEUES` | `send_email,append_sent,notifications,calendar,default` | Queue list expected by worker/process manager definitions. The Calendar queue drains durable external-calendar operations. |
| `CELERY_QUEUES` | `send_email,append_sent,notifications,calendar,dataflow,events,default` | Queue list expected by worker/process manager definitions. The `events` queue drains transactional platform events; `dataflow` drains trigger deliveries and schedules. |
| `PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS` | `8` | Failed durable consumer deliveries are quarantined after this many attempts. |
| `PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS` | `90` | Successful event envelopes older than this are removed by the daily retention task. Quarantined evidence is retained. |
Worker command:
```bash
python -m celery -A govoplan_core.celery_app:celery worker \
--queues send_email,append_sent,notifications,calendar,default \
--queues send_email,append_sent,notifications,calendar,dataflow,events,default \
--loglevel INFO
```
@@ -209,9 +211,13 @@ prefer `FILE_STORAGE_*`.
Interactive password login is enabled with fixed-window limits of 10 failures
per normalized identity and 100 failures per direct client over 900 seconds.
`AUTH_LOGIN_THROTTLE_*` settings change those limits. Counters use `REDIS_URL`
when Redis is reachable so replicas share state; a bounded process-local
fallback keeps development and Redis outages functional, with per-process
enforcement until Redis recovers.
when Redis is reachable so replicas share state. Production-like startup fails
when throttling is enabled without `REDIS_URL`. Set
`GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE=true` only as an explicit
single-process risk acceptance. A bounded process-local fallback keeps
development and temporary Redis outages functional, with per-process
enforcement until Redis recovers; monitor Redis because protection is weaker
during that fallback.
### Outbound Connector Egress

View File

@@ -9,6 +9,7 @@ operator, and roadmap pages.
| Topic | Canonical document | Notes |
| --- | --- | --- |
| Module architecture and kernel contracts | `MODULE_ARCHITECTURE.md` | Stable module contracts, API efficiency contracts, durable boundary decisions, lifecycle, and WebUI contribution rules. |
| Compatibility policy | `COMPATIBILITY_POLICY.md` | Supported database upgrade origins, portable-schema read/write windows, runtime/API alias retirement, and compatibility-code removal criteria. |
| RBAC and resource access | `ACCESS_RBAC_MODEL.md` | Current permission, role, API-key, and resource-access model. |
| Governance hierarchy | `GOVERNANCE_MODEL.md` | System, tenant, user/group, campaign policy inheritance and admin UI structure. |
| Policy decision DTOs and provenance | `POLICY_CONTRACTS.md` | Shared explain/provenance shape; module-specific policy docs should link here. |

View File

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

View File

@@ -0,0 +1,47 @@
# External References And Integration Maturity
GovOPlaN integrations use a shared external-reference contract instead of
storing connector-specific URLs and identifiers in every module.
An external reference identifies an object by:
- external system instance
- object type
- stable external object ID
- optional connector configuration
- canonical HTTP(S) URL without embedded credentials
- optional source version, ETag, observation time, and non-secret metadata
The identity key is `system:object_type:object_id`. A GovOPlaN object may retain
multiple references, but one reference must never silently change its identity.
Moving or escalating work creates a new object and an explicit relationship; it
does not rewrite either object's history.
## Integration Maturity
Maturity is cumulative:
1. `discover`: identify configured external systems and their health.
2. `link`: retain and open stable external references.
3. `search`: include authorized external objects in GovOPlaN search.
4. `read`: display authoritative external content.
5. `publish`: create or update external content from GovOPlaN.
6. `synchronize`: reconcile changes in both directions with conflict handling.
7. `migrate`: perform a governed, verifiable transfer into GovOPlaN.
8. `replace`: provide the native operational capability without the external tool.
Connectors must declare and document the maturity they actually implement.
`synchronize` requires durable cursors, idempotency, provenance, conflict
handling, deletion semantics, and observable failures. A link-only connector
must not imply that GovOPlaN holds an authoritative copy.
## Domain Ownership
- Domain modules own native GovOPlaN objects and their authorization.
- Connectors own protocols, credentials, discovery, transport, and sync state.
- Search owns indexing and result aggregation, but source modules remain
responsible for authorization.
- Core owns only the stable DTOs and extension contracts.
The Python contract is
`govoplan_core.core.external_references.ExternalObjectReference`.

View File

@@ -10,12 +10,12 @@ gates. Issues are the active backlog; this document is durable architecture
planning context and should be mirrored to the Gitea wiki.
The meta repository's
[Connected Governance Platform Roadmap](https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
[Connected Governance Platform Roadmap](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
describes the corresponding cross-product stakeholder visions, configurable
service and operating configurations, connected outcome stories, and
capability horizons. The selected five-stage delivery sequence and its gates
are in the meta repository's
[Reference Journey Program](https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/REFERENCE_JOURNEY_PROGRAM.md).
[Reference Journey Program](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/REFERENCE_JOURNEY_PROGRAM.md).
Those product documents are canonical; this Core roadmap remains their
technical sequencing and module-routing companion.
@@ -412,7 +412,7 @@ Goal: cover internal support and public issue reporting.
Create or refine in this order:
1. `govoplan-issue-reporting`: public/internal reports, categories, intake,
1. `govoplan-tickets`: public/internal reports, requests, incidents, queues,
location, evidence, and triage.
2. `govoplan-helpdesk`: service desk tickets, queues, SLAs, assignments,
escalation, and resolution evidence.
@@ -625,48 +625,48 @@ repositories or to explicit missing-module decisions.
| Idea | Owner | Tracking |
| --- | --- | --- |
| Government operations backbone reference model | `govoplan-core` | `add-ideas/govoplan-core#213` |
| Permit-to-payment configuration package | `govoplan-core` plus participating modules | `add-ideas/govoplan-core#214` |
| Fully UI-managed configuration with safety controls | `govoplan-admin`, `govoplan-core`, `govoplan-policy`, `govoplan-access`, `govoplan-audit` | `add-ideas/govoplan-core#218` |
| Access as a module | `govoplan-access` | `add-ideas/govoplan-access#7` |
| Interface ethics and decision-consequence doctrine | `govoplan-core` plus all UI-owning modules | `add-ideas/govoplan-core#227` |
| Action/effect automation layer | first `govoplan-workflow`; possible future `govoplan-automation` | `add-ideas/govoplan-workflow#1` |
| E2EE role/function postbox architecture | `govoplan-postbox`, `govoplan-identity-trust`, `govoplan-access`, `govoplan-policy`, `govoplan-audit` | `add-ideas/govoplan-postbox#15`, `add-ideas/govoplan-identity-trust#1` |
| Identity, account, function, role, right semantic model | `govoplan-access` | `add-ideas/govoplan-access#9` |
| Role-based service directory/catalog | `govoplan-portal` | `add-ideas/govoplan-portal#1` |
| Unified inbox across tasks, postbox, notifications, and portal | `govoplan-core` coordination plus owning modules | `add-ideas/govoplan-tasks#1`, `add-ideas/govoplan-notifications#1` |
| OpenProject API / project management connector | `govoplan-connectors` | `add-ideas/govoplan-connectors#1` |
| Native project-management module decision | connector-first through `govoplan-connectors`; no native project module yet | `add-ideas/govoplan-core#196`, `add-ideas/govoplan-connectors#1` |
| Datasources for databases, CSV, files, APIs | no repository yet; start with connectors/files/reporting and create `govoplan-datasources` only after the first package proves shared source-catalog ownership | `add-ideas/govoplan-core#197` |
| Dataflow for pipelines, BI, publication | no repository yet; start with workflow/reporting/connectors and create `govoplan-dataflow` only after repeated pipeline/lineage contracts emerge | `add-ideas/govoplan-core#198` |
| Monthly datasource and transformation workflows | first as configuration package across connectors, files, workflow, reporting, and templates | `add-ideas/govoplan-core#216` |
| Templates for letters, emails, forms, reports | `govoplan-templates`, separate from reporting | `add-ideas/govoplan-core#190`, `add-ideas/govoplan-templates#1` |
| Reporting and BI | `govoplan-reporting`, separate from templates | `add-ideas/govoplan-core#190`, `add-ideas/govoplan-reporting#1` |
| File connectors: Nextcloud, Seafile, SMB, NFS | `govoplan-files` | `add-ideas/govoplan-files#15` |
| Public-sector software integration catalogue | `govoplan-connectors` with core strategy index | `add-ideas/govoplan-core#191`, `add-ideas/govoplan-connectors#2` |
| Public-sector integration landscape catalogue | `govoplan-connectors` with core tracking | `add-ideas/govoplan-core#215` |
| Cases module concept | `govoplan-cases` | `add-ideas/govoplan-core#174` |
| Workflow module concept | `govoplan-workflow` | `add-ideas/govoplan-core#175` |
| Connectors module concept | `govoplan-connectors` | `add-ideas/govoplan-core#176` |
| Adrema-style address and distribution-list management | `govoplan-addresses` | `add-ideas/govoplan-addresses#1` |
| Consume sources and become a governed source | `govoplan-connectors` plus possible future `govoplan-dataflow` | `add-ideas/govoplan-connectors#3`, `add-ideas/govoplan-core#198` |
| Governed connector configuration, dry-run, and simulation runtime | `govoplan-connectors` | `add-ideas/govoplan-connectors#6` |
| Terminfindung and meeting scheduling polls | `govoplan-scheduling`; calendar primitives remain in calendar | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-scheduling#1` |
| Terminplaner and calendar primitives | `govoplan-calendar` | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-calendar#1` |
| Terminbuchung appointment booking | `govoplan-appointments` | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-appointments#1` |
| Collaborative documents | `govoplan-dms` | `add-ideas/govoplan-dms#1` |
| Forms | `govoplan-forms` for definitions and `govoplan-forms-runtime` for submissions/runtime behavior | `add-ideas/govoplan-core#194`, `add-ideas/govoplan-forms#1` |
| RSS consume and emit | `govoplan-connectors` | `add-ideas/govoplan-connectors#4` |
| LDAP, Active Directory, OpenDesk identity | `govoplan-idm` | `add-ideas/govoplan-idm#1` |
| OpenDesk stack integration map | integration profile across IDM/access, mail/calendar, files/DMS, and connectors; not a monolithic module | `add-ideas/govoplan-core#195`, `add-ideas/govoplan-connectors#5` |
| Open-Xchange mail/groupware | `govoplan-mail` | `add-ideas/govoplan-mail#5` |
| Open-Xchange calendar | `govoplan-calendar` | `add-ideas/govoplan-calendar#2` |
| Scalability profiles and autoscaling readiness | `govoplan-ops`, `govoplan-core` | `add-ideas/govoplan-core#217` |
| Hardware sizing matrix and requirements calculator | `govoplan-ops`, `govoplan-core` | `add-ideas/govoplan-core#219` |
| Collaboration suite integration strategy | `govoplan-connectors`, `govoplan-dms`, `govoplan-workflow`, `govoplan-tasks`, `govoplan-appointments`, `govoplan-calendar` | `add-ideas/govoplan-core#220` |
| Install/runtime configuration contract | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#19` |
| Installer/deployment operator flow | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#26` |
| Production-like deployment documentation | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#28` |
| Government operations backbone reference model | `govoplan-core` | `GovOPlaN/govoplan-core#213` |
| Permit-to-payment configuration package | `govoplan-core` plus participating modules | `GovOPlaN/govoplan-core#214` |
| Fully UI-managed configuration with safety controls | `govoplan-admin`, `govoplan-core`, `govoplan-policy`, `govoplan-access`, `govoplan-audit` | `GovOPlaN/govoplan-core#218` |
| Access as a module | `govoplan-access` | `GovOPlaN/govoplan-access#7` |
| Interface ethics and decision-consequence doctrine | `govoplan-core` plus all UI-owning modules | `GovOPlaN/govoplan-core#227` |
| Action/effect automation layer | first `govoplan-workflow`; possible future `govoplan-automation` | `GovOPlaN/govoplan-workflow#1` |
| E2EE role/function postbox architecture | `govoplan-postbox`, `govoplan-identity-trust`, `govoplan-access`, `govoplan-policy`, `govoplan-audit` | `GovOPlaN/govoplan-postbox#15`, `GovOPlaN/govoplan-identity-trust#1` |
| Identity, account, function, role, right semantic model | `govoplan-access` | `GovOPlaN/govoplan-access#9` |
| Role-based service directory/catalog | `govoplan-portal` | `GovOPlaN/govoplan-portal#1` |
| Unified inbox across tasks, postbox, notifications, and portal | `govoplan-core` coordination plus owning modules | `GovOPlaN/govoplan-tasks#1`, `GovOPlaN/govoplan-notifications#1` |
| OpenProject API / project management connector | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#1` |
| Native project-management module decision | connector-first through `govoplan-connectors`; no native project module yet | `GovOPlaN/govoplan-core#196`, `GovOPlaN/govoplan-connectors#1` |
| Datasources for databases, CSV, files, APIs | no repository yet; start with connectors/files/reporting and create `govoplan-datasources` only after the first package proves shared source-catalog ownership | `GovOPlaN/govoplan-core#197` |
| Dataflow for pipelines, BI, publication | no repository yet; start with workflow/reporting/connectors and create `govoplan-dataflow` only after repeated pipeline/lineage contracts emerge | `GovOPlaN/govoplan-core#198` |
| Monthly datasource and transformation workflows | first as configuration package across connectors, files, workflow, reporting, and templates | `GovOPlaN/govoplan-core#216` |
| Templates for letters, emails, forms, reports | `govoplan-templates`, separate from reporting | `GovOPlaN/govoplan-core#190`, `GovOPlaN/govoplan-templates#1` |
| Reporting and BI | `govoplan-reporting`, separate from templates | `GovOPlaN/govoplan-core#190`, `GovOPlaN/govoplan-reporting#1` |
| File connectors: Nextcloud, Seafile, SMB, NFS | `govoplan-files` | `GovOPlaN/govoplan-files#15` |
| Public-sector software integration catalogue | `govoplan-connectors` with core strategy index | `GovOPlaN/govoplan-core#191`, `GovOPlaN/govoplan-connectors#2` |
| Public-sector integration landscape catalogue | `govoplan-connectors` with core tracking | `GovOPlaN/govoplan-core#215` |
| Cases module concept | `govoplan-cases` | `GovOPlaN/govoplan-core#174` |
| Workflow module concept | `govoplan-workflow` | `GovOPlaN/govoplan-core#175` |
| Connectors module concept | `govoplan-connectors` | `GovOPlaN/govoplan-core#176` |
| Adrema-style address and distribution-list management | `govoplan-addresses` | `GovOPlaN/govoplan-addresses#1` |
| Consume sources and become a governed source | `govoplan-connectors` plus possible future `govoplan-dataflow` | `GovOPlaN/govoplan-connectors#3`, `GovOPlaN/govoplan-core#198` |
| Governed connector configuration, dry-run, and simulation runtime | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#6` |
| Terminfindung and meeting scheduling polls | `govoplan-scheduling`; calendar primitives remain in calendar | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-scheduling#1` |
| Terminplaner and calendar primitives | `govoplan-calendar` | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-calendar#1` |
| Terminbuchung appointment booking | `govoplan-appointments` | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-appointments#1` |
| Collaborative documents | `govoplan-dms` | `GovOPlaN/govoplan-dms#1` |
| Forms | `govoplan-forms` for definitions and `govoplan-forms-runtime` for submissions/runtime behavior | `GovOPlaN/govoplan-core#194`, `GovOPlaN/govoplan-forms#1` |
| RSS consume and emit | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#4` |
| LDAP, Active Directory, OpenDesk identity | `govoplan-idm` | `GovOPlaN/govoplan-idm#1` |
| OpenDesk stack integration map | integration profile across IDM/access, mail/calendar, files/DMS, and connectors; not a monolithic module | `GovOPlaN/govoplan-core#195`, `GovOPlaN/govoplan-connectors#5` |
| Open-Xchange mail/groupware | `govoplan-mail` | `GovOPlaN/govoplan-mail#5` |
| Open-Xchange calendar | `govoplan-calendar` | `GovOPlaN/govoplan-calendar#2` |
| Scalability profiles and autoscaling readiness | `govoplan-ops`, `govoplan-core` | `GovOPlaN/govoplan-core#217` |
| Hardware sizing matrix and requirements calculator | `govoplan-ops`, `govoplan-core` | `GovOPlaN/govoplan-core#219` |
| Collaboration suite integration strategy | `govoplan-connectors`, `govoplan-dms`, `govoplan-workflow`, `govoplan-tasks`, `govoplan-appointments`, `govoplan-calendar` | `GovOPlaN/govoplan-core#220` |
| Install/runtime configuration contract | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#19` |
| Installer/deployment operator flow | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#26` |
| Production-like deployment documentation | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#28` |
Boundary rationale lives in `MODULE_ARCHITECTURE.md`. Current decisions:
@@ -705,7 +705,7 @@ Release composition and tag-only repository handling are documented in
## Next Practical Work
The active cross-product story is
[`add-ideas/govoplan#14`](https://git.add-ideas.de/add-ideas/govoplan/issues/14).
[`GovOPlaN/govoplan#14`](https://git.add-ideas.de/GovOPlaN/govoplan/issues/14).
Module repositories own implementation issues; do not clone their state here.
Immediate issue buckets:

View File

@@ -74,6 +74,10 @@ The compatibility/deprecation plan for the current split line is:
- reject new cross-module imports that bypass manifests, capabilities, events,
or public module APIs
The retention windows and removal checklist for database bridges,
configuration/export schemas, and runtime/API aliases are defined in
`COMPATIBILITY_POLICY.md`.
## Stable Kernel Contracts
The following contracts are the baseline API that modules can rely on:

View File

@@ -13,6 +13,7 @@ consistent while each module still owns its domain rules.
| RBAC/access policy | `govoplan-access` | access capabilities in `govoplan_core.core.access` | Permission decisions should use access capability contracts. Explain responses should adopt `PolicyDecision` when an API-level explanation is added. |
| Governance defaults | `govoplan-admin` plus `govoplan-access` materializer | admin settings, governance template routes, access materialization capability | System governance can block tenant-local groups, roles, and API keys. |
| Delegation and ownership policy | access/campaign/mail/files modules | capability checks and owner-scoped APIs | Source provenance should use this contract when policies become externally explainable. |
| Definition governance | `govoplan-policy` | capability `policy.definitionGovernance` | Resolves view, edit, run/start, reuse, derive, and automate for system, tenant, group, and user Dataflow/Workflow definitions. |
## Policy Decision
@@ -111,6 +112,20 @@ matching compatibility DTOs only for its legacy admin surface. Admin overview
responses remain module-local because the same counters are exposed from
different menu contexts and are not yet a separately versioned platform API.
## Definition Governance
Dataflow and Workflow submit a `DefinitionGovernanceRequest` using only stable
scope, principal, status, definition-kind, and limit fields. Policy returns a
standard `PolicyDecision`. System definitions may be inherited as read-only;
group and user definitions are visible only in matching contexts. Templates
may be viewed and derived but never run or automated. A derived definition
passes its pinned ancestor limits back through the request context, and Policy
applies those limits as ceilings rather than defaults that can be broadened.
When the capability is absent, modules must not silently emulate cross-scope
inheritance. Their conservative fallback is limited to local tenant
definitions and disables reuse, derivation, and automation.
## Frontend Contract
Policy UIs must:

View File

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

View File

@@ -40,26 +40,26 @@ cd /mnt/DATA/git/govoplan
Update those refs when cutting a release:
```text
govoplan-tenancy git@git.add-ideas.de:add-ideas/govoplan-tenancy.git v0.1.8
govoplan-organizations git@git.add-ideas.de:add-ideas/govoplan-organizations.git v0.1.8
govoplan-identity git@git.add-ideas.de:add-ideas/govoplan-identity.git v0.1.8
govoplan-idm git@git.add-ideas.de:add-ideas/govoplan-idm.git v0.1.8
govoplan-access git@git.add-ideas.de:add-ideas/govoplan-access.git v0.1.8
govoplan-admin git@git.add-ideas.de:add-ideas/govoplan-admin.git v0.1.8
govoplan-policy git@git.add-ideas.de:add-ideas/govoplan-policy.git v0.1.8
govoplan-audit git@git.add-ideas.de:add-ideas/govoplan-audit.git v0.1.8
govoplan-dashboard git@git.add-ideas.de:add-ideas/govoplan-dashboard.git v0.1.8
govoplan-addresses git@git.add-ideas.de:add-ideas/govoplan-addresses.git v0.1.8
govoplan-files git@git.add-ideas.de:add-ideas/govoplan-files.git v0.1.8
govoplan-mail git@git.add-ideas.de:add-ideas/govoplan-mail.git v0.1.8
govoplan-campaign git@git.add-ideas.de:add-ideas/govoplan-campaign.git v0.1.8
govoplan-calendar git@git.add-ideas.de:add-ideas/govoplan-calendar.git v0.1.8
govoplan-poll git@git.add-ideas.de:add-ideas/govoplan-poll.git v0.1.8
govoplan-scheduling git@git.add-ideas.de:add-ideas/govoplan-scheduling.git v0.1.8
govoplan-notifications git@git.add-ideas.de:add-ideas/govoplan-notifications.git v0.1.8
govoplan-evaluation git@git.add-ideas.de:add-ideas/govoplan-evaluation.git v0.1.8
govoplan-docs git@git.add-ideas.de:add-ideas/govoplan-docs.git v0.1.8
govoplan-ops git@git.add-ideas.de:add-ideas/govoplan-ops.git v0.1.8
govoplan-tenancy git@git.add-ideas.de:GovOPlaN/govoplan-tenancy.git v0.1.8
govoplan-organizations git@git.add-ideas.de:GovOPlaN/govoplan-organizations.git v0.1.8
govoplan-identity git@git.add-ideas.de:GovOPlaN/govoplan-identity.git v0.1.8
govoplan-idm git@git.add-ideas.de:GovOPlaN/govoplan-idm.git v0.1.8
govoplan-access git@git.add-ideas.de:GovOPlaN/govoplan-access.git v0.1.8
govoplan-admin git@git.add-ideas.de:GovOPlaN/govoplan-admin.git v0.1.8
govoplan-policy git@git.add-ideas.de:GovOPlaN/govoplan-policy.git v0.1.8
govoplan-audit git@git.add-ideas.de:GovOPlaN/govoplan-audit.git v0.1.8
govoplan-dashboard git@git.add-ideas.de:GovOPlaN/govoplan-dashboard.git v0.1.8
govoplan-addresses git@git.add-ideas.de:GovOPlaN/govoplan-addresses.git v0.1.8
govoplan-files git@git.add-ideas.de:GovOPlaN/govoplan-files.git v0.1.8
govoplan-mail git@git.add-ideas.de:GovOPlaN/govoplan-mail.git v0.1.8
govoplan-campaign git@git.add-ideas.de:GovOPlaN/govoplan-campaign.git v0.1.8
govoplan-calendar git@git.add-ideas.de:GovOPlaN/govoplan-calendar.git v0.1.8
govoplan-poll git@git.add-ideas.de:GovOPlaN/govoplan-poll.git v0.1.8
govoplan-scheduling git@git.add-ideas.de:GovOPlaN/govoplan-scheduling.git v0.1.8
govoplan-notifications git@git.add-ideas.de:GovOPlaN/govoplan-notifications.git v0.1.8
govoplan-evaluation git@git.add-ideas.de:GovOPlaN/govoplan-evaluation.git v0.1.8
govoplan-docs git@git.add-ideas.de:GovOPlaN/govoplan-docs.git v0.1.8
govoplan-ops git@git.add-ideas.de:GovOPlaN/govoplan-ops.git v0.1.8
```
## WebUI Packages
@@ -803,6 +803,12 @@ collecting release evidence.
## Migration Baselines
Release migration support follows `COMPATIBILITY_POLICY.md`: every tagged
`0.1.x` installation is a supported upgrade origin, released revision IDs are
immutable, and migration-only reconciliation remains available for at least one
subsequent major release cycle after the matching runtime compatibility path is
removed.
Development migrations may be small and numerous while a feature is moving.
GovOPlaN keeps those detailed migrations on an explicit development track and
publishes reviewed release shortcuts on the release track. Before a stable
@@ -892,7 +898,7 @@ before that baseline, so pre-v0.1.7 development revisions are not release
upgrade targets. Future release-to-release changes must start from a recorded
release baseline and add a new release-track step-up instead of replacing prior
release shortcuts. The tracking issue is
`add-ideas/govoplan-core#223`.
`GovOPlaN/govoplan-core#223`.
## Related Operator Documents

View File

@@ -3,10 +3,13 @@
Core exposes `govoplan_core.core.throttling` for security-sensitive endpoints
that need bounded fixed-window counters. Subjects are SHA-256 hashed before they
become store keys. A configured Redis instance provides atomic counters shared
across API workers; development, a missing Redis configuration, and temporary
Redis outages use a bounded process-local fallback. When Redis fails, local
attempts are still mirrored so losing the distributed store does not reset the
active worker's protection window.
across API workers. Development and temporary Redis outages use a bounded
process-local fallback. Production-like startup rejects an enabled login
throttle without `REDIS_URL` unless
`GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE=true` explicitly acknowledges the
single-process limitation. When Redis fails at runtime, local attempts are still
mirrored so losing the distributed store does not reset the active worker's
protection window.
Callers define one or more `ThrottleDimension` values with a controlled
namespace, a subject and a positive limit. They must call `check` before an

View File

@@ -6,7 +6,7 @@ binding design reference: future implementation should follow these decisions
unless the decision is explicitly revised here and affected screens are updated
to match.
Active tracking issue: `add-ideas/govoplan-core#225`.
Active tracking issue: `GovOPlaN/govoplan-core#225`.
## Operating Rule

View File

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

View File

@@ -539,6 +539,130 @@
"release": "0.1.13",
"squash_policy": "reviewed-manual",
"track": "release"
},
{
"heads": [
{
"owner": "govoplan-mail",
"revision": "608192abcdef"
},
{
"owner": "govoplan-poll",
"revision": "6e7f8a9b0c1d"
},
{
"owner": "govoplan-notifications",
"revision": "6f7a8b9c0d1e"
},
{
"owner": "govoplan-idm",
"revision": "8f9a0b1c2d3e"
},
{
"owner": "govoplan-files",
"revision": "a7b8c9d0e1f3"
},
{
"owner": "govoplan-calendar",
"revision": "af1b2c3d4e5f"
},
{
"owner": "govoplan-scheduling",
"revision": "c9d4e7f1a2b3"
},
{
"owner": "govoplan-campaign",
"revision": "d8b3e2c1f4a5"
},
{
"owner": "govoplan-addresses",
"revision": "e1f2a4b5c6d"
}
],
"owner_heads": [
{
"owner": "govoplan-access",
"revisions": [
"4a5b6c7d8e9f"
]
},
{
"owner": "govoplan-addresses",
"revisions": [
"e1f2a4b5c6d"
]
},
{
"owner": "govoplan-calendar",
"revisions": [
"af1b2c3d4e5f"
]
},
{
"owner": "govoplan-campaign",
"revisions": [
"d8b3e2c1f4a5"
]
},
{
"owner": "govoplan-core",
"revisions": [
"4f2a9c8e7b6d"
]
},
{
"owner": "govoplan-files",
"revisions": [
"a7b8c9d0e1f3"
]
},
{
"owner": "govoplan-identity",
"revisions": [
"5c6d7e8f9a10"
]
},
{
"owner": "govoplan-idm",
"revisions": [
"8f9a0b1c2d3e"
]
},
{
"owner": "govoplan-mail",
"revisions": [
"608192abcdef"
]
},
{
"owner": "govoplan-notifications",
"revisions": [
"6f7a8b9c0d1e"
]
},
{
"owner": "govoplan-organizations",
"revisions": [
"6d7e8f9a0b1c"
]
},
{
"owner": "govoplan-poll",
"revisions": [
"6e7f8a9b0c1d"
]
},
{
"owner": "govoplan-scheduling",
"revisions": [
"c9d4e7f1a2b3"
]
}
],
"recorded_at": "2026-07-22T18:15:01Z",
"release": "0.1.14",
"squash_policy": "reviewed-manual",
"track": "release"
}
],
"version": 1

View File

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

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-core"
version = "0.1.13"
version = "0.1.14"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md"
requires-python = ">=3.12"

View File

@@ -39,6 +39,22 @@ class DeltaCollectionResponse(BaseModel):
full: bool = False
class ReferenceOptionResponse(BaseModel):
value: str
label: str
description: str | None = None
kind: str | None = None
availability: Literal["available", "inactive", "unavailable"] = "available"
disabled: bool = False
source_module: str | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
class ReferenceOptionListResponse(BaseModel):
options: list[ReferenceOptionResponse] = Field(default_factory=list)
provider_available: bool = True
class LoginRequest(BaseModel):
model_config = ConfigDict(extra="forbid")

View File

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

View File

@@ -118,7 +118,7 @@ def _registry_from_request(request: Request) -> PlatformRegistry | None:
def _api_principal_provider_from_request(request: Request) -> ApiPrincipalProvider:
registry = _registry_from_request(request)
if registry is None or not registry.has_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Auth provider is not available")
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Auth provider is not available")
capability = registry.require_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER)
if not isinstance(capability, ApiPrincipalProvider):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid auth provider capability")

View File

@@ -1,9 +1,22 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from celery import Celery
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_OUTBOX, CalendarOutboxProvider
from govoplan_core.core.dataflows import (
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER,
DataflowTriggerDispatcher,
)
from govoplan_core.core.events import (
CAPABILITY_PLATFORM_EVENT_OUTBOX,
DurableEventConsumer,
PlatformEvent,
PlatformEventOutbox,
publish_platform_event,
)
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
@@ -29,6 +42,9 @@ celery.conf.update(
"govoplan.notifications.deliver": {"queue": "notifications"},
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
"govoplan.calendar.dispatch_outbox": {"queue": "calendar"},
"govoplan.dataflow.dispatch_triggers": {"queue": "dataflow"},
"govoplan.events.dispatch_outbox": {"queue": "events"},
"govoplan.events.purge_outbox": {"queue": "events"},
},
worker_prefetch_multiplier=1,
task_acks_late=True,
@@ -39,6 +55,21 @@ celery.conf.update(
"schedule": 60.0,
"args": (None, 100),
},
"dataflow-triggers-every-minute": {
"task": "govoplan.dataflow.dispatch_triggers",
"schedule": 60.0,
"args": (100,),
},
"platform-events-every-ten-seconds": {
"task": "govoplan.events.dispatch_outbox",
"schedule": 10.0,
"args": (100,),
},
"platform-event-retention-daily": {
"task": "govoplan.events.purge_outbox",
"schedule": 24 * 60 * 60.0,
"args": (500,),
},
},
)
@@ -86,6 +117,32 @@ def _calendar_outbox() -> CalendarOutboxProvider | None:
return capability
def _dataflow_trigger_dispatcher(
registry: PlatformRegistry | None = None,
) -> DataflowTriggerDispatcher | None:
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER):
return None
capability = registry.require_capability(
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER
)
if not isinstance(capability, DataflowTriggerDispatcher):
raise RuntimeError("Dataflow trigger dispatcher capability is invalid")
return capability
def _platform_event_outbox(
registry: PlatformRegistry | None = None,
) -> PlatformEventOutbox | None:
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX):
return None
capability = registry.require_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
if not isinstance(capability, PlatformEventOutbox):
raise RuntimeError("Platform event outbox capability is invalid")
return capability
@celery.task(name="govoplan.campaigns.send_email", bind=True, max_retries=0)
def send_email(self, job_id: str):
"""Send one explicitly queued campaign job.
@@ -149,3 +206,112 @@ def dispatch_calendar_outbox(self, tenant_id: str | None = None, limit: int = 50
result = dict(provider.dispatch_due(session, tenant_id=tenant_id, limit=limit))
session.commit()
return result
@celery.task(
name="govoplan.dataflow.dispatch_triggers",
bind=True,
max_retries=0,
)
def dispatch_dataflow_triggers(self, limit: int = 100):
"""Drain durable Dataflow trigger deliveries and due schedules."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
provider = _dataflow_trigger_dispatcher()
if provider is None:
return {
"queued": 0,
"processed": 0,
"succeeded": 0,
"failed": 0,
"blocked": 0,
"skipped": 0,
}
result = dict(provider.dispatch_due(session, limit=limit))
session.commit()
return result
@celery.task(
name="govoplan.events.dispatch_outbox",
bind=True,
max_retries=0,
)
def dispatch_platform_events(self, limit: int = 100):
"""Deliver committed platform events through persistent consumer ledgers."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
registry = _platform_registry()
outbox = _platform_event_outbox(registry)
if outbox is None:
return {
"selected": 0,
"delivered": 0,
"retrying": 0,
"quarantined": 0,
"dispatched": 0,
"observer_failed": 0,
}
dataflow_dispatcher = _dataflow_trigger_dispatcher(registry)
consumers = ()
if dataflow_dispatcher is not None:
def deliver_to_dataflow(
event: PlatformEvent,
_delivery_key: str,
) -> None:
dataflow_dispatcher.ingest_event(
session,
event=event,
)
consumers = (
DurableEventConsumer(
consumer_id="dataflow.event-triggers.v1",
event_types=frozenset({"*"}),
classifications=frozenset({"public", "internal"}),
handler=deliver_to_dataflow,
),
)
result = dict(
outbox.dispatch_pending(
session,
consumers=consumers,
observer=publish_platform_event,
limit=limit,
)
)
session.commit()
return result
@celery.task(
name="govoplan.events.purge_outbox",
bind=True,
max_retries=0,
)
def purge_platform_events(self, limit: int = 500):
"""Remove old terminal event envelopes while retaining quarantine evidence."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
outbox = _platform_event_outbox()
if outbox is None:
return {"deleted": 0}
before = datetime.now(timezone.utc) - timedelta(
days=settings.platform_event_outbox_terminal_retention_days
)
result = dict(
outbox.purge_terminal(
session,
before=before,
limit=limit,
)
)
session.commit()
return result

View File

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

View File

@@ -0,0 +1,183 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.access import (
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
)
AutomationInvocationKind = Literal[
"manual",
"api",
"schedule",
"event",
"workflow",
"dependency",
"retry",
"backfill",
]
AutomationSubjectKind = Literal["delegated_user", "service_account"]
AUTOMATION_PRINCIPAL_CONTRACT_VERSION = "1"
@dataclass(frozen=True, slots=True)
class AutomationInvocation:
kind: AutomationInvocationKind = "manual"
trigger_ref: str | None = None
delivery_ref: str | None = None
event_id: str | None = None
event_type: str | None = None
correlation_id: str | None = None
causation_id: str | None = None
scheduled_for: datetime | None = None
requested_by: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class AutomationPrincipalRequest:
tenant_id: str
authorization_ref: str
grant_scopes: tuple[str, ...]
account_id: str | None = None
membership_id: str | None = None
service_account_id: str | None = None
subject_kind: AutomationSubjectKind = "delegated_user"
context: Mapping[str, object] = field(default_factory=dict)
contract_version: str = AUTOMATION_PRINCIPAL_CONTRACT_VERSION
def __post_init__(self) -> None:
if (
self.contract_version
!= AUTOMATION_PRINCIPAL_CONTRACT_VERSION
):
raise ValueError(
"Unsupported automation-principal contract version"
)
if not self.tenant_id.strip():
raise ValueError("Automation tenant id is required")
if not self.authorization_ref.strip():
raise ValueError(
"Automation authorization artifact reference is required"
)
if self.subject_kind == "delegated_user":
if (
not self.account_id
or not self.membership_id
or self.service_account_id is not None
):
raise ValueError(
"Delegated-user automation requires account and "
"membership references only"
)
elif (
not self.service_account_id
or self.account_id is not None
or self.membership_id is not None
):
raise ValueError(
"Service-account automation requires only a service-account "
"reference"
)
if any(
not scope.strip()
for scope in self.grant_scopes
):
raise ValueError("Automation grant scopes must not be empty")
@classmethod
def delegated_user(
cls,
*,
tenant_id: str,
account_id: str,
membership_id: str,
authorization_ref: str,
grant_scopes: tuple[str, ...],
context: Mapping[str, object] | None = None,
) -> AutomationPrincipalRequest:
return cls(
tenant_id=tenant_id,
account_id=account_id,
membership_id=membership_id,
authorization_ref=authorization_ref,
grant_scopes=grant_scopes,
context=context or {},
)
@classmethod
def service_account(
cls,
*,
tenant_id: str,
service_account_id: str,
authorization_ref: str,
grant_scopes: tuple[str, ...],
context: Mapping[str, object] | None = None,
) -> AutomationPrincipalRequest:
return cls(
tenant_id=tenant_id,
service_account_id=service_account_id,
subject_kind="service_account",
authorization_ref=authorization_ref,
grant_scopes=grant_scopes,
context=context or {},
)
@dataclass(frozen=True, slots=True)
class AutomationPrincipalResolution:
allowed: bool
principal: object | None = None
reason: str | None = None
granted_scopes: tuple[str, ...] = ()
missing_scopes: tuple[str, ...] = ()
provenance: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class AutomationPrincipalProvider(Protocol):
def resolve_automation_principal(
self,
session: object,
*,
request: AutomationPrincipalRequest,
) -> AutomationPrincipalResolution:
...
def automation_principal_provider(
registry: object | None,
) -> AutomationPrincipalProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
)
):
return None
capability = registry.capability(
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
)
return (
capability
if isinstance(capability, AutomationPrincipalProvider)
else None
)
__all__ = [
"CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER",
"AUTOMATION_PRINCIPAL_CONTRACT_VERSION",
"AutomationInvocation",
"AutomationInvocationKind",
"AutomationPrincipalProvider",
"AutomationPrincipalRequest",
"AutomationPrincipalResolution",
"AutomationSubjectKind",
"automation_principal_provider",
]

View File

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

View File

@@ -0,0 +1,172 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
from govoplan_core.core.automation import AutomationInvocation
from govoplan_core.core.events import PlatformEvent
CAPABILITY_DATAFLOW_RUN_LIFECYCLE = "dataflow.runLifecycle"
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER = "dataflow.triggerDispatcher"
class DataflowRunError(ValueError):
"""Stable base error for module-neutral Dataflow run operations."""
class DataflowRunNotFoundError(DataflowRunError):
pass
class DataflowRunConflictError(DataflowRunError):
pass
class DataflowRunUnavailableError(DataflowRunError):
pass
@dataclass(frozen=True, slots=True)
class DataflowPublicationTarget:
target_datasource_ref: str | None = None
name: str | None = None
source_name: str | None = None
description: str | None = None
freeze: bool = False
frozen_label: str | None = None
set_current: bool = True
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DataflowRunRequest:
pipeline_ref: str
revision: int
idempotency_key: str
row_limit: int = 500
publication: DataflowPublicationTarget | None = None
invocation: AutomationInvocation = field(
default_factory=AutomationInvocation
)
@dataclass(frozen=True, slots=True)
class DataflowRunDescriptor:
ref: str
pipeline_ref: str
revision: int
status: str
definition_hash: str
executor_version: str
input_row_count: int = 0
output_row_count: int = 0
output_publication_ref: str | None = None
output_datasource_ref: str | None = None
output_materialization_ref: str | None = None
invocation_kind: str = "manual"
trigger_ref: str | None = None
delivery_ref: str | None = None
error: str | None = None
started_at: datetime | None = None
finished_at: datetime | None = None
replayed: bool = False
metadata: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class DataflowRunLifecycleProvider(Protocol):
def start_run(
self,
session: object,
principal: object,
*,
request: DataflowRunRequest,
) -> DataflowRunDescriptor:
...
def get_run(
self,
session: object,
principal: object,
*,
run_ref: str,
) -> DataflowRunDescriptor | None:
...
def cancel_run(
self,
session: object,
principal: object,
*,
run_ref: str,
) -> DataflowRunDescriptor:
...
@runtime_checkable
class DataflowTriggerDispatcher(Protocol):
def dispatch_due(
self,
session: object,
*,
now: datetime | None = None,
limit: int = 50,
) -> Mapping[str, object]:
...
def ingest_event(
self,
session: object,
*,
event: PlatformEvent,
) -> Mapping[str, object]:
...
def dataflow_run_lifecycle(
registry: object | None,
) -> DataflowRunLifecycleProvider | None:
capability = _capability(registry, CAPABILITY_DATAFLOW_RUN_LIFECYCLE)
return capability if isinstance(capability, DataflowRunLifecycleProvider) else None
def dataflow_trigger_dispatcher(
registry: object | None,
) -> DataflowTriggerDispatcher | None:
capability = _capability(registry, CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER)
return (
capability
if isinstance(capability, DataflowTriggerDispatcher)
else None
)
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
__all__ = [
"CAPABILITY_DATAFLOW_RUN_LIFECYCLE",
"CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER",
"DataflowPublicationTarget",
"DataflowRunConflictError",
"DataflowRunDescriptor",
"DataflowRunError",
"DataflowRunLifecycleProvider",
"DataflowRunNotFoundError",
"DataflowRunRequest",
"DataflowRunUnavailableError",
"DataflowTriggerDispatcher",
"dataflow_run_lifecycle",
"dataflow_trigger_dispatcher",
]

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,137 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal
from urllib.parse import urlsplit
IntegrationMaturity = Literal[
"discover",
"link",
"search",
"read",
"publish",
"synchronize",
"migrate",
"replace",
]
INTEGRATION_MATURITY_ORDER: tuple[IntegrationMaturity, ...] = (
"discover",
"link",
"search",
"read",
"publish",
"synchronize",
"migrate",
"replace",
)
class ExternalReferenceValidationError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class ExternalObjectReference:
"""Stable identity and provenance for an object owned by another system."""
system: str
object_type: str
object_id: str
maturity: IntegrationMaturity = "link"
connector_id: str | None = None
canonical_url: str | None = None
version: str | None = None
etag: str | None = None
observed_at: datetime | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
for field_name in ("system", "object_type", "object_id"):
value = str(getattr(self, field_name) or "").strip()
if not value:
raise ExternalReferenceValidationError(
f"External reference {field_name} is required."
)
if len(value) > 255:
raise ExternalReferenceValidationError(
f"External reference {field_name} is limited to 255 characters."
)
object.__setattr__(self, field_name, value)
if self.maturity not in INTEGRATION_MATURITY_ORDER:
raise ExternalReferenceValidationError(
f"Unsupported integration maturity: {self.maturity!r}."
)
if self.connector_id is not None:
connector_id = self.connector_id.strip()
if not connector_id:
raise ExternalReferenceValidationError(
"External reference connector_id cannot be blank."
)
object.__setattr__(self, "connector_id", connector_id)
if self.canonical_url is not None:
object.__setattr__(
self,
"canonical_url",
_validated_reference_url(self.canonical_url),
)
@property
def identity_key(self) -> str:
return f"{self.system}:{self.object_type}:{self.object_id}"
def supports(self, maturity: IntegrationMaturity) -> bool:
return integration_maturity_rank(self.maturity) >= integration_maturity_rank(
maturity
)
def to_dict(self) -> dict[str, object]:
return {
"system": self.system,
"object_type": self.object_type,
"object_id": self.object_id,
"maturity": self.maturity,
"connector_id": self.connector_id,
"canonical_url": self.canonical_url,
"version": self.version,
"etag": self.etag,
"observed_at": (
self.observed_at.isoformat() if self.observed_at is not None else None
),
"metadata": dict(self.metadata),
}
def integration_maturity_rank(maturity: IntegrationMaturity) -> int:
try:
return INTEGRATION_MATURITY_ORDER.index(maturity)
except ValueError as exc:
raise ExternalReferenceValidationError(
f"Unsupported integration maturity: {maturity!r}."
) from exc
def _validated_reference_url(value: str) -> str:
normalized = value.strip()
parsed = urlsplit(normalized)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ExternalReferenceValidationError(
"External reference URLs must use HTTP or HTTPS."
)
if parsed.username is not None or parsed.password is not None:
raise ExternalReferenceValidationError(
"External reference URLs must not contain credentials."
)
return normalized
__all__ = [
"ExternalObjectReference",
"ExternalReferenceValidationError",
"INTEGRATION_MATURITY_ORDER",
"IntegrationMaturity",
"integration_maturity_rank",
]

View File

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

View File

@@ -338,8 +338,10 @@ ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,aud
CELERY_ENABLED=true
REDIS_URL=redis://127.0.0.1:6379/0
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
CELERY_QUEUES=send_email,append_sent,notifications,calendar,dataflow,events,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
# Deployment-wide connector egress policy. Enable private networks only when
@@ -404,8 +406,10 @@ DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
REDIS_URL=redis://127.0.0.1:56379/0
CELERY_ENABLED=true
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
CELERY_QUEUES=send_email,append_sent,notifications,calendar,dataflow,events,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true

View File

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

View File

@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from govoplan_core.admin.models import SystemSettings
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID, get_system_settings
from govoplan_core.core.access import DEFAULT_CAPABILITY_PROVIDERS
from govoplan_core.core.discovery import iter_module_entry_points
from govoplan_core.core.modules import ModuleManifest
from govoplan_core.db.session import get_database
@@ -17,8 +18,8 @@ from govoplan_core.server.registry import parse_enabled_modules
MODULE_SETTINGS_KEY = "module_management"
INSTALL_PLAN_KEY = "install_plan"
REQUIRED_PLATFORM_MODULES = ("access",)
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin")
REQUIRED_PLATFORM_MODULES: tuple[str, ...] = ()
PROTECTED_MODULES = ("admin",)
INSTALL_PLAN_ACTIONS = ("install", "update", "uninstall")
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
INSTALL_PLAN_SOURCES = ("manual", "catalog")
@@ -109,6 +110,7 @@ def startup_candidate_module_ids(
if desired is not None:
candidates.extend(str(item).strip() for item in desired if str(item).strip())
candidates.extend(REQUIRED_PLATFORM_MODULES)
candidates.extend(DEFAULT_CAPABILITY_PROVIDERS.values())
if "admin" in fallback:
candidates.append("admin")
return tuple(dict.fromkeys(candidates))
@@ -404,46 +406,148 @@ def plan_desired_enabled_modules(
*,
protected_modules: Iterable[str] = PROTECTED_MODULES,
) -> ModuleStatePlan:
requested = {str(item).strip() for item in requested_enabled if str(item).strip()}
protected = {str(item).strip() for item in protected_modules if str(item).strip()}
requested.update(protected)
requested = _normalized_module_ids(requested_enabled)
requested.update(_normalized_module_ids(protected_modules))
missing = sorted(module_id for module_id in requested if module_id not in available)
if missing:
raise ModuleManagementError("Unknown or uninstalled modules: " + ", ".join(missing))
added_dependencies: set[str] = set()
visiting: set[str] = set()
visited: set[str] = set()
ordered: list[str] = []
def visit(module_id: str) -> None:
if module_id in visited:
return
if module_id in visiting:
raise ModuleManagementError(f"Module dependency cycle includes {module_id!r}.")
manifest = available.get(module_id)
if manifest is None:
raise ModuleManagementError(f"Module {module_id!r} is required but not installed.")
visiting.add(module_id)
for dependency_id in manifest.dependencies:
if dependency_id not in available:
raise ModuleManagementError(f"Module {module_id!r} depends on uninstalled module {dependency_id!r}.")
if dependency_id not in requested:
added_dependencies.add(dependency_id)
requested.add(dependency_id)
visit(dependency_id)
visiting.remove(module_id)
visited.add(module_id)
ordered.append(module_id)
for module_id in sorted(requested):
visit(module_id)
added_dependencies = _expand_enabled_module_closure(requested, available)
ordered = _order_enabled_modules(requested, available, added_dependencies)
return ModuleStatePlan(
enabled_modules=tuple(dict.fromkeys(ordered)),
added_dependencies=tuple(sorted(added_dependencies)),
)
def _normalized_module_ids(values: Iterable[str]) -> set[str]:
return {str(item).strip() for item in values if str(item).strip()}
def _expand_enabled_module_closure(
requested: set[str],
available: Mapping[str, ModuleManifest],
) -> set[str]:
added: set[str] = set()
while True:
dependencies = {
dependency
for module_id in requested
for dependency in available[module_id].dependencies
}
_require_installed_modules(
dependencies,
available,
message="Required modules are not installed: ",
)
dependency_additions = dependencies - requested
added.update(dependency_additions)
requested.update(dependency_additions)
providers = _missing_capability_providers(requested, available)
_require_installed_modules(
providers,
available,
message="Required capability providers are not installed: ",
)
additions = providers - requested
if not additions:
return added
added.update(additions)
requested.update(additions)
def _missing_capability_providers(
requested: set[str],
available: Mapping[str, ModuleManifest],
) -> set[str]:
provided = {
capability
for module_id in requested
for capability in available[module_id].capability_factories
}
return {
provider_id
for module_id in requested
for capability in available[module_id].required_capabilities
if capability not in provided
for provider_id in (DEFAULT_CAPABILITY_PROVIDERS.get(capability),)
if provider_id is not None
}
def _require_installed_modules(
module_ids: set[str],
available: Mapping[str, ModuleManifest],
*,
message: str,
) -> None:
missing = sorted(module_id for module_id in module_ids if module_id not in available)
if missing:
raise ModuleManagementError(message + ", ".join(missing))
def _order_enabled_modules(
requested: set[str],
available: Mapping[str, ModuleManifest],
added_dependencies: set[str],
) -> list[str]:
visiting: set[str] = set()
visited: set[str] = set()
ordered: list[str] = []
for module_id in sorted(requested):
_visit_enabled_module(
module_id,
requested=requested,
available=available,
added_dependencies=added_dependencies,
visiting=visiting,
visited=visited,
ordered=ordered,
)
return ordered
def _visit_enabled_module(
module_id: str,
*,
requested: set[str],
available: Mapping[str, ModuleManifest],
added_dependencies: set[str],
visiting: set[str],
visited: set[str],
ordered: list[str],
) -> None:
if module_id in visited:
return
if module_id in visiting:
raise ModuleManagementError(f"Module dependency cycle includes {module_id!r}.")
manifest = available.get(module_id)
if manifest is None:
raise ModuleManagementError(f"Module {module_id!r} is required but not installed.")
visiting.add(module_id)
for dependency_id in manifest.dependencies:
if dependency_id not in available:
raise ModuleManagementError(
f"Module {module_id!r} depends on uninstalled module {dependency_id!r}."
)
if dependency_id not in requested:
added_dependencies.add(dependency_id)
requested.add(dependency_id)
_visit_enabled_module(
dependency_id,
requested=requested,
available=available,
added_dependencies=added_dependencies,
visiting=visiting,
visited=visited,
ordered=ordered,
)
visiting.remove(module_id)
visited.add(module_id)
ordered.append(module_id)
def module_dependents(available: Mapping[str, ModuleManifest]) -> dict[str, tuple[str, ...]]:
dependents: dict[str, list[str]] = {module_id: [] for module_id in available}
for manifest in available.values():

View File

@@ -4,8 +4,14 @@ from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any, Literal, Protocol, TYPE_CHECKING
from govoplan_core.core.views import ViewSurface
if TYPE_CHECKING:
from fastapi import APIRouter
from govoplan_core.core.search import (
SearchProviderRegistration,
SearchSourceProviderRegistration,
)
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
@@ -57,6 +63,7 @@ class NavItem:
required_all: tuple[str, ...] = ()
required_any: tuple[str, ...] = ()
order: int = 100
surface_id: str | None = None
@@ -68,6 +75,7 @@ class FrontendRoute:
required_all: tuple[str, ...] = ()
required_any: tuple[str, ...] = ()
order: int = 100
surface_id: str | None = None
@dataclass(frozen=True, slots=True)
@@ -92,6 +100,7 @@ class FrontendModule:
public_routes: tuple[PublicFrontendRoute, ...] = ()
nav_items: tuple[NavItem, ...] = ()
settings_routes: tuple[FrontendRoute, ...] = ()
view_surfaces: tuple[ViewSurface, ...] = ()
@dataclass(frozen=True, slots=True)
@@ -351,6 +360,8 @@ class ModuleManifest:
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = ()
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
search_providers: tuple["SearchProviderRegistration", ...] = ()
search_sources: tuple["SearchSourceProviderRegistration", ...] = ()
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
on_activate: LifecycleHook | None = None
on_deactivate: LifecycleHook | None = None

View File

@@ -4,11 +4,24 @@ from dataclasses import dataclass, field
from typing import Any, Iterable, Literal, Mapping, Protocol, cast, runtime_checkable
from urllib.parse import quote, unquote
from govoplan_core.core.access import PrincipalRef
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
SchedulingParticipantVisibility = Literal["aggregates_only", "names_and_statuses"]
DefinitionScopeType = Literal["system", "tenant", "group", "user"]
DefinitionKind = Literal["flow", "template"]
DefinitionGovernanceAction = Literal[
"view",
"edit",
"run",
"reuse",
"derive",
"automate",
]
CAPABILITY_POLICY_PRIVACY_RETENTION = "policy.privacyRetention"
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY = "policy.schedulingParticipantPrivacy"
CAPABILITY_POLICY_DEFINITION_GOVERNANCE = "policy.definitionGovernance"
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign")
@@ -128,6 +141,63 @@ class PolicyDecision:
}
@dataclass(frozen=True, slots=True)
class DefinitionScopeRef:
scope_type: DefinitionScopeType
scope_id: str | None = None
@property
def path(self) -> str:
return policy_source_path(self.scope_type, self.scope_id)
@dataclass(frozen=True, slots=True)
class DefinitionGovernanceRequest:
module_id: str
definition_ref: str
tenant_id: str
definition_scope: DefinitionScopeRef
target_scope: DefinitionScopeRef
definition_kind: DefinitionKind
action: DefinitionGovernanceAction
actor: PrincipalRef
status: str = "draft"
inherit_to_lower_scopes: bool = False
allow_run: bool = True
allow_reuse: bool = False
allow_automation: bool = False
context: Mapping[str, Any] = field(default_factory=dict)
@runtime_checkable
class DefinitionGovernancePolicy(Protocol):
def resolve_definition_action(
self,
*,
request: DefinitionGovernanceRequest,
) -> PolicyDecision:
...
def definition_governance_policy(
registry: object | None,
) -> DefinitionGovernancePolicy | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(
CAPABILITY_POLICY_DEFINITION_GOVERNANCE
)
):
return None
capability = registry.capability(CAPABILITY_POLICY_DEFINITION_GOVERNANCE)
return (
capability
if isinstance(capability, DefinitionGovernancePolicy)
else None
)
@dataclass(frozen=True, slots=True)
class SchedulingParticipantPrivacyRequest:
"""Context for resolving what one Scheduling participant may see.

View File

@@ -0,0 +1,386 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
POSTBOX_MODULE_ID = "postbox"
CAPABILITY_POSTBOX_DIRECTORY = "postbox.directory"
CAPABILITY_POSTBOX_ACCESS = "postbox.access"
CAPABILITY_POSTBOX_MESSAGES = "postbox.messages"
CAPABILITY_POSTBOX_DELIVERY = "postbox.delivery"
CAPABILITY_POSTBOX_EVIDENCE = "postbox.evidence"
PostboxAction = Literal["discover", "read", "send", "acknowledge", "administer"]
PostboxMessageListState = Literal["all", "unread", "read", "acknowledged"]
@dataclass(frozen=True, slots=True)
class PostboxActorRef:
account_id: str
identity_id: str | None = None
selected_assignment_id: str | None = None
acting_for_account_id: str | None = None
authorized_actions: frozenset[PostboxAction] = frozenset()
@dataclass(frozen=True, slots=True)
class PostboxTargetRef:
postbox_id: str | None = None
address_key: str | None = None
template_id: str | None = None
organization_unit_id: str | None = None
function_id: str | None = None
context_key: str | None = None
@dataclass(frozen=True, slots=True)
class PostboxDeliveryTemplateRef:
id: str
slug: str
name: str
description: str | None
published_revision_id: str
function_type_id: str | None
scope_kind: str
scope_id: str | None
classification: str
allow_vacant_delivery: bool
@dataclass(frozen=True, slots=True)
class PostboxOrganizationFunctionTargetRef:
id: str
slug: str
name: str
function_type_id: str | None = None
@dataclass(frozen=True, slots=True)
class PostboxOrganizationUnitTargetRef:
id: str
slug: str
name: str
unit_type_id: str | None = None
parent_id: str | None = None
functions: tuple[PostboxOrganizationFunctionTargetRef, ...] = ()
@dataclass(frozen=True, slots=True)
class PostboxDeliveryCatalogRef:
postboxes: tuple["PostboxDirectoryEntryRef", ...] = ()
templates: tuple[PostboxDeliveryTemplateRef, ...] = ()
organization_units: tuple[PostboxOrganizationUnitTargetRef, ...] = ()
@dataclass(frozen=True, slots=True)
class PostboxAccessDecisionRef:
allowed: bool
action: PostboxAction
postbox_id: str
reason_code: str
explanation: str
organization_unit_id: str | None = None
function_id: str | None = None
assignment_ids: tuple[str, ...] = ()
assignment_sources: tuple[str, ...] = ()
selected_assignment_id: str | None = None
holder_count: int = 0
vacant: bool = True
@dataclass(frozen=True, slots=True)
class PostboxDirectoryEntryRef:
id: str
tenant_id: str
address: str
address_key: str
name: str
status: str
classification: str
organization_unit_id: str | None = None
organization_unit_name: str | None = None
function_id: str | None = None
function_name: str | None = None
context_key: str | None = None
template_revision_id: str | None = None
holder_count: int = 0
vacant: bool = True
access: PostboxAccessDecisionRef | None = None
@dataclass(frozen=True, slots=True)
class PostboxParticipantRef:
kind: str
reference_type: str
reference_id: str | None = None
label: str | None = None
address: str | None = None
@dataclass(frozen=True, slots=True)
class PostboxAttachmentRef:
reference_type: str
reference_id: str
name: str | None = None
media_type: str | None = None
size_bytes: int | None = None
digest: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxMessageRef:
id: str
tenant_id: str
postbox_id: str
subject: str
body_text: str | None
status: str
classification: str
sender_label: str | None
delivered_at: datetime
read_at: datetime | None = None
acknowledged_at: datetime | None = None
expires_at: datetime | None = None
withdrawn_at: datetime | None = None
producer_module: str | None = None
producer_resource_type: str | None = None
producer_resource_id: str | None = None
encryption_profile: str = "plaintext_v1"
key_epoch: int = 1
ciphertext_ref: str | None = None
signed_manifest_ref: str | None = None
participants: tuple[PostboxParticipantRef, ...] = ()
attachments: tuple[PostboxAttachmentRef, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxDeliveryRequest:
tenant_id: str
target: PostboxTargetRef
producer_module: str
producer_resource_type: str
producer_resource_id: str | None
idempotency_key: str
subject: str
body_text: str | None = None
sender_label: str | None = None
classification: str = "internal"
participants: tuple[PostboxParticipantRef, ...] = ()
attachments: tuple[PostboxAttachmentRef, ...] = ()
expires_at: datetime | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxDeliveryResult:
delivery_id: str
postbox_id: str
message_id: str
address: str
status: str
vacant: bool
holder_count: int
duplicate: bool = False
evidence: Mapping[str, object] = field(default_factory=dict)
class PostboxDeliveryRejected(RuntimeError):
"""A delivery was rejected before the provider accepted any effect."""
def __init__(
self,
code: str,
message: str,
*,
temporary: bool = False,
) -> None:
super().__init__(message)
self.code = code
self.temporary = temporary
class PostboxDeliveryOutcomeUnknown(RuntimeError):
"""The provider may have accepted an effect and must not be bypassed."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
@runtime_checkable
class PostboxDirectoryProvider(Protocol):
def list_visible_postboxes(
self,
session: object,
*,
tenant_id: str,
actor: PostboxActorRef,
) -> Sequence[PostboxDirectoryEntryRef]:
...
def resolve_postbox(
self,
session: object,
*,
tenant_id: str,
target: PostboxTargetRef,
materialize: bool = False,
) -> PostboxDirectoryEntryRef | None:
...
def delivery_catalog(
self,
session: object,
*,
tenant_id: str,
) -> PostboxDeliveryCatalogRef:
...
@runtime_checkable
class PostboxAccessProvider(Protocol):
def explain_access(
self,
session: object,
*,
tenant_id: str,
postbox_id: str,
actor: PostboxActorRef,
action: PostboxAction,
) -> PostboxAccessDecisionRef:
...
@runtime_checkable
class PostboxMessagesProvider(Protocol):
def list_messages(
self,
session: object,
*,
tenant_id: str,
postbox_ids: Sequence[str],
actor: PostboxActorRef,
limit: int = 100,
offset: int = 0,
query: str | None = None,
state: PostboxMessageListState = "all",
) -> Sequence[PostboxMessageRef]:
...
def get_message(
self,
session: object,
*,
tenant_id: str,
message_id: str,
actor: PostboxActorRef,
) -> PostboxMessageRef | None:
...
def mark_message(
self,
session: object,
*,
tenant_id: str,
message_id: str,
actor: PostboxActorRef,
state: Literal["read", "acknowledged"],
) -> PostboxMessageRef:
...
@runtime_checkable
class PostboxDeliveryProvider(Protocol):
def deliver(
self,
session: object,
request: PostboxDeliveryRequest,
) -> PostboxDeliveryResult:
...
@runtime_checkable
class PostboxEvidenceProvider(Protocol):
def link_evidence(
self,
session: object,
*,
tenant_id: str,
message_id: str,
attachment: PostboxAttachmentRef,
) -> PostboxMessageRef:
...
def _postbox_provider(
registry: object | None,
*,
capability_name: str,
provider_type: type,
) -> object | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(capability_name):
return None
capability = registry.capability(capability_name)
return capability if isinstance(capability, provider_type) else None
def postbox_directory_provider(
registry: object | None,
) -> PostboxDirectoryProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_DIRECTORY,
provider_type=PostboxDirectoryProvider,
)
return provider if isinstance(provider, PostboxDirectoryProvider) else None
def postbox_access_provider(
registry: object | None,
) -> PostboxAccessProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_ACCESS,
provider_type=PostboxAccessProvider,
)
return provider if isinstance(provider, PostboxAccessProvider) else None
def postbox_messages_provider(
registry: object | None,
) -> PostboxMessagesProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_MESSAGES,
provider_type=PostboxMessagesProvider,
)
return provider if isinstance(provider, PostboxMessagesProvider) else None
def postbox_delivery_provider(
registry: object | None,
) -> PostboxDeliveryProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_DELIVERY,
provider_type=PostboxDeliveryProvider,
)
return provider if isinstance(provider, PostboxDeliveryProvider) else None
def postbox_evidence_provider(
registry: object | None,
) -> PostboxEvidenceProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_EVIDENCE,
provider_type=PostboxEvidenceProvider,
)
return provider if isinstance(provider, PostboxEvidenceProvider) else None

View File

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

View File

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

View File

@@ -25,6 +25,19 @@ from govoplan_core.core.modules import (
user_workflow_scope_condition_issues,
)
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
from govoplan_core.core.search import (
RegisteredSearchProvider,
RegisteredSearchSourceProvider,
SearchProvider,
SearchSourceProvider,
)
from govoplan_core.core.views import (
ViewSurface,
module_view_surface_id,
navigation_view_surface_id,
route_view_surface_id,
validate_view_surface_id,
)
_MODULE_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
_NPM_PACKAGE_RE = re.compile(r"^(?:@[a-z0-9][a-z0-9_.-]*/)?[a-z0-9][a-z0-9_.-]*$")
@@ -53,6 +66,12 @@ class PlatformRegistry:
self._capability_factories: dict[str, CapabilityFactory] = {}
self._capabilities: dict[str, object] = {}
self._capability_context: ModuleContext | None = None
self._search_provider_registrations: list[RegisteredSearchProvider] = []
self._search_providers: dict[str, SearchProvider] = {}
self._search_source_registrations: list[
RegisteredSearchSourceProvider
] = []
self._search_sources: dict[str, SearchSourceProvider] = {}
def register(self, manifest: ModuleManifest) -> ModuleManifest:
if manifest.id in self._manifests:
@@ -65,6 +84,20 @@ class PlatformRegistry:
self.register_delete_veto(manifest.id, resource_type, provider)
for name, factory in manifest.capability_factories.items():
self.register_capability_factory(manifest.id, name, factory)
for registration in manifest.search_providers:
self._search_provider_registrations.append(
RegisteredSearchProvider(
module_id=manifest.id,
registration=registration,
)
)
for registration in manifest.search_sources:
self._search_source_registrations.append(
RegisteredSearchSourceProvider(
module_id=manifest.id,
registration=registration,
)
)
return manifest
def replace(self, manifests: Iterable[ModuleManifest]) -> RegistrySnapshot:
@@ -82,7 +115,15 @@ class PlatformRegistry:
for resource_type, providers in replacement._delete_veto_providers.items()
})
self._capability_factories = dict(replacement._capability_factories)
self._search_provider_registrations = list(
replacement._search_provider_registrations
)
self._search_source_registrations = list(
replacement._search_source_registrations
)
self._capabilities.clear()
self._search_providers.clear()
self._search_sources.clear()
return snapshot
def get(self, module_id: str) -> ModuleManifest | None:
@@ -113,12 +154,21 @@ class PlatformRegistry:
def nav_items(self) -> tuple[NavItem, ...]:
return tuple(sorted((item for manifest in self.manifests() for item in manifest.nav_items), key=lambda item: item.order))
def view_surfaces(self) -> tuple[ViewSurface, ...]:
return tuple(
surface
for manifest in self.manifests()
for surface in manifest_view_surfaces(manifest)
)
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers)
def configure_capability_context(self, context: ModuleContext) -> None:
self._capability_context = context
self._capabilities.clear()
self._search_providers.clear()
self._search_sources.clear()
def register_capability_factory(self, module_id: str, name: str, factory: CapabilityFactory) -> None:
if name in self._capability_factories:
@@ -146,6 +196,77 @@ class PlatformRegistry:
raise RegistryError(f"Required capability is not available: {name}")
return capability
def search_provider_registrations(
self,
) -> tuple[RegisteredSearchProvider, ...]:
return tuple(
sorted(
self._search_provider_registrations,
key=lambda item: (
item.registration.order,
item.module_id,
item.registration.id,
),
)
)
def search_providers(
self,
) -> tuple[tuple[RegisteredSearchProvider, SearchProvider], ...]:
if self._capability_context is None:
if self._search_provider_registrations:
raise RegistryError("Search provider context is not configured.")
return ()
providers: list[tuple[RegisteredSearchProvider, SearchProvider]] = []
for registered in self.search_provider_registrations():
key = f"{registered.module_id}:{registered.registration.id}"
provider = self._search_providers.get(key)
if provider is None:
provider = registered.registration.create(self._capability_context)
self._search_providers[key] = provider
providers.append((registered, provider))
return tuple(providers)
def search_source_registrations(
self,
) -> tuple[RegisteredSearchSourceProvider, ...]:
return tuple(
sorted(
self._search_source_registrations,
key=lambda item: (
item.registration.order,
item.module_id,
item.registration.id,
),
)
)
def search_sources(
self,
) -> tuple[
tuple[RegisteredSearchSourceProvider, SearchSourceProvider],
...,
]:
if self._capability_context is None:
if self._search_source_registrations:
raise RegistryError(
"Search source context is not configured."
)
return ()
providers: list[
tuple[RegisteredSearchSourceProvider, SearchSourceProvider]
] = []
for registered in self.search_source_registrations():
key = f"{registered.module_id}:{registered.registration.id}"
provider = self._search_sources.get(key)
if provider is None:
provider = registered.registration.create(
self._capability_context
)
self._search_sources[key] = provider
providers.append((registered, provider))
return tuple(providers)
def register_tenant_summary_provider(self, module_id: str, provider: TenantSummaryProvider) -> None:
self._tenant_summary_providers[module_id] = provider
@@ -230,6 +351,53 @@ class PlatformRegistry:
return (self._manifests[module_id] for module_id in ordered)
def manifest_view_surfaces(manifest: ModuleManifest) -> tuple[ViewSurface, ...]:
frontend = manifest.frontend
if frontend is None:
return ()
root_id = module_view_surface_id(manifest.id)
surfaces = [
ViewSurface(
id=root_id,
module_id=manifest.id,
kind="module",
label=manifest.name,
order=min((item.order for item in frontend.nav_items), default=100),
)
]
surfaces.extend(
ViewSurface(
id=item.surface_id or navigation_view_surface_id(manifest.id, item.path),
module_id=manifest.id,
kind="navigation",
label=item.label,
parent_id=root_id,
order=item.order,
)
for item in frontend.nav_items
)
surfaces.extend(
ViewSurface(
id=route.surface_id or route_view_surface_id(manifest.id, route.path),
module_id=manifest.id,
kind="route",
label=route.component,
parent_id=root_id,
description=route.path,
order=route.order,
)
for route in (*frontend.routes, *frontend.settings_routes)
)
surfaces.extend(
replace(
surface,
parent_id=surface.parent_id or root_id,
)
for surface in frontend.view_surfaces
)
return tuple(surfaces)
def _normalize_delete_veto_result(
result: object,
*,
@@ -401,6 +569,32 @@ def _validate_manifest_contract_lists(manifest: ModuleManifest) -> None:
_validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities)
_validate_interface_providers(manifest.id, manifest.provides_interfaces)
_validate_interface_requirements(manifest.id, manifest.requires_interfaces)
provider_ids: set[str] = set()
for registration in manifest.search_providers:
if not _INTERFACE_NAME_RE.match(registration.id):
raise RegistryError(
f"Module {manifest.id!r} search provider id must be namespaced: "
f"{registration.id!r}"
)
if registration.id in provider_ids:
raise RegistryError(
f"Module {manifest.id!r} declares duplicate search provider "
f"{registration.id!r}"
)
provider_ids.add(registration.id)
source_ids: set[str] = set()
for registration in manifest.search_sources:
if not _INTERFACE_NAME_RE.match(registration.id):
raise RegistryError(
f"Module {manifest.id!r} search source id must be "
f"namespaced: {registration.id!r}"
)
if registration.id in source_ids:
raise RegistryError(
f"Module {manifest.id!r} declares duplicate search source "
f"{registration.id!r}"
)
source_ids.add(registration.id)
def _validate_manifest_overlaps(manifest: ModuleManifest) -> None:
@@ -438,8 +632,117 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes):
_validate_frontend_route(manifest.id, route.path, route.component)
for route in (*frontend.routes, *frontend.settings_routes):
if route.surface_id is not None:
_validate_view_surface_id(manifest.id, route.surface_id)
for item in frontend.nav_items:
_validate_nav_item(manifest.id, item)
if item.surface_id is not None:
_validate_view_surface_id(manifest.id, item.surface_id)
_validate_view_surfaces(manifest)
def _validate_view_surfaces(manifest: ModuleManifest) -> None:
frontend = manifest.frontend
if frontend is None:
return
root_id = module_view_surface_id(manifest.id)
known_ids = {root_id}
_validate_view_surface_id(manifest.id, root_id)
for item in frontend.nav_items:
surface_id = item.surface_id or navigation_view_surface_id(manifest.id, item.path)
_register_view_surface_id(manifest.id, surface_id, known_ids)
for route in (*frontend.routes, *frontend.settings_routes):
surface_id = route.surface_id or route_view_surface_id(manifest.id, route.path)
_register_view_surface_id(manifest.id, surface_id, known_ids)
for surface in frontend.view_surfaces:
_validate_declared_view_surface(manifest.id, surface)
_register_view_surface_id(manifest.id, surface.id, known_ids)
for surface in frontend.view_surfaces:
_validate_view_surface_definition(
manifest.id,
surface,
root_id=root_id,
known_ids=known_ids,
)
parent_by_id = {
surface.id: surface.parent_id or root_id
for surface in frontend.view_surfaces
}
_validate_view_surface_hierarchy(manifest.id, parent_by_id)
def _register_view_surface_id(
module_id: str,
surface_id: str,
known_ids: set[str],
) -> None:
_validate_view_surface_id(module_id, surface_id)
if surface_id in known_ids:
raise RegistryError(
f"Duplicate view surface id {surface_id!r} in module {module_id!r}"
)
known_ids.add(surface_id)
def _validate_declared_view_surface(
module_id: str,
surface: ViewSurface,
) -> None:
if surface.module_id != module_id:
raise RegistryError(
f"View surface {surface.id!r} belongs to {surface.module_id!r}, "
f"not module {module_id!r}"
)
def _validate_view_surface_definition(
module_id: str,
surface: ViewSurface,
*,
root_id: str,
known_ids: set[str],
) -> None:
parent_id = surface.parent_id or root_id
if parent_id not in known_ids:
raise RegistryError(
f"View surface {surface.id!r} in module {module_id!r} "
f"references unknown parent {parent_id!r}"
)
if surface.required and not surface.default_visible:
raise RegistryError(
f"Required view surface {surface.id!r} in module {module_id!r} "
"must be visible by default"
)
def _validate_view_surface_hierarchy(
module_id: str,
parent_by_id: Mapping[str, str],
) -> None:
for surface_id in parent_by_id:
path: set[str] = set()
current_id: str | None = surface_id
while current_id in parent_by_id:
if current_id in path:
raise RegistryError(
f"View surface hierarchy in module {module_id!r} "
f"contains a cycle at {current_id!r}"
)
path.add(current_id)
current_id = parent_by_id[current_id]
def _validate_view_surface_id(module_id: str, surface_id: str) -> None:
if not validate_view_surface_id(surface_id):
raise RegistryError(
f"View surface id for module {module_id!r} is invalid: {surface_id!r}"
)
if not surface_id.startswith(f"{module_id}."):
raise RegistryError(
f"View surface id for module {module_id!r} must start with "
f"{module_id!r} followed by '.': {surface_id!r}"
)
def _validate_frontend_route(module_id: str, path: str, component: str) -> None:

View File

@@ -0,0 +1,152 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
SANCTIONS_SNAPSHOT_CONTRACT_VERSION = "1"
CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS = (
"connectors.sanctionsSnapshotProvider"
)
@dataclass(frozen=True, slots=True)
class SanctionsSnapshotReference:
ref: str
tenant_id: str
provider_id: str
publisher: str
jurisdiction: str
list_type: str
source_id: str
source_version: str
publication_at: datetime | None
effective_at: datetime | None
acquired_at: datetime
content_type: str
byte_count: int
sha256: str
parser_version: str
raw_evidence_ref: str
connector_run_id: str
signature_evidence: Mapping[str, object] = field(
default_factory=dict
)
licence_notes: str | None = None
trust_notes: str | None = None
transport_evidence: Mapping[str, object] = field(
default_factory=dict
)
contract_version: str = SANCTIONS_SNAPSHOT_CONTRACT_VERSION
def __post_init__(self) -> None:
if self.contract_version != SANCTIONS_SNAPSHOT_CONTRACT_VERSION:
raise ValueError(
"Unsupported sanctions snapshot contract version."
)
required = (
self.ref,
self.tenant_id,
self.provider_id,
self.publisher,
self.jurisdiction,
self.list_type,
self.source_id,
self.source_version,
self.content_type,
self.sha256,
self.parser_version,
self.raw_evidence_ref,
self.connector_run_id,
)
if not all(value.strip() for value in required):
raise ValueError(
"Sanctions snapshot references require complete provenance."
)
if self.byte_count < 1:
raise ValueError(
"Sanctions snapshot evidence must not be empty."
)
if (
len(self.sha256) != 64
or any(character not in "0123456789abcdef" for character in self.sha256)
):
raise ValueError(
"Sanctions snapshot SHA-256 evidence is invalid."
)
@dataclass(frozen=True, slots=True)
class SanctionsSnapshotPayload:
snapshot: SanctionsSnapshotReference
content: bytes
def __post_init__(self) -> None:
if len(self.content) != self.snapshot.byte_count:
raise ValueError(
"Sanctions snapshot content length does not match metadata."
)
@runtime_checkable
class SanctionsSnapshotProvider(Protocol):
def list_snapshots(
self,
session: object,
principal: object,
*,
limit: int = 100,
) -> Sequence[SanctionsSnapshotReference]:
...
def get_snapshot(
self,
session: object,
principal: object,
*,
snapshot_ref: str,
) -> SanctionsSnapshotReference | None:
...
def read_snapshot(
self,
session: object,
principal: object,
*,
snapshot_ref: str,
) -> SanctionsSnapshotPayload:
...
def sanctions_snapshot_provider(
registry: object | None,
) -> SanctionsSnapshotProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(
CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS
)
):
return None
provider = registry.capability(
CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS
)
return (
provider
if isinstance(provider, SanctionsSnapshotProvider)
else None
)
__all__ = [
"CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS",
"SANCTIONS_SNAPSHOT_CONTRACT_VERSION",
"SanctionsSnapshotPayload",
"SanctionsSnapshotProvider",
"SanctionsSnapshotReference",
"sanctions_snapshot_provider",
]

View File

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

View File

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

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
import re
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Literal, Protocol, runtime_checkable
VIEWS_MODULE_ID = "views"
CAPABILITY_VIEWS_RESOLVER = "views.resolver"
VIEW_SURFACE_CONTRACT_VERSION = "1"
ViewSurfaceKind = Literal[
"module",
"navigation",
"route",
"section",
"action",
"selector",
]
_SURFACE_ID_RE = re.compile(r"^[a-z][a-z0-9_.-]{2,159}$")
_SURFACE_SLUG_RE = re.compile(r"[^a-z0-9]+")
@dataclass(frozen=True, slots=True)
class ViewSurface:
id: str
module_id: str
kind: ViewSurfaceKind
label: str
parent_id: str | None = None
description: str | None = None
order: int = 100
default_visible: bool = True
required: bool = False
@dataclass(frozen=True, slots=True)
class EffectiveView:
view_id: str | None
revision_id: str | None
name: str | None
visible_surface_ids: frozenset[str]
locked: bool = False
provenance: tuple[dict[str, object], ...] = ()
@runtime_checkable
class ViewResolver(Protocol):
def resolve_effective_view(
self,
session: object,
*,
tenant_id: str,
account_id: str,
group_ids: Iterable[str] = (),
workflow_view_id: str | None = None,
) -> EffectiveView: ...
def module_view_surface_id(module_id: str) -> str:
return f"{module_id}.module"
def navigation_view_surface_id(module_id: str, path: str) -> str:
return f"{module_id}.nav.{_surface_slug(path)}"
def route_view_surface_id(module_id: str, path: str) -> str:
return f"{module_id}.route.{_surface_slug(path)}"
def validate_view_surface_id(value: str) -> bool:
return bool(_SURFACE_ID_RE.fullmatch(value))
def _surface_slug(value: str) -> str:
normalized = _SURFACE_SLUG_RE.sub(".", value.strip().lower()).strip(".")
return normalized or "root"
__all__ = [
"CAPABILITY_VIEWS_RESOLVER",
"EffectiveView",
"VIEWS_MODULE_ID",
"VIEW_SURFACE_CONTRACT_VERSION",
"ViewResolver",
"ViewSurface",
"ViewSurfaceKind",
"module_view_surface_id",
"navigation_view_surface_id",
"route_view_surface_id",
"validate_view_surface_id",
]

View File

@@ -32,6 +32,7 @@ def create_all_tables() -> None:
# model metadata with the shared SQLAlchemy base before create_all runs.
from govoplan_core.admin import models as core_admin_models # noqa: F401
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules)

View File

@@ -18,6 +18,7 @@ from sqlalchemy import create_engine, inspect, text
from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.modules import ModuleMigrationTaskContext, ModuleMigrationTaskResult

View File

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

View File

@@ -37,6 +37,7 @@ class DevserverState:
config: GovoplanServerConfig
registry: PlatformRegistry
reload_dirs: list[str]
reload_module_ids: tuple[str, ...] | None
def _config_module_runtime_root(config_path: str | None) -> Path | None:
@@ -237,20 +238,44 @@ def build_reload_dirs(
config_path: str | None = None,
registry: PlatformRegistry | None = None,
extra_dirs: Sequence[str] = (),
module_ids: Sequence[str] | None = None,
) -> list[str]:
active_registry = registry or build_platform_registry(config.enabled_modules, manifest_factories=config.manifest_factories)
manifests = active_registry.manifests()
enabled_module_ids = {manifest.id for manifest in manifests}
selected_module_ids = (
enabled_module_ids
if module_ids is None
else {module_id.strip() for module_id in module_ids if module_id.strip()}
)
unknown_module_ids = selected_module_ids - enabled_module_ids
if unknown_module_ids:
raise SystemExit(
"Reload modules are not enabled: "
+ ", ".join(sorted(unknown_module_ids))
)
roots: list[Path | str] = []
roots.extend(_config_source_roots(config_path))
for factory in config.manifest_factories:
try:
manifest = factory()
except TypeError:
manifest = None
if (
module_ids is not None
and isinstance(manifest, ModuleManifest)
and manifest.id not in selected_module_ids
):
continue
roots.extend(_source_roots_for_object(factory))
roots.extend(_entry_point_source_roots(enabled_module_ids))
roots.extend(_entry_point_source_roots(selected_module_ids))
for manifest in manifests:
if manifest.id not in selected_module_ids:
continue
roots.extend(_manifest_source_roots(manifest))
roots.extend(extra_dirs)
@@ -265,11 +290,32 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser.add_argument("--port", type=int, default=8000, help="Port to bind. Default: 8000.")
parser.add_argument("--no-reload", action="store_true", help="Disable uvicorn reload.")
parser.add_argument("--reload-dir", action="append", default=[], help="Additional directory to watch. May be passed multiple times.")
reload_scope = parser.add_mutually_exclusive_group()
reload_scope.add_argument(
"--reload-module",
action="append",
default=None,
metavar="MODULE_ID",
help=(
"Watch only this enabled module in addition to core/config sources. "
"May be passed multiple times."
),
)
reload_scope.add_argument(
"--reload-core-only",
action="store_true",
help="Watch core/config sources but no optional module source trees.",
)
parser.add_argument("--smoke", action="store_true", help="Prepare runtime paths, run app startup, print effective paths, and exit without uvicorn.")
return parser.parse_args(argv)
def prepare_devserver(config_path: str | None, *, extra_reload_dirs: Sequence[str] = ()) -> DevserverState:
def prepare_devserver(
config_path: str | None,
*,
extra_reload_dirs: Sequence[str] = (),
reload_module_ids: Sequence[str] | None = None,
) -> DevserverState:
runtime_root = apply_runtime_defaults(config_path)
database_url = os.getenv("DATABASE_URL", "")
validate_sqlite_database_url(database_url)
@@ -291,7 +337,13 @@ def prepare_devserver(config_path: str | None, *, extra_reload_dirs: Sequence[st
)
enabled_modules = load_startup_enabled_modules(config.enabled_modules, available=available_modules)
registry = build_platform_registry(enabled_modules, manifest_factories=config.manifest_factories)
reload_dirs = build_reload_dirs(config, config_path=config_path, registry=registry, extra_dirs=extra_reload_dirs)
reload_dirs = build_reload_dirs(
config,
config_path=config_path,
registry=registry,
extra_dirs=extra_reload_dirs,
module_ids=reload_module_ids,
)
return DevserverState(
config_path=config_path,
runtime_root=runtime_root,
@@ -300,6 +352,11 @@ def prepare_devserver(config_path: str | None, *, extra_reload_dirs: Sequence[st
config=config,
registry=registry,
reload_dirs=reload_dirs,
reload_module_ids=(
None
if reload_module_ids is None
else tuple(sorted(set(reload_module_ids)))
),
)
@@ -320,6 +377,15 @@ def print_devserver_summary(state: DevserverState, *, app: str, no_reload: bool)
if no_reload:
print("Reload: disabled")
else:
if state.reload_module_ids is None:
print("Reload scope: core/config plus all enabled modules")
elif state.reload_module_ids:
print(
"Reload scope: core/config plus "
+ ", ".join(state.reload_module_ids)
)
else:
print("Reload scope: core/config only")
print("Reload dirs:")
for directory in state.reload_dirs:
print(f" - {directory}")
@@ -353,7 +419,14 @@ def main(argv: Sequence[str] | None = None) -> int:
os.environ["GOVOPLAN_SERVER_CONFIG"] = args.config
config_path = args.config or os.getenv("GOVOPLAN_SERVER_CONFIG")
state = prepare_devserver(config_path, extra_reload_dirs=args.reload_dir)
reload_module_ids: Sequence[str] | None = args.reload_module
if args.reload_core_only:
reload_module_ids = ()
state = prepare_devserver(
config_path,
extra_reload_dirs=args.reload_dir,
reload_module_ids=reload_module_ids,
)
print_devserver_summary(state, app=args.app, no_reload=args.no_reload)
if args.smoke:

View File

@@ -0,0 +1,563 @@
from __future__ import annotations
import json
import re
import uuid
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Mapping
from sqlalchemy import Boolean, DateTime, Index, JSON, String, Text, select
from sqlalchemy.orm import Mapped, Session, mapped_column
from govoplan_core.audit.logging import audit_event
from govoplan_core.core.change_sequence import record_change
from govoplan_core.db.base import Base, TimestampMixin, utcnow
from govoplan_core.security.redaction import is_sensitive_key, redact_secret_values
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
CREDENTIAL_SCOPE_TYPES = frozenset({"system", "tenant", "group", "user", "campaign"})
CREDENTIAL_KINDS = frozenset(
{
"username_password",
"token",
"oauth2",
"api_key",
"client_secret",
"aws",
"custom",
"external_secret",
}
)
_SECRET_PUBLIC_DATA_KEYS = frozenset(
{
"access_token",
"api_key",
"authorization",
"bearer_token",
"client_secret",
"password",
"passphrase",
"private_key",
"refresh_token",
"secret",
"secret_access_key",
"session_token",
"token",
}
)
class CredentialEnvelopeError(RuntimeError):
pass
class CredentialEnvelope(Base, TimestampMixin):
__tablename__ = "core_credential_envelopes"
__table_args__ = (
Index("ix_core_credential_envelopes_scope", "tenant_id", "scope_type", "scope_id"),
Index("ix_core_credential_envelopes_active", "tenant_id", "is_active", "deleted_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
scope_type: Mapped[str] = mapped_column(String(20), nullable=False, default="tenant", index=True)
scope_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
credential_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
public_data: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
secret_data_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
secret_keys: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
allowed_modules: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
allowed_server_refs: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
inherit_to_lower_scopes: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
revision: Mapped[str] = mapped_column(String(36), default=lambda: str(uuid.uuid4()), nullable=False)
created_by_user_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
updated_by_user_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
@dataclass(frozen=True, slots=True)
class CredentialAccessContext:
tenant_id: str | None
user_id: str | None = None
group_ids: frozenset[str] = frozenset()
target_scope_type: str = "tenant"
target_scope_id: str | None = None
module_id: str | None = None
server_ref: str | None = None
administrative: bool = False
@dataclass(frozen=True, slots=True)
class ResolvedCredentialEnvelope:
id: str
name: str
credential_kind: str
public_data: Mapping[str, Any]
secret_data: Mapping[str, Any]
revision: str
def normalize_credential_scope(
*,
tenant_id: str | None,
scope_type: str,
scope_id: str | None,
) -> tuple[str | None, str, str | None]:
normalized_type = str(scope_type or "tenant").strip().casefold()
if normalized_type not in CREDENTIAL_SCOPE_TYPES:
raise CredentialEnvelopeError(f"Unsupported credential scope: {scope_type!r}")
normalized_id = _optional_text(scope_id)
if normalized_type == "system":
return None, "system", None
normalized_tenant = _required_text(tenant_id, "Credential tenant_id is required outside system scope")
if normalized_type == "tenant":
return normalized_tenant, "tenant", normalized_id or normalized_tenant
if not normalized_id:
raise CredentialEnvelopeError(f"{normalized_type.capitalize()} credentials require scope_id")
return normalized_tenant, normalized_type, normalized_id
def create_credential_envelope(
session: Session,
*,
tenant_id: str | None,
scope_type: str,
scope_id: str | None,
name: str,
credential_kind: str,
public_data: Mapping[str, Any] | None = None,
secret_data: Mapping[str, Any] | None = None,
description: str | None = None,
allowed_modules: list[str] | tuple[str, ...] = (),
allowed_server_refs: list[str] | tuple[str, ...] = (),
inherit_to_lower_scopes: bool = False,
is_active: bool = True,
user_id: str | None = None,
metadata: Mapping[str, Any] | None = None,
) -> CredentialEnvelope:
row_tenant_id, row_scope_type, row_scope_id = normalize_credential_scope(
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
)
clean_kind = _normalize_kind(credential_kind)
clean_public = _safe_public_mapping(public_data)
clean_secret = _json_mapping(secret_data)
row = CredentialEnvelope(
tenant_id=row_tenant_id,
scope_type=row_scope_type,
scope_id=row_scope_id,
name=_required_text(name, "Credential name is required"),
description=_optional_text(description),
credential_kind=clean_kind,
public_data=clean_public,
secret_data_encrypted=_encrypt_secret_mapping(clean_secret),
secret_keys=sorted(clean_secret),
allowed_modules=_normalized_values(allowed_modules),
allowed_server_refs=_normalized_values(allowed_server_refs),
inherit_to_lower_scopes=bool(inherit_to_lower_scopes),
is_active=bool(is_active),
created_by_user_id=_optional_text(user_id),
updated_by_user_id=_optional_text(user_id),
metadata_=_json_mapping(metadata) or None,
)
session.add(row)
session.flush()
_record_credential_change(session, row=row, operation="created", user_id=user_id)
return row
def update_credential_envelope(
session: Session,
row: CredentialEnvelope,
*,
name: str | None = None,
description: str | None = None,
description_supplied: bool = False,
credential_kind: str | None = None,
public_data: Mapping[str, Any] | None = None,
secret_data: Mapping[str, Any] | None = None,
clear_secret: bool = False,
allowed_modules: list[str] | tuple[str, ...] | None = None,
allowed_server_refs: list[str] | tuple[str, ...] | None = None,
inherit_to_lower_scopes: bool | None = None,
is_active: bool | None = None,
user_id: str | None = None,
metadata: Mapping[str, Any] | None = None,
) -> CredentialEnvelope:
if row.deleted_at is not None:
raise CredentialEnvelopeError("Deleted credentials cannot be updated")
if name is not None:
row.name = _required_text(name, "Credential name is required")
if description_supplied or description is not None:
row.description = _optional_text(description)
if credential_kind is not None:
clean_kind = _normalize_kind(credential_kind)
if clean_kind != row.credential_kind and secret_data is None and not clear_secret:
raise CredentialEnvelopeError(
"Changing credential kind requires replacing or clearing its secret"
)
row.credential_kind = clean_kind
if public_data is not None:
row.public_data = _safe_public_mapping(public_data)
if secret_data is not None:
clean_secret = _json_mapping(secret_data)
row.secret_data_encrypted = _encrypt_secret_mapping(clean_secret)
row.secret_keys = sorted(clean_secret)
elif clear_secret:
row.secret_data_encrypted = None
row.secret_keys = []
if allowed_modules is not None:
row.allowed_modules = _normalized_values(allowed_modules)
if allowed_server_refs is not None:
row.allowed_server_refs = _normalized_values(allowed_server_refs)
if inherit_to_lower_scopes is not None:
row.inherit_to_lower_scopes = bool(inherit_to_lower_scopes)
if is_active is not None:
row.is_active = bool(is_active)
if metadata is not None:
row.metadata_ = _json_mapping(metadata) or None
row.updated_by_user_id = _optional_text(user_id)
row.revision = str(uuid.uuid4())
session.flush()
_record_credential_change(session, row=row, operation="updated", user_id=user_id)
return row
def retire_credential_envelope(
session: Session,
row: CredentialEnvelope,
*,
user_id: str | None = None,
) -> CredentialEnvelope:
if row.deleted_at is not None:
return row
row.secret_data_encrypted = None
row.secret_keys = []
row.is_active = False
row.deleted_at = utcnow()
row.updated_by_user_id = _optional_text(user_id)
row.revision = str(uuid.uuid4())
session.flush()
_record_credential_change(session, row=row, operation="deleted", user_id=user_id)
return row
def get_credential_envelope(
session: Session,
*,
credential_id: str,
context: CredentialAccessContext,
require_active: bool = True,
for_update: bool = False,
) -> CredentialEnvelope:
statement = select(CredentialEnvelope).where(CredentialEnvelope.id == _required_text(credential_id, "Credential id is required"))
if for_update:
statement = statement.with_for_update()
row = session.execute(statement).scalar_one_or_none()
if row is None or row.deleted_at is not None or not credential_visible_to_context(row, context):
raise CredentialEnvelopeError("Credential envelope not found")
if require_active and not row.is_active:
raise CredentialEnvelopeError("Credential envelope is inactive")
return row
def list_credential_envelopes(
session: Session,
*,
context: CredentialAccessContext,
include_inactive: bool = False,
) -> list[CredentialEnvelope]:
statement = select(CredentialEnvelope).where(CredentialEnvelope.deleted_at.is_(None))
if context.tenant_id is None:
statement = statement.where(CredentialEnvelope.tenant_id.is_(None))
else:
statement = statement.where(
(CredentialEnvelope.tenant_id == context.tenant_id)
| (CredentialEnvelope.tenant_id.is_(None))
)
if not include_inactive:
statement = statement.where(CredentialEnvelope.is_active.is_(True))
rows = session.execute(statement.order_by(CredentialEnvelope.name, CredentialEnvelope.id)).scalars()
return [row for row in rows if credential_visible_to_context(row, context)]
def list_managed_credential_envelopes(
session: Session,
*,
tenant_id: str | None,
include_inactive: bool = False,
) -> list[CredentialEnvelope]:
"""List envelopes for an administrative surface without applying use-site limits."""
statement = select(CredentialEnvelope).where(CredentialEnvelope.deleted_at.is_(None))
if tenant_id is None:
statement = statement.where(CredentialEnvelope.tenant_id.is_(None))
else:
statement = statement.where(CredentialEnvelope.tenant_id == tenant_id)
if not include_inactive:
statement = statement.where(CredentialEnvelope.is_active.is_(True))
return list(
session.execute(
statement.order_by(
CredentialEnvelope.scope_type,
CredentialEnvelope.name,
CredentialEnvelope.id,
)
).scalars()
)
def get_managed_credential_envelope(
session: Session,
*,
credential_id: str,
tenant_id: str | None,
for_update: bool = False,
) -> CredentialEnvelope:
statement = select(CredentialEnvelope).where(
CredentialEnvelope.id
== _required_text(credential_id, "Credential id is required"),
CredentialEnvelope.deleted_at.is_(None),
)
if tenant_id is None:
statement = statement.where(CredentialEnvelope.tenant_id.is_(None))
else:
statement = statement.where(CredentialEnvelope.tenant_id == tenant_id)
if for_update:
statement = statement.with_for_update()
row = session.execute(statement).scalar_one_or_none()
if row is None:
raise CredentialEnvelopeError("Credential envelope not found")
return row
def credential_visible_to_context(row: CredentialEnvelope, context: CredentialAccessContext) -> bool:
if row.deleted_at is not None:
return False
if not _scope_visible(row, context):
return False
if row.allowed_modules and (not context.module_id or context.module_id not in row.allowed_modules):
return False
if row.allowed_server_refs and (not context.server_ref or context.server_ref not in row.allowed_server_refs):
return False
return True
def resolve_credential_envelope(
session: Session,
*,
credential_id: str,
context: CredentialAccessContext,
) -> ResolvedCredentialEnvelope:
row = get_credential_envelope(
session,
credential_id=credential_id,
context=context,
require_active=True,
)
return ResolvedCredentialEnvelope(
id=row.id,
name=row.name,
credential_kind=row.credential_kind,
public_data=dict(row.public_data or {}),
secret_data=_decrypt_secret_mapping(row.secret_data_encrypted),
revision=row.revision,
)
def credential_envelope_summary(row: CredentialEnvelope) -> dict[str, Any]:
public_data = redact_secret_values(dict(row.public_data or {}))
return {
"id": row.id,
"tenant_id": row.tenant_id,
"scope_type": row.scope_type,
"scope_id": row.scope_id,
"name": row.name,
"description": row.description,
"credential_kind": row.credential_kind,
"public_data": public_data,
"secret_keys": sorted(str(key) for key in (row.secret_keys or [])),
"secret_configured": bool(row.secret_data_encrypted),
"allowed_modules": sorted(str(item) for item in (row.allowed_modules or [])),
"allowed_server_refs": sorted(str(item) for item in (row.allowed_server_refs or [])),
"inherit_to_lower_scopes": bool(row.inherit_to_lower_scopes),
"is_active": bool(row.is_active),
"revision": row.revision,
"created_at": row.created_at,
"updated_at": row.updated_at,
"deleted_at": row.deleted_at,
}
def _scope_visible(row: CredentialEnvelope, context: CredentialAccessContext) -> bool:
target_type = str(context.target_scope_type or "tenant").strip().casefold()
target_id = _optional_text(context.target_scope_id)
if context.administrative:
return row.scope_type == "system" or row.tenant_id == context.tenant_id
if row.scope_type == "system":
return target_type == "system" or bool(row.inherit_to_lower_scopes)
if row.tenant_id != context.tenant_id:
return False
if row.scope_type == "tenant":
if target_type == "tenant":
return row.scope_id in {None, context.tenant_id, target_id}
return bool(row.inherit_to_lower_scopes)
if row.scope_type == "user":
return row.scope_id == context.user_id or (target_type == "user" and row.scope_id == target_id)
if row.scope_type == "group":
exact = row.scope_id in context.group_ids or (target_type == "group" and row.scope_id == target_id)
return exact and (target_type == "group" or bool(row.inherit_to_lower_scopes))
if row.scope_type == "campaign":
return target_type == "campaign" and row.scope_id == target_id
return False
def _record_credential_change(
session: Session,
*,
row: CredentialEnvelope,
operation: str,
user_id: str | None,
) -> None:
scope = "system" if row.scope_type == "system" else "tenant"
details = {
"scope_type": row.scope_type,
"scope_id": row.scope_id,
"credential_kind": row.credential_kind,
"allowed_modules": list(row.allowed_modules or []),
"allowed_server_refs": list(row.allowed_server_refs or []),
"secret_configured": bool(row.secret_data_encrypted),
}
record_change(
session,
module_id="core",
collection="core.security.credentials",
resource_type="credential_envelope",
resource_id=row.id,
operation=operation,
tenant_id=row.tenant_id,
actor_type="user" if user_id else None,
actor_id=user_id,
payload={"scope_type": row.scope_type, "credential_kind": row.credential_kind},
)
audit_event(
session,
tenant_id=row.tenant_id,
action=f"credential.{operation}",
scope=scope,
user_id=user_id,
object_type="credential_envelope",
object_id=row.id,
details=details,
)
def _encrypt_secret_mapping(value: Mapping[str, Any]) -> str | None:
if not value:
return None
return encrypt_secret(json.dumps(dict(value), separators=(",", ":"), sort_keys=True))
def _decrypt_secret_mapping(value: str | None) -> dict[str, Any]:
decrypted = decrypt_secret(value)
if not decrypted:
return {}
try:
parsed = json.loads(decrypted)
except json.JSONDecodeError as exc:
raise CredentialEnvelopeError("Stored credential payload is invalid") from exc
if not isinstance(parsed, dict):
raise CredentialEnvelopeError("Stored credential payload is invalid")
return parsed
def _normalize_kind(value: str) -> str:
normalized = _required_text(value, "Credential kind is required").casefold()
if normalized not in CREDENTIAL_KINDS:
raise CredentialEnvelopeError(f"Unsupported credential kind: {value!r}")
return normalized
def _normalized_values(values: list[str] | tuple[str, ...]) -> list[str]:
return sorted({_required_text(value, "Credential policy values cannot be blank") for value in values})
def _json_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
return dict(value or {})
def _safe_public_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
payload = _json_mapping(value)
unsafe_path = _secret_public_data_path(payload)
if unsafe_path:
raise CredentialEnvelopeError(
f"Credential public_data must not contain secret field {unsafe_path!r}"
)
return payload
def _secret_public_data_path(value: object, path: str = "public_data") -> str | None:
if isinstance(value, Mapping):
for key, item in value.items():
item_path = f"{path}.{key}"
if _is_secret_public_key(key):
return item_path
nested_path = _secret_public_data_path(item, item_path)
if nested_path:
return nested_path
elif isinstance(value, list):
for index, item in enumerate(value):
nested_path = _secret_public_data_path(item, f"{path}[{index}]")
if nested_path:
return nested_path
return None
def _is_secret_public_key(key: object) -> bool:
clean_key = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", str(key).strip())
clean_key = re.sub(r"[^A-Za-z0-9]+", "_", clean_key).strip("_").casefold()
if clean_key.endswith(("_id", "_ref", "_reference", "_type")):
return False
return clean_key in _SECRET_PUBLIC_DATA_KEYS or is_sensitive_key(key)
def _required_text(value: object | None, message: str) -> str:
clean = _optional_text(value)
if not clean:
raise CredentialEnvelopeError(message)
return clean
def _optional_text(value: object | None) -> str | None:
if value is None:
return None
clean = str(value).strip()
return clean or None
__all__ = [
"CREDENTIAL_KINDS",
"CREDENTIAL_SCOPE_TYPES",
"CredentialAccessContext",
"CredentialEnvelope",
"CredentialEnvelopeError",
"ResolvedCredentialEnvelope",
"create_credential_envelope",
"credential_envelope_summary",
"credential_visible_to_context",
"get_credential_envelope",
"get_managed_credential_envelope",
"list_credential_envelopes",
"list_managed_credential_envelopes",
"normalize_credential_scope",
"resolve_credential_envelope",
"retire_credential_envelope",
"update_credential_envelope",
]

View File

@@ -8,6 +8,7 @@ from govoplan_core.db.session import configure_database
from govoplan_core.server.config import GovoplanServerConfig, load_server_config
from govoplan_core.server.fastapi import create_govoplan_app
from govoplan_core.server.platform import create_platform_router
from govoplan_core.server.credentials import router as credential_router
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.server.route_validation import validate_no_route_collisions
@@ -67,6 +68,7 @@ def _server_api_router(server_config: GovoplanServerConfig, registry) -> APIRout
for router in server_config.base_routers:
api_router.include_router(router)
api_router.include_router(create_platform_router(settings=server_config.settings))
api_router.include_router(credential_router)
for router in server_config.post_module_routers:
api_router.include_router(router)
for contribution in server_config.extra_routers:

View File

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

View File

@@ -72,8 +72,19 @@ def _validate_production_startup() -> None:
"off",
}
if throttle_enabled and not os.getenv("REDIS_URL", "").strip():
allow_process_local = os.getenv(
"GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE",
"false",
).strip().lower() in {"1", "true", "yes", "on"}
if not allow_process_local:
raise RuntimeError(
"Production login throttling requires REDIS_URL. "
"Set GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE=true only "
"for an explicitly single-process deployment."
)
logger.warning(
"Redis is not configured; login throttling is process-local and will not coordinate horizontally"
"Redis is not configured; login throttling is explicitly limited "
"to this process"
)

View File

@@ -1,13 +1,20 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.exc import SQLAlchemyError
from govoplan_core.admin.models import SystemSettings
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.maintenance import saved_maintenance_mode
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem, PublicFrontendRoute
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleManifest, NavItem, PublicFrontendRoute
from govoplan_core.core.registry import PlatformRegistry, manifest_view_surfaces
from govoplan_core.core.views import (
VIEW_SURFACE_CONTRACT_VERSION,
ViewSurface,
navigation_view_surface_id,
route_view_surface_id,
)
from govoplan_core.db.session import get_database
from govoplan_core.i18n import system_i18n_payload
@@ -19,7 +26,7 @@ def _registry(request: Request) -> PlatformRegistry:
return registry
def _nav_item_payload(item: NavItem) -> dict[str, object]:
def _nav_item_payload(item: NavItem, module_id: str | None = None) -> dict[str, object]:
return {
"path": item.path,
"label": item.label,
@@ -28,16 +35,26 @@ def _nav_item_payload(item: NavItem) -> dict[str, object]:
"required_all": list(item.required_all),
"required_any": list(item.required_any),
"order": item.order,
"surface_id": (
item.surface_id or navigation_view_surface_id(module_id, item.path)
if module_id
else item.surface_id
),
}
def _frontend_route_payload(route: FrontendRoute) -> dict[str, object]:
def _frontend_route_payload(route: FrontendRoute, module_id: str | None = None) -> dict[str, object]:
return {
"path": route.path,
"component": route.component,
"required_all": list(route.required_all),
"required_any": list(route.required_any),
"order": route.order,
"surface_id": (
route.surface_id or route_view_surface_id(module_id, route.path)
if module_id
else route.surface_id
),
}
@@ -49,7 +66,29 @@ def _public_frontend_route_payload(route: PublicFrontendRoute) -> dict[str, obje
}
def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | None:
def _view_surface_payload(surface: ViewSurface) -> dict[str, object]:
return {
"id": surface.id,
"module_id": surface.module_id,
"kind": surface.kind,
"label": surface.label,
"parent_id": surface.parent_id,
"description": surface.description,
"order": surface.order,
"default_visible": surface.default_visible,
"required": surface.required,
}
def _frontend_view_surfaces(manifest: ModuleManifest) -> list[dict[str, object]]:
return [
_view_surface_payload(surface)
for surface in manifest_view_surfaces(manifest)
]
def _frontend_payload(manifest: ModuleManifest) -> dict[str, object] | None:
frontend = manifest.frontend
if frontend is None:
return None
return {
@@ -60,12 +99,14 @@ def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | No
"asset_manifest_public_key_id": frontend.asset_manifest_public_key_id,
"asset_manifest_integrity": frontend.asset_manifest_integrity,
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
"routes": [_frontend_route_payload(route) for route in frontend.routes],
"routes": [_frontend_route_payload(route, manifest.id) for route in frontend.routes],
"public_routes": [
_public_frontend_route_payload(route) for route in frontend.public_routes
],
"nav": [_nav_item_payload(item) for item in frontend.nav_items],
"settings_routes": [_frontend_route_payload(route) for route in frontend.settings_routes],
"nav": [_nav_item_payload(item, manifest.id) for item in frontend.nav_items],
"settings_routes": [_frontend_route_payload(route, manifest.id) for route in frontend.settings_routes],
"view_surface_contract_version": VIEW_SURFACE_CONTRACT_VERSION,
"view_surfaces": _frontend_view_surfaces(manifest),
}
@@ -113,7 +154,10 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
}
@router.get("/modules")
def modules(request: Request):
def modules(
request: Request,
_principal: ApiPrincipal = Depends(get_api_principal),
):
registry = _registry(request)
return {
"modules": [
@@ -125,8 +169,8 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
"optional_dependencies": list(manifest.optional_dependencies),
"enabled": True,
"runtime_ui_capabilities": _runtime_ui_capabilities(manifest.id, settings, registry),
"nav": [_nav_item_payload(item) for item in manifest.nav_items],
"frontend": _frontend_payload(manifest.frontend),
"nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items],
"frontend": _frontend_payload(manifest),
}
for manifest in registry.manifests()
]
@@ -150,7 +194,10 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
}
@router.get("/permissions")
def permissions(request: Request):
def permissions(
request: Request,
_principal: ApiPrincipal = Depends(get_api_principal),
):
registry = _registry(request)
return {
"permissions": [
@@ -170,7 +217,10 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
}
@router.get("/navigation")
def navigation(request: Request):
def navigation(
request: Request,
_principal: ApiPrincipal = Depends(get_api_principal),
):
registry = _registry(request)
return {
"items": [_nav_item_payload(item) for item in registry.nav_items()]

View File

@@ -3,6 +3,9 @@ from __future__ import annotations
from collections.abc import Callable, Iterable, Sequence
import importlib
from govoplan_core.core.access import (
DEFAULT_CAPABILITY_PROVIDERS,
)
from govoplan_core.core.discovery import discover_module_manifests
from govoplan_core.core.modules import ModuleManifest
from govoplan_core.core.registry import PlatformRegistry, RegistryError
@@ -15,7 +18,6 @@ _BUILTIN_MANIFESTS = {
"tenancy": "govoplan_tenancy.backend.manifest:get_manifest",
}
def parse_enabled_modules(value: str | Iterable[str]) -> list[str]:
if isinstance(value, str):
items = [item.strip() for item in value.split(",")]
@@ -57,22 +59,68 @@ def available_module_manifests(
return manifests
def build_platform_registry(enabled_modules: str | Iterable[str], *, manifest_factories: Sequence[ManifestFactory] = ()) -> PlatformRegistry:
def build_platform_registry(
enabled_modules: str | Iterable[str],
*,
manifest_factories: Sequence[ManifestFactory] = (),
) -> PlatformRegistry:
requested = parse_enabled_modules(enabled_modules)
if "access" not in requested:
requested.insert(0, "access")
available = available_module_manifests(manifest_factories, enabled_modules=requested)
available = _resolve_required_manifests(
requested,
manifest_factories=manifest_factories,
)
registry = PlatformRegistry()
for module_id in requested:
manifest = available.get(module_id)
if manifest is None:
raise RegistryError(f"Enabled module is not available: {module_id}")
for module_id, manifest in available.items():
registry.register(manifest)
registry.validate()
return registry
def _resolve_required_manifests(
requested: Sequence[str],
*,
manifest_factories: Sequence[ManifestFactory],
) -> dict[str, ModuleManifest]:
manifests: dict[str, ModuleManifest] = {}
pending = list(dict.fromkeys(requested))
while pending:
module_ids = [module_id for module_id in pending if module_id not in manifests]
pending = []
if not module_ids:
break
available = available_module_manifests(
manifest_factories,
enabled_modules=module_ids,
)
for module_id in module_ids:
manifest = available.get(module_id)
if manifest is None:
raise RegistryError(f"Enabled module is not available: {module_id}")
manifests[module_id] = manifest
provided_capabilities = {
capability
for manifest in manifests.values()
for capability in manifest.capability_factories
}
for manifest in tuple(manifests.values()):
pending.extend(
dependency_id
for dependency_id in manifest.dependencies
if dependency_id not in manifests
)
for capability in manifest.required_capabilities:
if capability in provided_capabilities:
continue
provider_id = DEFAULT_CAPABILITY_PROVIDERS.get(capability)
if provider_id is not None and provider_id not in manifests:
pending.append(provider_id)
return manifests
def _load_builtin_manifest(module_id: str) -> ModuleManifest:
target = _BUILTIN_MANIFESTS[module_id]
module_name, function_name = validate_object_path(target)

View File

@@ -6,18 +6,54 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=None, extra="ignore")
app_env: str = Field(default="dev", alias="APP_ENV")
module_live_apply_enabled: bool | None = Field(
default=None,
alias="GOVOPLAN_MODULE_LIVE_APPLY_ENABLED",
)
database_url: str = Field(
default="postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev",
alias="DATABASE_URL",
)
database_pool_size: int = Field(
default=5,
alias="GOVOPLAN_DB_POOL_SIZE",
ge=1,
le=100,
)
database_max_overflow: int = Field(
default=10,
alias="GOVOPLAN_DB_MAX_OVERFLOW",
ge=0,
le=200,
)
database_pool_timeout_seconds: int = Field(
default=30,
alias="GOVOPLAN_DB_POOL_TIMEOUT_SECONDS",
ge=1,
le=300,
)
database_pool_recycle_seconds: int = Field(
default=1800,
alias="GOVOPLAN_DB_POOL_RECYCLE_SECONDS",
ge=0,
le=86_400,
)
access_database_url: str | None = Field(default=None, alias="ACCESS_DATABASE_URL")
access_db_schema: str | None = Field(default=None, alias="ACCESS_DB_SCHEMA")
access_table_prefix: str = Field(default="access_", alias="ACCESS_TABLE_PREFIX")
tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE")
tenant_db_url_template: str | None = Field(default=None, alias="TENANT_DB_URL_TEMPLATE")
tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE")
enabled_modules: str = Field(default="tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,notifications,docs,ops", alias="ENABLED_MODULES")
enabled_modules: str = Field(
default=(
"tenancy,organizations,identity,idm,access,admin,dashboard,policy,"
"audit,campaigns,files,mail,calendar,poll,scheduling,connectors,"
"datasources,dataflow,workflow,views,search,risk_compliance,"
"postbox,notifications,docs,ops"
),
alias="ENABLED_MODULES",
)
migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK")
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
@@ -47,6 +83,33 @@ class Settings(BaseSettings):
auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE")
auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN")
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS")
auth_activity_touch_interval_seconds: int = Field(
default=5 * 60,
ge=0,
alias="AUTH_ACTIVITY_TOUCH_INTERVAL_SECONDS",
)
auth_principal_cache_enabled: bool = Field(
default=True,
alias="AUTH_PRINCIPAL_CACHE_ENABLED",
)
auth_principal_cache_session_ttl_seconds: int = Field(
default=30,
ge=0,
le=300,
alias="AUTH_PRINCIPAL_CACHE_SESSION_TTL_SECONDS",
)
auth_principal_cache_api_key_ttl_seconds: int = Field(
default=10,
ge=0,
le=300,
alias="AUTH_PRINCIPAL_CACHE_API_KEY_TTL_SECONDS",
)
auth_principal_cache_max_entries: int = Field(
default=2048,
ge=1,
le=100_000,
alias="AUTH_PRINCIPAL_CACHE_MAX_ENTRIES",
)
auth_login_throttle_enabled: bool = Field(default=True, alias="AUTH_LOGIN_THROTTLE_ENABLED")
auth_login_throttle_identity_limit: int = Field(
default=10,
@@ -70,12 +133,29 @@ class Settings(BaseSettings):
)
master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64")
celery_queues: str = Field(default="send_email,append_sent,notifications,calendar,default", alias="CELERY_QUEUES")
celery_queues: str = Field(
default=(
"send_email,append_sent,notifications,calendar,"
"dataflow,events,default"
),
alias="CELERY_QUEUES",
)
calendar_outbox_terminal_retention_days: int = Field(
default=90,
ge=0,
alias="CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS",
)
platform_event_outbox_max_attempts: int = Field(
default=8,
ge=1,
le=100,
alias="PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS",
)
platform_event_outbox_terminal_retention_days: int = Field(
default=90,
ge=0,
alias="PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS",
)
scheduling_cancellation_notice_days: int = Field(
default=30,
ge=1,

View File

@@ -46,12 +46,24 @@ def assert_tenant_governance_override_allowed(session: Session, *, field: str, v
raise AdminValidationError("Tenant governance cannot explicitly allow a capability denied by system settings.")
def _tenant_module_counts(session: Session, tenant_id: str) -> dict[str, int]:
def _tenant_module_counts(
session: Session,
tenant_id: str,
*,
module_ids: tuple[str, ...] | None = None,
) -> dict[str, int]:
registry = get_registry()
if registry is None or not hasattr(registry, "tenant_summary_providers"):
return {}
counts: dict[str, int] = {}
for provider in registry.tenant_summary_providers().values():
providers = registry.tenant_summary_providers()
if module_ids is not None:
providers = {
module_id: provider
for module_id, provider in providers.items()
if module_id in module_ids
}
for provider in providers.values():
provided = provider(session, tenant_id)
counts.update({str(key): int(value) for key, value in provided.items()})
return counts
@@ -67,17 +79,27 @@ def _access_administration() -> AccessAdministration | None:
return capability
def tenant_counts(session: Session, tenant_id: str) -> dict[str, int]:
module_counts = _tenant_module_counts(session, tenant_id)
def tenant_counts(
session: Session,
tenant_id: str,
*,
module_ids: tuple[str, ...] | None = None,
) -> dict[str, int]:
module_counts = _tenant_module_counts(
session,
tenant_id,
module_ids=module_ids,
)
access_administration = _access_administration()
access_counts = access_administration.tenant_counts(session, tenant_id) if access_administration is not None else {}
return {
**module_counts,
"users": int(access_counts.get("users", 0)),
"active_users": int(access_counts.get("active_users", 0)),
"groups": int(access_counts.get("groups", 0)),
"campaigns": module_counts.get("campaigns", 0),
"files": module_counts.get("files", 0),
"campaigns": int(module_counts.get("campaigns", 0)),
"files": int(module_counts.get("files", 0)),
"api_keys": int(access_counts.get("api_keys", 0)),
"active_api_keys": int(access_counts.get("active_api_keys", access_counts.get("api_keys", 0))),
}

View File

@@ -25,6 +25,7 @@ from govoplan_core.core.access import (
CAPABILITY_AUDIT_RECORDER,
CAPABILITY_AUDIT_RETENTION,
CAPABILITY_AUDIT_SINK,
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
CAPABILITY_TENANCY_TENANT_RESOLVER,
AccessDecision,
AccessAdministration,
@@ -1082,16 +1083,62 @@ class AccessContractTests(unittest.TestCase):
def get_organization_function_assignment(self, requested_assignment_id: str):
return assignment if requested_assignment_id == assignment_id else None
def organization_function_assignments_for_identity(self, requested_identity_id: str, *, tenant_id: str | None = None):
def organization_function_assignments_for_identity(
self,
requested_identity_id: str,
*,
tenant_id: str | None = None,
effective_at=None,
):
del effective_at
if requested_identity_id == identity_id and tenant_id in (None, assignment.tenant_id):
return (assignment,)
return ()
def organization_function_assignments_for_account(self, requested_account_id: str, *, tenant_id: str | None = None):
def organization_function_assignments_for_account(
self,
requested_account_id: str,
*,
tenant_id: str | None = None,
effective_at=None,
):
del effective_at
if requested_account_id == account_id and tenant_id in (None, assignment.tenant_id):
return (assignment,)
return ()
def organization_function_assignments_for_identities(
self,
identity_ids,
*,
tenant_id: str | None = None,
effective_at=None,
):
return {
item_id: self.organization_function_assignments_for_identity(
item_id,
tenant_id=tenant_id,
effective_at=effective_at,
)
for item_id in identity_ids
}
def organization_function_assignments_for_accounts(
self,
account_ids,
*,
tenant_id: str | None = None,
effective_at=None,
):
return {
item_id: self.organization_function_assignments_for_account(
item_id,
tenant_id=tenant_id,
effective_at=effective_at,
)
for item_id in account_ids
}
class FakeOrganizationDirectory(CoreOrganizationDirectory):
def get_organization_unit(self, requested_organization_unit_id: str):
if requested_organization_unit_id != organization_unit_id:
@@ -1317,7 +1364,7 @@ class AccessContractTests(unittest.TestCase):
clear_runtime()
shutil.rmtree(root, ignore_errors=True)
def test_api_principal_dependency_uses_access_resolver_capability(self) -> None:
def test_api_principal_dependency_uses_auth_provider_capability(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-access-contract-"))
try:
account_id = "account-capability"
@@ -1346,6 +1393,31 @@ class AccessContractTests(unittest.TestCase):
def explain_scope(self, principal: PrincipalRef, required_scope: str) -> AccessDecision:
return AccessDecision(allowed=self.has_scope(principal, required_scope), requirements=(required_scope,))
class CapabilityApiPrincipalProvider:
def resolve_api_principal(
self,
request,
session,
*,
authorization=None,
x_api_key=None,
):
del authorization, x_api_key
principal_ref = CapabilityPrincipalResolver().resolve_request(
request,
session=session,
)
account = session.get(Account, principal_ref.account_id)
user = session.get(User, principal_ref.membership_id)
assert account is not None
assert user is not None
return ApiPrincipal(
principal=principal_ref,
account=account,
user=user,
permission_evaluator=CapabilityPermissionEvaluator(),
)
router = APIRouter()
@router.get("/capability-principal")
@@ -1365,6 +1437,7 @@ class AccessContractTests(unittest.TestCase):
capability_factories={
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER: lambda context: CapabilityPrincipalResolver(),
CAPABILITY_ACCESS_PERMISSION_EVALUATOR: lambda context: CapabilityPermissionEvaluator(),
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER: lambda context: CapabilityApiPrincipalProvider(),
},
)
@@ -1372,7 +1445,7 @@ class AccessContractTests(unittest.TestCase):
title="access contract test",
version="test",
settings=SimpleNamespace(database_url=f"sqlite:///{root / 'test.db'}"),
enabled_modules=(),
enabled_modules=(ACCESS_MODULE_ID,),
manifest_factories=(access_manifest,),
base_routers=(router,),
post_module_routers=(),

View File

@@ -52,6 +52,7 @@ engine = _database.engine
SessionLocal = _database.SessionLocal
from govoplan_core.server.app import app
from govoplan_campaign.backend.sending.execution import SNAPSHOT_VERSION
class ApiSmokeTests(unittest.TestCase):
@@ -764,7 +765,7 @@ class ApiSmokeTests(unittest.TestCase):
assert version is not None
self.assertEqual(version.raw_json["server"], {"mail_profile_id": profile_id})
snapshot = version.execution_snapshot or {}
self.assertEqual(snapshot["snapshot_version"], "5")
self.assertEqual(snapshot["snapshot_version"], SNAPSHOT_VERSION)
self.assertEqual(snapshot["mail_profile_id"], profile_id)
self.assertNotIn("smtp", snapshot)
self.assertNotIn("imap", snapshot)
@@ -774,8 +775,10 @@ class ApiSmokeTests(unittest.TestCase):
deleted = self.client.delete(f"/api/v1/mail/profiles/{profile_id}", headers=headers)
self.assertEqual(deleted.status_code, 200, deleted.text)
self.assertFalse(deleted.json()["is_active"])
self.assertFalse(deleted.json()["smtp_password_configured"])
self.assertFalse(deleted.json()["imap_password_configured"])
# Reusable hierarchy credentials outlive a deactivated profile. The
# profile's retired legacy secret columns are still scrubbed below.
self.assertTrue(deleted.json()["smtp_password_configured"])
self.assertTrue(deleted.json()["imap_password_configured"])
from govoplan_audit.backend.db.models import AuditLog
@@ -864,7 +867,10 @@ class ApiSmokeTests(unittest.TestCase):
self.assertIsNotNone(version)
assert version is not None
snapshot_payload = version.execution_snapshot or {}
self.assertEqual(snapshot_payload["snapshot_version"], "5")
self.assertEqual(
snapshot_payload["snapshot_version"],
SNAPSHOT_VERSION,
)
self.assertEqual(snapshot_payload["mail_profile_id"], profile_id)
self.assertNotIn("smtp", snapshot_payload)
self.assertNotIn("imap", snapshot_payload)
@@ -2945,7 +2951,7 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(delta_payload["jobs"][0]["send_status"], "outcome_unknown")
self.assertEqual(
delta_payload["jobs"][0]["last_error"],
"SMTP delivery outcome requires operator reconciliation.",
"Delivery outcome requires operator reconciliation.",
)
self.assertEqual(delta_payload["counts"]["send"]["outcome_unknown"], 1)
self.assertTrue(str(delta_payload["watermark"]).startswith("seq:"))
@@ -4172,7 +4178,7 @@ class ApiSmokeTests(unittest.TestCase):
)
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, SendAttempt
from govoplan_mail.backend.db.models import MailServerProfile
from govoplan_mail.backend.db.models import MailServerEndpoint, MailServerProfile
with SessionLocal() as session:
version = session.get(CampaignVersion, version_id)
@@ -4180,7 +4186,7 @@ class ApiSmokeTests(unittest.TestCase):
assert version is not None
snapshot = version.execution_snapshot
self.assertIsInstance(snapshot, dict)
self.assertEqual(snapshot["snapshot_version"], "5")
self.assertEqual(snapshot["snapshot_version"], SNAPSHOT_VERSION)
self.assertEqual(snapshot["job_count"], 1)
self.assertEqual(snapshot["queueable_job_count"], 1)
self.assertTrue(snapshot["job_manifest_sha256"])
@@ -4195,9 +4201,17 @@ class ApiSmokeTests(unittest.TestCase):
profile = session.get(MailServerProfile, snapshot["mail_profile_id"])
self.assertIsNotNone(profile)
assert profile is not None
profile.smtp_config = {**profile.smtp_config, "host": "smtp.example.invalid"}
profile.smtp_transport_revision = "manually-rotated-transport-revision"
session.add(profile)
smtp_server_id = snapshot.get("smtp_server_id")
self.assertTrue(smtp_server_id)
smtp_server = session.get(MailServerEndpoint, smtp_server_id)
self.assertIsNotNone(smtp_server)
assert smtp_server is not None
smtp_server.config = {
**smtp_server.config,
"host": "smtp.example.invalid",
}
smtp_server.transport_revision = "manually-rotated-transport-revision"
session.add(smtp_server)
session.commit()
sent = self.client.post(
@@ -4211,18 +4225,17 @@ class ApiSmokeTests(unittest.TestCase):
"enqueue_imap_task": False,
},
)
self.assertEqual(sent.status_code, 200, sent.text)
self.assertEqual(sent.json()["result"]["sent_count"], 0)
self.assertEqual(sent.json()["result"]["failed_count"], 1)
self.assertIn("changed after this campaign was built", sent.json()["result"]["results"][0]["message"])
self.assertEqual(sent.status_code, 422, sent.text)
self.assertIn("Synchronous preflight stopped before contacting SMTP", sent.json()["detail"])
with SessionLocal() as session:
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
self.assertEqual(job.send_status, "failed_permanent")
attempt = session.query(SendAttempt).filter(SendAttempt.job_id == job.id).one()
self.assertIn("changed after this campaign was built", attempt.error_message)
self.assertNotIn("smtp.example.invalid", attempt.error_message)
self.assertIsNone(attempt.smtp_response)
self.assertEqual(job.queue_status, "draft")
self.assertEqual(job.send_status, "not_queued")
self.assertEqual(
session.query(SendAttempt).filter(SendAttempt.job_id == job.id).count(),
0,
)
def test_send_now_sends_exact_generated_eml_bytes(self) -> None:
headers, _ = self._login()
@@ -4286,11 +4299,15 @@ class ApiSmokeTests(unittest.TestCase):
"enqueue_imap_task": False,
},
)
self.assertEqual(sent.status_code, 200, sent.text)
self.assertEqual(sent.json()["result"]["failed_count"], 1)
self.assertIn("Generated EML", sent.json()["result"]["results"][0]["message"])
self.assertEqual(sent.status_code, 422, sent.text)
self.assertIn("Synchronous preflight stopped before contacting SMTP", sent.json()["detail"])
self.assertEqual(list_records(kind="smtp"), [])
with SessionLocal() as session:
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
self.assertEqual(job.queue_status, "draft")
self.assertEqual(job.send_status, "not_queued")
def test_partial_smtp_recipient_refusal_is_recorded_without_retrying_accepted_delivery(self) -> None:
headers, _ = self._login()
from govoplan_mail.backend.dev.mock_mailbox import set_failures
@@ -4349,7 +4366,8 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(sent.status_code, 200, sent.text)
result = sent.json()["result"]
self.assertEqual(result["sent_count"], 1, sent.text)
self.assertIn("refused recipients", result["results"][0]["message"])
self.assertEqual(result["results"][0]["status"], "smtp_accepted")
self.assertNotIn("message", result["results"][0])
finally:
set_failures(smtp_reject_recipients_containing=None)

View File

@@ -0,0 +1,96 @@
from __future__ import annotations
import unittest
from govoplan_core.core.automation import (
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
AutomationInvocation,
AutomationPrincipalProvider,
AutomationPrincipalRequest,
AutomationPrincipalResolution,
automation_principal_provider,
)
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
class _Provider:
def resolve_automation_principal(self, session, *, request):
del session
return AutomationPrincipalResolution(
allowed=True,
principal={"account_id": request.account_id},
granted_scopes=request.grant_scopes,
)
class AutomationContractTests(unittest.TestCase):
def test_principal_request_has_explicit_subject_contracts(self) -> None:
service = AutomationPrincipalRequest.service_account(
tenant_id="tenant-1",
service_account_id="service-1",
authorization_ref="trigger:1",
grant_scopes=("dataflow:pipeline:run",),
)
self.assertEqual("service_account", service.subject_kind)
self.assertEqual("service-1", service.service_account_id)
self.assertIsNone(service.account_id)
with self.assertRaisesRegex(
ValueError,
"Delegated-user automation",
):
AutomationPrincipalRequest(
tenant_id="tenant-1",
authorization_ref="trigger:1",
grant_scopes=("dataflow:pipeline:run",),
)
def test_invocation_records_stable_trigger_provenance(self) -> None:
invocation = AutomationInvocation(
kind="event",
trigger_ref="dataflow-trigger:1",
event_id="event-1",
event_type="files.uploaded",
)
self.assertEqual("event", invocation.kind)
self.assertEqual("event-1", invocation.event_id)
def test_principal_provider_is_runtime_resolved(self) -> None:
provider = _Provider()
self.assertIsInstance(provider, AutomationPrincipalProvider)
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="automation_contract_test",
name="Automation contract test",
version="test",
capability_factories={
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER: (
lambda context: provider
),
},
)
)
registry.configure_capability_context(
ModuleContext(registry=registry, settings=object())
)
self.assertIs(provider, automation_principal_provider(registry))
result = provider.resolve_automation_principal(
object(),
request=AutomationPrincipalRequest(
tenant_id="tenant-1",
account_id="account-1",
membership_id="membership-1",
authorization_ref="trigger:1",
grant_scopes=("dataflow:pipeline:run",),
),
)
self.assertTrue(result.allowed)
self.assertEqual(("dataflow:pipeline:run",), result.granted_scopes)
if __name__ == "__main__":
unittest.main()

View File

@@ -19,10 +19,25 @@ from govoplan_core.core.change_sequence import (
sequence_watermark_is_expired,
)
from govoplan_core.core.events import EventActorRef, EventBus, EventObjectRef, EventTenantRef, PlatformEvent, event_bus_context
from govoplan_core.core.runtime import (
clear_runtime,
configure_runtime,
get_runtime_context,
)
from govoplan_core.db.base import Base
class ChangeSequenceTests(unittest.TestCase):
def setUp(self) -> None:
self._runtime_context = get_runtime_context()
clear_runtime()
def tearDown(self) -> None:
if self._runtime_context is not None:
configure_runtime(self._runtime_context)
else:
clear_runtime()
def test_records_changes_and_returns_entries_after_watermark(self) -> None:
engine = create_engine("sqlite:///:memory:")
SessionLocal = sessionmaker(bind=engine)
@@ -93,6 +108,8 @@ class ChangeSequenceTests(unittest.TestCase):
actor_id="user-1",
payload={"scope_type": "tenant"},
)
self.assertEqual([], seen)
session.commit()
self.assertEqual([case[-1] for case in cases], [event.type for event in seen])
event = seen[1]
@@ -105,6 +122,36 @@ class ChangeSequenceTests(unittest.TestCase):
finally:
engine.dispose()
def test_record_change_discards_event_when_transaction_rolls_back(self) -> None:
engine = create_engine("sqlite:///:memory:")
SessionLocal = sessionmaker(bind=engine)
try:
Base.metadata.create_all(
bind=engine,
tables=[
ChangeSequenceEntry.__table__,
ChangeSequenceRetentionFloor.__table__,
],
)
seen: list[PlatformEvent] = []
bus = EventBus()
bus.subscribe("*", seen.append)
with SessionLocal() as session, event_bus_context(bus):
record_change(
session,
tenant_id="tenant-1",
module_id="files",
collection="files.assets",
resource_type="file",
resource_id="file-1",
operation="created",
)
session.rollback()
self.assertEqual([], seen)
finally:
engine.dispose()
def test_pruning_records_retention_floor_for_stale_watermarks(self) -> None:
engine = create_engine("sqlite:///:memory:")
SessionLocal = sessionmaker(bind=engine)

View File

@@ -6,6 +6,7 @@ from fastapi import APIRouter, Response
from fastapi.responses import PlainTextResponse
from fastapi.testclient import TestClient
from govoplan_core.auth import get_api_principal
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.server.fastapi import create_govoplan_app
from govoplan_core.server.platform import create_platform_router
@@ -85,6 +86,7 @@ class ConditionalRequestTests(unittest.TestCase):
registry=PlatformRegistry(),
api_router=api_router,
)
app.dependency_overrides[get_api_principal] = lambda: object()
with TestClient(app) as client:
for path in (

View File

@@ -8,15 +8,19 @@ from unittest.mock import patch
from fastapi import APIRouter, Request
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_event, audit_operation_context
from govoplan_core.core.events import (
DurableEventConsumer,
EventActorRef,
EventBus,
EventObjectRef,
EventTenantRef,
PlatformEvent,
current_event_trace,
emit_platform_event,
event_bus_context,
event_context,
normalize_trace_id,
@@ -31,13 +35,96 @@ from tests.db_isolation import temporary_database
def _configure_audit_runtime() -> None:
registry = build_platform_registry(("audit",))
registry = build_platform_registry(("access", "audit"))
context = ModuleContext(registry=registry, settings=object())
registry.configure_capability_context(context)
configure_runtime(context)
class CoreEventTests(unittest.TestCase):
def test_durable_consumer_requires_policy_for_classified_events(self) -> None:
with self.assertRaisesRegex(ValueError, "policy decision"):
DurableEventConsumer(
consumer_id="workflow.triggers.v1",
classifications=frozenset({"restricted"}),
handler=lambda _event, _delivery_key: None,
)
consumer = DurableEventConsumer(
consumer_id="workflow.triggers.v1",
event_types=frozenset({"case.changed"}),
classifications=frozenset(
{"internal", "confidential"}
),
policy_decision_ref="policy:decision:1",
handler=lambda _event, _delivery_key: None,
)
accepted = PlatformEvent(
type="case.changed",
module_id="cases",
classification="confidential",
event_id="event-1",
)
rejected = PlatformEvent(
type="case.deleted",
module_id="cases",
classification="confidential",
)
self.assertTrue(consumer.accepts(accepted))
self.assertFalse(consumer.accepts(rejected))
self.assertEqual(
"event-1:workflow.triggers.v1",
consumer.delivery_key(accepted),
)
def test_fallback_event_waits_for_outer_commit_after_savepoint(self) -> None:
engine = create_engine("sqlite:///:memory:")
seen: list[PlatformEvent] = []
bus = EventBus()
bus.subscribe("*", seen.append)
try:
with Session(engine) as session, event_bus_context(bus):
with session.begin():
with session.begin_nested():
emit_platform_event(
session,
PlatformEvent(type="nested.created", module_id="core"),
)
self.assertEqual([], seen)
self.assertEqual(
["nested.created"],
[event.type for event in seen],
)
finally:
engine.dispose()
def test_fallback_event_discards_only_rolled_back_savepoint(self) -> None:
engine = create_engine("sqlite:///:memory:")
seen: list[PlatformEvent] = []
bus = EventBus()
bus.subscribe("*", seen.append)
try:
with Session(engine) as session, event_bus_context(bus):
with session.begin():
emit_platform_event(
session,
PlatformEvent(type="outer.created", module_id="core"),
)
savepoint = session.begin_nested()
emit_platform_event(
session,
PlatformEvent(type="nested.created", module_id="core"),
)
savepoint.rollback()
self.assertEqual([], seen)
self.assertEqual(
["outer.created"],
[event.type for event in seen],
)
finally:
engine.dispose()
def test_event_bus_adds_trace_ids_and_propagates_causation_to_nested_events(self) -> None:
bus = EventBus()
seen: list[PlatformEvent] = []
@@ -267,6 +354,11 @@ class CoreEventTests(unittest.TestCase):
object_id="user-2",
details={"password": "secret", "field": "display_name"},
)
session.commit()
from govoplan_audit.backend.outbox import SqlAuditOutbox
SqlAuditOutbox().dispatch_pending(session)
session.commit()
action_events = [event for event in seen if event.type == "user.updated"]
self.assertEqual(1, len(action_events))
@@ -315,6 +407,11 @@ class CoreEventTests(unittest.TestCase):
object_type="demo",
object_id=action,
)
session.commit()
from govoplan_audit.backend.outbox import SqlAuditOutbox
SqlAuditOutbox().dispatch_pending(session)
session.commit()
by_type = {event.type: event.module_id for event in seen if event.type in cases}
self.assertEqual(cases, by_type)

View File

@@ -0,0 +1,245 @@
from __future__ import annotations
import json
import unittest
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.security.credential_envelopes import (
CredentialAccessContext,
CredentialEnvelope,
CredentialEnvelopeError,
create_credential_envelope,
credential_envelope_summary,
credential_visible_to_context,
list_managed_credential_envelopes,
resolve_credential_envelope,
update_credential_envelope,
)
from govoplan_core.security.secrets import encrypt_secret
from govoplan_core.server.credentials import _target_scope
def _principal(scopes: set[str]) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id="account-1",
membership_id="user-1",
tenant_id="tenant-1",
scopes=frozenset(scopes),
),
account=object(),
user=object(),
)
class CredentialEnvelopeTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
ChangeSequenceEntry.__table__.create(self.engine)
CredentialEnvelope.__table__.create(self.engine)
def tearDown(self) -> None:
self.engine.dispose()
def test_inherited_credential_is_filtered_by_module_and_server(self) -> None:
row = CredentialEnvelope(
id="credential-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Shared account",
credential_kind="username_password",
public_data={"username": "service@example.org"},
secret_data_encrypted=encrypt_secret(json.dumps({"password": "not-returned"})),
secret_keys=["password"],
allowed_modules=["mail"],
allowed_server_refs=["mail:server-1"],
inherit_to_lower_scopes=True,
is_active=True,
revision="revision-1",
)
allowed = CredentialAccessContext(
tenant_id="tenant-1",
user_id="user-1",
target_scope_type="user",
target_scope_id="user-1",
module_id="mail",
server_ref="mail:server-1",
)
wrong_module = CredentialAccessContext(
tenant_id="tenant-1",
user_id="user-1",
target_scope_type="user",
target_scope_id="user-1",
module_id="calendar",
server_ref="mail:server-1",
)
wrong_server = CredentialAccessContext(
tenant_id="tenant-1",
user_id="user-1",
target_scope_type="user",
target_scope_id="user-1",
module_id="mail",
server_ref="mail:server-2",
)
self.assertTrue(credential_visible_to_context(row, allowed))
self.assertFalse(credential_visible_to_context(row, wrong_module))
self.assertFalse(credential_visible_to_context(row, wrong_server))
self.assertNotIn("secret_data_encrypted", credential_envelope_summary(row))
def test_resolution_returns_secret_only_after_access_check(self) -> None:
with Session(self.engine) as session:
session.add(
CredentialEnvelope(
id="credential-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Shared account",
credential_kind="username_password",
public_data={"username": "service@example.org"},
secret_data_encrypted=encrypt_secret(json.dumps({"password": "secret"})),
secret_keys=["password"],
allowed_modules=["mail"],
allowed_server_refs=[],
inherit_to_lower_scopes=True,
is_active=True,
revision="revision-1",
)
)
session.commit()
resolved = resolve_credential_envelope(
session,
credential_id="credential-1",
context=CredentialAccessContext(
tenant_id="tenant-1",
user_id="user-1",
target_scope_type="user",
target_scope_id="user-1",
module_id="mail",
),
)
self.assertEqual(resolved.public_data["username"], "service@example.org")
self.assertEqual(resolved.secret_data["password"], "secret")
with self.assertRaises(CredentialEnvelopeError):
resolve_credential_envelope(
session,
credential_id="credential-1",
context=CredentialAccessContext(
tenant_id="tenant-2",
user_id="user-2",
target_scope_type="user",
target_scope_id="user-2",
module_id="mail",
),
)
def test_management_listing_stays_in_tenant_but_ignores_use_site_limits(self) -> None:
with Session(self.engine) as session:
session.add_all(
[
CredentialEnvelope(
id="tenant-credential",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Tenant restricted",
credential_kind="username_password",
public_data={},
secret_keys=[],
allowed_modules=["calendar"],
allowed_server_refs=["calendar:source-1"],
inherit_to_lower_scopes=True,
is_active=True,
revision="revision-1",
),
CredentialEnvelope(
id="other-credential",
tenant_id="tenant-2",
scope_type="tenant",
scope_id="tenant-2",
name="Other tenant",
credential_kind="username_password",
public_data={},
secret_keys=[],
allowed_modules=[],
allowed_server_refs=[],
inherit_to_lower_scopes=False,
is_active=True,
revision="revision-2",
),
]
)
session.commit()
rows = list_managed_credential_envelopes(
session,
tenant_id="tenant-1",
)
self.assertEqual([row.id for row in rows], ["tenant-credential"])
def test_tenant_credential_permission_cannot_manage_system_scope(self) -> None:
tenant_principal = _principal({"access:credential:write"})
with self.assertRaises(HTTPException) as raised:
_target_scope(
tenant_principal,
scope_type="system",
scope_id=None,
write=True,
)
self.assertEqual(raised.exception.status_code, 403)
system_principal = _principal({"access:system_credential:write"})
self.assertEqual(
_target_scope(
system_principal,
scope_type="system",
scope_id=None,
write=True,
),
(None, "system", None),
)
def test_public_data_rejects_secrets_and_kind_changes_require_secret_decision(self) -> None:
with Session(self.engine) as session:
with self.assertRaisesRegex(CredentialEnvelopeError, "public_data"):
create_credential_envelope(
session,
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Unsafe",
credential_kind="username_password",
public_data={"nested": [{"clientSecret": "not-public"}]},
)
row = create_credential_envelope(
session,
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Safe",
credential_kind="username_password",
public_data={"username": "service@example.org"},
secret_data={"password": "secret"},
)
with self.assertRaisesRegex(CredentialEnvelopeError, "requires replacing"):
update_credential_envelope(
session,
row,
credential_kind="token",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -131,8 +131,15 @@ class DatabaseMigrationTests(unittest.TestCase):
with tempfile.TemporaryDirectory(prefix="govoplan-core-baseline-test-") as directory:
database = Path(directory) / "core.db"
url = f"sqlite:///{database}"
enabled_modules = ("access",)
command.upgrade(alembic_config(database_url=url, enabled_modules=()), "heads")
command.upgrade(
alembic_config(
database_url=url,
enabled_modules=enabled_modules,
),
"heads",
)
engine = create_engine(url)
try:
@@ -157,7 +164,13 @@ class DatabaseMigrationTests(unittest.TestCase):
).scalar_one()
current = database_migration_heads(connection)
self.assertEqual(current, configured_migration_heads(url, enabled_modules=()))
self.assertEqual(
current,
configured_migration_heads(
url,
enabled_modules=enabled_modules,
),
)
self.assertIn("core_scopes", tables)
self.assertNotIn("tenancy_tenants", tables)
self.assertIn("access_accounts", tables)
@@ -232,7 +245,7 @@ class DatabaseMigrationTests(unittest.TestCase):
self.assertEqual(current, configured_migration_heads(url, migration_track="dev"))
self.assertEqual(result.current_revision, ",".join(sorted(current)))
self.assertIn("0f1e2d3c4b5a", current)
self.assertIn("core_credential_envelopes", tables)
self.assertIn("audit_outbox_events", tables)
self.assertIn("calendar_outbox_operations", tables)
self.assertIn("file_connector_profiles", tables)

View File

@@ -0,0 +1,76 @@
from __future__ import annotations
import unittest
from govoplan_core.core.dataflows import (
CAPABILITY_DATAFLOW_RUN_LIFECYCLE,
DataflowRunDescriptor,
DataflowRunLifecycleProvider,
dataflow_run_lifecycle,
)
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
class _Provider:
def start_run(self, session, principal, *, request):
del session, principal
return DataflowRunDescriptor(
ref="dataflow-run:1",
pipeline_ref=request.pipeline_ref,
revision=request.revision,
status="succeeded",
definition_hash="abc",
executor_version="test",
)
def get_run(self, session, principal, *, run_ref):
del session, principal
if run_ref != "dataflow-run:1":
return None
return DataflowRunDescriptor(
ref=run_ref,
pipeline_ref="pipeline:1",
revision=1,
status="succeeded",
definition_hash="abc",
executor_version="test",
)
def cancel_run(self, session, principal, *, run_ref):
del session, principal
return DataflowRunDescriptor(
ref=run_ref,
pipeline_ref="pipeline:1",
revision=1,
status="cancelled",
definition_hash="abc",
executor_version="test",
)
class DataflowContractTests(unittest.TestCase):
def test_run_lifecycle_is_runtime_checkable_and_resolved(self) -> None:
provider = _Provider()
self.assertIsInstance(provider, DataflowRunLifecycleProvider)
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="dataflow_contract_test",
name="Dataflow contract test",
version="test",
capability_factories={
CAPABILITY_DATAFLOW_RUN_LIFECYCLE: lambda context: provider,
},
)
)
registry.configure_capability_context(
ModuleContext(registry=registry, settings=object())
)
self.assertIs(provider, dataflow_run_lifecycle(registry))
self.assertIsNone(dataflow_run_lifecycle(PlatformRegistry()))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
import unittest
from unittest.mock import MagicMock, patch
from govoplan_core.celery_app import celery, dispatch_dataflow_triggers
class DataflowTriggerWorkerTests(unittest.TestCase):
def test_worker_commits_dispatch_outcome(self) -> None:
session = MagicMock()
database = MagicMock()
database.SessionLocal.return_value.__enter__.return_value = session
provider = MagicMock()
provider.dispatch_due.return_value = {
"queued": 1,
"processed": 1,
"succeeded": 1,
"failed": 0,
"blocked": 0,
"skipped": 0,
}
with (
patch(
"govoplan_core.celery_app._dataflow_trigger_dispatcher",
return_value=provider,
),
patch(
"govoplan_core.db.session.get_database",
return_value=database,
),
):
result = dispatch_dataflow_triggers.run(25)
provider.dispatch_due.assert_called_once_with(session, limit=25)
session.commit.assert_called_once_with()
self.assertEqual(result["succeeded"], 1)
def test_worker_route_and_periodic_dispatch_are_registered(self) -> None:
self.assertEqual(
celery.conf.task_routes["govoplan.dataflow.dispatch_triggers"],
{"queue": "dataflow"},
)
schedule = celery.conf.beat_schedule[
"dataflow-triggers-every-minute"
]
self.assertEqual(
schedule["task"],
"govoplan.dataflow.dispatch_triggers",
)
self.assertEqual(schedule["schedule"], 60.0)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,186 @@
from __future__ import annotations
import unittest
from govoplan_core.core.datasources import (
CAPABILITY_DATASOURCE_CATALOGUE,
CAPABILITY_DATASOURCE_LIFECYCLE,
CAPABILITY_DATASOURCE_ORIGINS,
CAPABILITY_DATASOURCE_PUBLICATION,
DatasourceCatalogueProvider,
DatasourceDescriptor,
DatasourceField,
DatasourceLifecycleProvider,
DatasourceMaterialization,
DatasourceOrigin,
DatasourceOriginProvider,
DatasourceOriginReadResult,
DatasourcePublicationProvider,
DatasourcePublicationResult,
DatasourceReadResult,
DatasourceStage,
datasource_catalogue,
datasource_lifecycle,
datasource_origins,
datasource_publication,
)
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
class _Provider:
descriptor = DatasourceDescriptor(
ref="datasource:source-1",
source_name="monthly_cases",
name="Monthly cases",
kind="upload",
mode="static",
shape="tabular",
schema=(DatasourceField(name="case_id", data_type="string", nullable=False),),
fingerprint="abc123",
)
def list_datasources(self, session, principal, *, query="", limit=100):
del session, principal, query
return (self.descriptor,)[:limit]
def get_datasource(self, session, principal, *, datasource_ref):
del session, principal
return self.descriptor if datasource_ref == self.descriptor.ref else None
def read_datasource(self, session, principal, *, request):
del session, principal, request
return DatasourceReadResult(
datasource=self.descriptor,
rows=({"case_id": "0012"},),
total_rows=1,
truncated=False,
)
def list_materializations(self, session, principal, *, datasource_ref):
del session, principal, datasource_ref
return ()
def list_stages(self, session, principal, *, limit=100):
del session, principal, limit
return ()
def create_stage(self, session, principal, *, stage):
del session, principal, stage
return DatasourceStage(
ref="stage:1",
name="Stage",
source_name="stage",
kind="upload",
mode="static",
shape="tabular",
state="ready",
)
def promote_stage(self, session, principal, *, stage_ref, freeze=False, frozen_label=None):
del session, principal, stage_ref, freeze, frozen_label
return self.descriptor, DatasourceMaterialization(
ref="materialization:1",
datasource_ref=self.descriptor.ref,
revision=1,
state="published",
fingerprint=self.descriptor.fingerprint,
)
def register_origin(self, session, principal, **kwargs):
del session, principal, kwargs
return self.descriptor
def refresh_datasource(self, session, principal, *, datasource_ref):
del session, principal, datasource_ref
return self.promote_stage(object(), object(), stage_ref="stage:1")
def freeze_datasource(self, session, principal, *, datasource_ref, label=None):
del session, principal, datasource_ref, label
return self.promote_stage(object(), object(), stage_ref="stage:1")[1]
def retire_datasource(self, session, principal, *, datasource_ref):
del session, principal, datasource_ref
return self.descriptor
def publish_rows(self, session, principal, *, request):
del session, principal, request
return DatasourcePublicationResult(
ref="publication:1",
status="published",
datasource=self.descriptor,
materialization=DatasourceMaterialization(
ref="materialization:1",
datasource_ref=self.descriptor.ref,
revision=1,
state="published",
fingerprint=self.descriptor.fingerprint,
),
)
def list_origins(self, session, principal, *, query="", limit=100):
del session, principal, query
return (
DatasourceOrigin(
ref="origin:1",
source_name="origin",
name="Origin",
kind="database",
shape="tabular",
supported_modes=("live", "cached"),
provider="test",
),
)[:limit]
def get_origin(self, session, principal, *, origin_ref):
return self.list_origins(session, principal)[0] if origin_ref == "origin:1" else None
def read_origin(self, session, principal, *, request):
origin = self.get_origin(session, principal, origin_ref=request.origin_ref)
return DatasourceOriginReadResult(
origin=origin,
rows=({"id": 1},),
total_rows=1,
truncated=False,
)
class DatasourceContractTests(unittest.TestCase):
def test_capabilities_are_runtime_checkable_and_resolved_without_modules(self) -> None:
provider = _Provider()
self.assertIsInstance(provider, DatasourceCatalogueProvider)
self.assertIsInstance(provider, DatasourceLifecycleProvider)
self.assertIsInstance(provider, DatasourcePublicationProvider)
self.assertIsInstance(provider, DatasourceOriginProvider)
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="datasource_contract_test",
name="Datasource contract test",
version="test",
capability_factories={
CAPABILITY_DATASOURCE_CATALOGUE: lambda context: provider,
CAPABILITY_DATASOURCE_LIFECYCLE: lambda context: provider,
CAPABILITY_DATASOURCE_PUBLICATION: lambda context: provider,
CAPABILITY_DATASOURCE_ORIGINS: lambda context: provider,
},
)
)
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
self.assertIs(provider, datasource_catalogue(registry))
self.assertIs(provider, datasource_lifecycle(registry))
self.assertIs(provider, datasource_publication(registry))
self.assertIs(provider, datasource_origins(registry))
self.assertIsNone(datasource_catalogue(PlatformRegistry()))
def test_descriptor_distinguishes_mode_kind_shape_and_materialization(self) -> None:
descriptor = _Provider.descriptor
self.assertEqual("static", descriptor.mode)
self.assertEqual("upload", descriptor.kind)
self.assertEqual("tabular", descriptor.shape)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,79 @@
from __future__ import annotations
import unittest
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.policy import (
CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
DefinitionGovernancePolicy,
DefinitionGovernanceRequest,
DefinitionScopeRef,
PolicyDecision,
definition_governance_policy,
)
from govoplan_core.core.registry import PlatformRegistry
class _Policy:
def resolve_definition_action(self, *, request):
return PolicyDecision(
allowed=request.definition_kind != "template"
or request.action != "run",
)
class DefinitionGovernanceContractTests(unittest.TestCase):
def test_scope_paths_reuse_policy_provenance_format(self) -> None:
self.assertEqual("system", DefinitionScopeRef("system").path)
self.assertEqual(
"tenant:tenant-1",
DefinitionScopeRef("tenant", "tenant-1").path,
)
def test_policy_is_runtime_resolved(self) -> None:
provider = _Policy()
self.assertIsInstance(provider, DefinitionGovernancePolicy)
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="definition_governance_test",
name="Definition governance test",
version="test",
capability_factories={
CAPABILITY_POLICY_DEFINITION_GOVERNANCE: (
lambda context: provider
),
},
)
)
registry.configure_capability_context(
ModuleContext(registry=registry, settings=object())
)
resolved = definition_governance_policy(registry)
self.assertIs(provider, resolved)
decision = resolved.resolve_definition_action(
request=DefinitionGovernanceRequest(
module_id="dataflow",
definition_ref="pipeline:1",
tenant_id="tenant-1",
definition_scope=DefinitionScopeRef(
"tenant",
"tenant-1",
),
target_scope=DefinitionScopeRef("tenant", "tenant-1"),
definition_kind="template",
action="run",
actor=PrincipalRef(
account_id="account-1",
membership_id="membership-1",
tenant_id="tenant-1",
),
)
)
self.assertFalse(decision.allowed)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,145 @@
from __future__ import annotations
import unittest
from govoplan_core.core.definition_graphs import (
DefinitionEdge,
DefinitionGraphConstraints,
DefinitionGraphLibrary,
DefinitionNode,
DefinitionNodeCountConstraint,
DefinitionNodeType,
DefinitionPort,
validate_definition_graph,
)
def library(*, allow_cycles: bool) -> DefinitionGraphLibrary:
return DefinitionGraphLibrary(
id="test",
version="0.1.0",
category_labels={"control": "Control"},
node_types=(
DefinitionNodeType(
type="start",
category="control",
label="Start",
description="Start",
icon="play",
),
DefinitionNodeType(
type="step",
category="control",
label="Step",
description="Step",
icon="square",
input_ports=(DefinitionPort(id="input", label="Input"),),
),
DefinitionNodeType(
type="end",
category="control",
label="End",
description="End",
icon="circle-stop",
input_ports=(DefinitionPort(id="input", label="Input"),),
output_ports=(),
),
),
constraints=DefinitionGraphConstraints(
allow_cycles=allow_cycles,
node_counts=(
DefinitionNodeCountConstraint(
code="graph.start_count",
label="start",
minimum=1,
maximum=1,
node_types=("start",),
),
DefinitionNodeCountConstraint(
code="graph.end_count",
label="end",
minimum=1,
node_types=("end",),
),
),
),
)
class DefinitionGraphTests(unittest.TestCase):
def test_library_driven_ports_and_counts_validate_a_definition(self) -> None:
diagnostics = validate_definition_graph(
library(allow_cycles=False),
nodes=(
DefinitionNode(id="start", type="start"),
DefinitionNode(id="step", type="step"),
DefinitionNode(id="end", type="end"),
),
edges=(
DefinitionEdge(id="edge-1", source="start", target="step"),
DefinitionEdge(id="edge-2", source="step", target="end"),
),
)
self.assertEqual((), diagnostics)
def test_constraints_change_cycle_semantics_without_changing_graph_shape(self) -> None:
nodes = (
DefinitionNode(id="start", type="start"),
DefinitionNode(id="step", type="step"),
DefinitionNode(id="retry", type="step"),
DefinitionNode(id="end", type="end"),
)
edges = (
DefinitionEdge(id="edge-1", source="start", target="step"),
DefinitionEdge(id="edge-2", source="step", target="retry"),
DefinitionEdge(id="edge-3", source="retry", target="step"),
DefinitionEdge(id="edge-4", source="retry", target="end"),
)
dag_codes = {
item.code
for item in validate_definition_graph(
library(allow_cycles=False),
nodes=nodes,
edges=edges,
)
}
workflow_codes = {
item.code
for item in validate_definition_graph(
library(allow_cycles=True),
nodes=nodes,
edges=edges,
)
}
self.assertIn("graph.cycle", dag_codes)
self.assertNotIn("graph.cycle", workflow_codes)
def test_unknown_ports_and_missing_required_inputs_are_explicit(self) -> None:
diagnostics = validate_definition_graph(
library(allow_cycles=False),
nodes=(
DefinitionNode(id="start", type="start"),
DefinitionNode(id="end", type="end"),
),
edges=(
DefinitionEdge(
id="edge",
source="start",
target="end",
target_port="missing",
),
),
)
self.assertTrue(
{"edge.unknown_target_port", "node.input_required"}.issubset(
{item.code for item in diagnostics}
)
)
if __name__ == "__main__":
unittest.main()

View File

@@ -6,11 +6,92 @@ import sys
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from govoplan_core.devserver import redacted_database_url
from govoplan_core.devserver import build_reload_dirs, redacted_database_url
class DevserverSmokeTests(unittest.TestCase):
def test_reload_dirs_can_be_limited_to_selected_modules(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-reload-") as directory:
root = Path(directory)
core_root = root / "core"
calendar_root = root / "calendar"
mail_root = root / "mail"
extra_root = root / "extra"
for path in (core_root, calendar_root, mail_root, extra_root):
path.mkdir()
manifests = (
SimpleNamespace(id="calendar"),
SimpleNamespace(id="mail"),
)
config = SimpleNamespace(enabled_modules=(), manifest_factories=())
registry = SimpleNamespace(manifests=lambda: manifests)
def manifest_roots(manifest: object) -> tuple[Path, ...]:
return (
calendar_root
if getattr(manifest, "id") == "calendar"
else mail_root,
)
with (
patch(
"govoplan_core.devserver._config_source_roots",
return_value=(core_root,),
),
patch(
"govoplan_core.devserver._entry_point_source_roots",
return_value=(),
),
patch(
"govoplan_core.devserver._manifest_source_roots",
side_effect=manifest_roots,
),
):
broad = build_reload_dirs(config, registry=registry)
focused = build_reload_dirs(
config,
registry=registry,
module_ids=("calendar",),
extra_dirs=(str(extra_root),),
)
core_only = build_reload_dirs(
config,
registry=registry,
module_ids=(),
)
self.assertEqual(
{str(core_root), str(calendar_root), str(mail_root)},
set(broad),
)
self.assertEqual(
{str(core_root), str(calendar_root), str(extra_root)},
set(focused),
)
self.assertEqual([str(core_root)], core_only)
def test_reload_dirs_reject_disabled_module_selector(self) -> None:
config = SimpleNamespace(enabled_modules=(), manifest_factories=())
registry = SimpleNamespace(
manifests=lambda: (SimpleNamespace(id="calendar"),)
)
with (
patch(
"govoplan_core.devserver._config_source_roots",
return_value=(),
),
self.assertRaisesRegex(SystemExit, "mail"),
):
build_reload_dirs(
config,
registry=registry,
module_ids=("mail",),
)
def test_database_url_redaction_hides_passwords(self) -> None:
rendered = redacted_database_url(
"postgresql+psycopg://govoplan:database-secret@db.example.test/govoplan"

View File

@@ -0,0 +1,36 @@
from __future__ import annotations
import unittest
from govoplan_core.core.external_references import (
ExternalObjectReference,
ExternalReferenceValidationError,
)
class ExternalReferenceContractTests(unittest.TestCase):
def test_reference_has_stable_identity_and_ordered_maturity(self) -> None:
reference = ExternalObjectReference(
system="openproject-main",
object_type="work_package",
object_id="42",
maturity="synchronize",
canonical_url="https://projects.example.test/work_packages/42",
)
self.assertEqual("openproject-main:work_package:42", reference.identity_key)
self.assertTrue(reference.supports("read"))
self.assertFalse(reference.supports("replace"))
def test_reference_rejects_credentials_in_urls(self) -> None:
with self.assertRaises(ExternalReferenceValidationError):
ExternalObjectReference(
system="wiki",
object_type="page",
object_id="Main_Page",
canonical_url="https://user:secret@example.test/wiki/Main_Page",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -119,20 +119,66 @@ class _FakeIdmDirectory:
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
return self.assignment if assignment_id == self.assignment.id else None
def organization_function_assignments_for_account(self, account_id: str, *, tenant_id: str | None = None):
def organization_function_assignments_for_account(
self,
account_id: str,
*,
tenant_id: str | None = None,
effective_at=None,
):
del effective_at
if account_id != self.assignment.account_id:
return ()
if tenant_id is not None and tenant_id != self.assignment.tenant_id:
return ()
return (self.assignment,)
def organization_function_assignments_for_identity(self, identity_id: str, *, tenant_id: str | None = None):
def organization_function_assignments_for_identity(
self,
identity_id: str,
*,
tenant_id: str | None = None,
effective_at=None,
):
del effective_at
if identity_id != self.assignment.identity_id:
return ()
if tenant_id is not None and tenant_id != self.assignment.tenant_id:
return ()
return (self.assignment,)
def organization_function_assignments_for_identities(
self,
identity_ids,
*,
tenant_id: str | None = None,
effective_at=None,
):
return {
identity_id: self.organization_function_assignments_for_identity(
identity_id,
tenant_id=tenant_id,
effective_at=effective_at,
)
for identity_id in identity_ids
}
def organization_function_assignments_for_accounts(
self,
account_ids,
*,
tenant_id: str | None = None,
effective_at=None,
):
return {
account_id: self.organization_function_assignments_for_account(
account_id,
tenant_id=tenant_id,
effective_at=effective_at,
)
for account_id in account_ids
}
class IdentityOrganizationContractTests(unittest.TestCase):
def test_protocol_shapes_match_reference_implementations(self) -> None:

View File

@@ -21,6 +21,30 @@ class InstallConfigTests(unittest.TestCase):
with self.assertRaises(ValidationError):
Settings(CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS="-1")
def test_platform_event_outbox_limits_are_validated(self) -> None:
defaults = Settings()
self.assertEqual(defaults.platform_event_outbox_max_attempts, 8)
self.assertEqual(
defaults.platform_event_outbox_terminal_retention_days,
90,
)
configured = Settings(
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS="12",
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS="30",
)
self.assertEqual(configured.platform_event_outbox_max_attempts, 12)
self.assertEqual(
configured.platform_event_outbox_terminal_retention_days,
30,
)
for values in (
{"PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS": "0"},
{"PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS": "101"},
{"PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS": "-1"},
):
with self.subTest(values=values), self.assertRaises(ValidationError):
Settings(**values)
def test_scheduling_cancellation_notice_setting_is_bounded(self) -> None:
self.assertEqual(Settings().scheduling_cancellation_notice_days, 30)
self.assertEqual(

View File

@@ -230,7 +230,10 @@ class ModuleSystemTests(unittest.TestCase):
self.assertTrue({"access", "admin", "tenancy", "policy", "audit", "dashboard", "files", "mail", "campaigns", "docs", "ops"}.issubset(manifests))
self.assertEqual(manifests["campaigns"].dependencies, ())
self.assertTrue(manifests["campaigns"].required_capabilities)
self.assertEqual(manifests["campaigns"].optional_dependencies, ("files", "mail", "notifications", "addresses"))
self.assertEqual(
manifests["campaigns"].optional_dependencies,
("files", "mail", "notifications", "addresses", "postbox"),
)
self.assertEqual(manifests["dashboard"].dependencies, ())
self.assertTrue(manifests["dashboard"].required_capabilities)
self.assertEqual(manifests["docs"].dependencies, ())
@@ -587,7 +590,7 @@ class ModuleSystemTests(unittest.TestCase):
def test_enabled_module_permutations_register_expected_routes(self) -> None:
cases = (
("core_only", (), {"access"}, set()),
("core_only", (), set(), set()),
("files_only", ("files",), {"access", "files"}, {"/api/v1/files"}),
("mail_only", ("mail",), {"access", "mail"}, {"/api/v1/mail"}),
("campaign_without_files_or_mail", ("campaigns",), {"access", "campaigns"}, {"/api/v1/campaigns"}),
@@ -602,8 +605,14 @@ class ModuleSystemTests(unittest.TestCase):
app, _settings_obj = self._app_for_modules(enabled_modules)
route_paths = _route_paths(app)
self.assertIn("/api/v1/platform/modules", route_paths)
self.assertIn("/api/v1/auth/login", route_paths)
self.assertIn("/api/v1/admin/users", route_paths)
self.assertEqual(
"access" in expected_modules,
"/api/v1/auth/login" in route_paths,
)
self.assertEqual(
"access" in expected_modules,
"/api/v1/admin/users" in route_paths,
)
for prefix in ("/api/v1/campaigns", "/api/v1/files", "/api/v1/mail", "/api/v1/docs", "/api/v1/ops"):
has_prefix = any(path == prefix or path.startswith(f"{prefix}/") for path in route_paths)
self.assertEqual(prefix in expected_prefixes, has_prefix, f"{name}: {prefix}")
@@ -613,12 +622,19 @@ class ModuleSystemTests(unittest.TestCase):
self.assertEqual(health.status_code, 200, health.text)
self.assertEqual({"status": "ok"}, health.json())
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
payload_modules = {item["id"] for item in response.json()["modules"]}
self.assertEqual(
401 if "access" in expected_modules else 503,
response.status_code,
response.text,
)
payload_modules = {
item.id
for item in app.state.govoplan_registry.manifests()
}
self.assertEqual(expected_modules, payload_modules)
def test_governance_template_routes_are_contributed_by_admin_module(self) -> None:
access_only_app, _settings_obj = self._app_for_modules(())
access_only_app, _settings_obj = self._app_for_modules(("access",))
access_only_paths = _route_paths(access_only_app)
self.assertIn("/api/v1/admin/users", access_only_paths)
self.assertNotIn("/api/v1/admin/system/governance-templates", access_only_paths)
@@ -827,6 +843,37 @@ finally:
with self.assertRaisesRegex(RegistryError, "requires unavailable capability"):
registry.validate()
def test_registry_keeps_optional_auth_modules_access_free(self) -> None:
for modules in ((), ("poll",), ("evaluation",), ("scheduling",)):
with self.subTest(modules=modules):
registry = build_platform_registry(modules)
self.assertFalse(registry.has_module("access"))
def test_registry_prefers_selected_auth_provider_over_default(self) -> None:
provider = ModuleManifest(
id="alternative_auth",
name="Alternative auth",
version="test",
capability_factories={
"auth.principalResolver": lambda _context: object(),
},
)
consumer = ModuleManifest(
id="consumer",
name="Consumer",
version="test",
required_capabilities=("auth.principalResolver",),
)
registry = build_platform_registry(
("alternative_auth", "consumer"),
manifest_factories=(lambda: provider, lambda: consumer),
)
self.assertFalse(registry.has_module("access"))
self.assertTrue(registry.has_module("alternative_auth"))
self.assertTrue(registry.has_module("consumer"))
def test_registry_resolves_required_interface_ranges(self) -> None:
registry = PlatformRegistry()
registry.register(ModuleManifest(
@@ -903,7 +950,7 @@ finally:
plan = plan_desired_enabled_modules(("campaigns",), manifests)
self.assertEqual(("access", "admin", "campaigns"), plan.enabled_modules)
self.assertEqual((), plan.added_dependencies)
self.assertEqual(("access",), plan.added_dependencies)
def test_module_install_plan_is_saved_alongside_desired_state(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-install-plan-", dir=_TEST_ROOT))
@@ -917,9 +964,9 @@ finally:
"module_id": "files",
"action": "install",
"python_package": "govoplan-files",
"python_ref": "govoplan-files @ git+ssh://git.example.invalid/add-ideas/govoplan-files.git@v0.1.4",
"python_ref": "govoplan-files @ git+ssh://git.example.invalid/GovOPlaN/govoplan-files.git@v0.1.4",
"webui_package": "@govoplan/files-webui",
"webui_ref": "git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.1.4",
"webui_ref": "git+ssh://git.example.invalid/GovOPlaN/govoplan-files.git#v0.1.4",
}])
save_desired_enabled_modules(session, ("tenancy", "access", "files"))
session.commit()
@@ -931,8 +978,8 @@ finally:
self.assertEqual(("files",), tuple(item.module_id for item in saved.items))
self.assertEqual("manual", saved.items[0].source)
self.assertEqual((
"python -m pip install 'govoplan-files @ git+ssh://git.example.invalid/add-ideas/govoplan-files.git@v0.1.4'",
"npm pkg set 'dependencies.@govoplan/files-webui=git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.1.4' && npm install",
"python -m pip install 'govoplan-files @ git+ssh://git.example.invalid/GovOPlaN/govoplan-files.git@v0.1.4'",
"npm pkg set 'dependencies.@govoplan/files-webui=git+ssh://git.example.invalid/GovOPlaN/govoplan-files.git#v0.1.4' && npm install",
), module_install_plan_commands(saved))
def test_module_install_plan_preserves_catalog_source(self) -> None:
@@ -961,7 +1008,7 @@ finally:
"python_package": "govoplan-files",
"python_ref": "govoplan-files==0.2.0",
"webui_package": "@govoplan/files-webui",
"webui_ref": "git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.2.0",
"webui_ref": "git+ssh://git.example.invalid/GovOPlaN/govoplan-files.git#v0.2.0",
"data_safety_acknowledged": True,
})
plan = ModuleInstallPlan(items=(item,))
@@ -971,7 +1018,7 @@ finally:
self.assertTrue(item.as_dict()["data_safety_acknowledged"])
self.assertEqual((
"python -m pip install govoplan-files==0.2.0",
"npm pkg set 'dependencies.@govoplan/files-webui=git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.2.0' && npm install",
"npm pkg set 'dependencies.@govoplan/files-webui=git+ssh://git.example.invalid/GovOPlaN/govoplan-files.git#v0.2.0' && npm install",
), module_install_plan_commands(plan))
def test_admin_catalog_install_plan_records_catalog_provenance(self) -> None:
@@ -996,7 +1043,7 @@ finally:
"python_package": "govoplan-files",
"python_ref": "govoplan-files==0.1.6",
"webui_package": "@govoplan/files-webui",
"webui_ref": "git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.1.6",
"webui_ref": "git+ssh://git.example.invalid/GovOPlaN/govoplan-files.git#v0.1.6",
}],
}), encoding="utf-8")
@@ -3300,7 +3347,10 @@ finally:
"version_min": "0.2.0",
"version_max_exclusive": "0.3.0",
}, modules["campaigns"]["requires_interfaces"])
self.assertEqual(["files", "mail", "notifications", "addresses"], modules["campaigns"]["optional_dependencies"])
self.assertEqual(
["files", "mail", "notifications", "addresses", "postbox"],
modules["campaigns"]["optional_dependencies"],
)
self.assertEqual("requires_review", modules["files"]["migration_safety"])
self.assertIn("migration", modules["files"]["migration_notes"].lower())
self.assertEqual("0.1.9", modules["files"]["version"])
@@ -3946,8 +3996,10 @@ finally:
with TestClient(app) as client:
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
payload_modules = {item["id"] for item in response.json()["modules"]}
self.assertEqual(response.status_code, 401, response.text)
payload_modules = {
item.id for item in app.state.govoplan_registry.manifests()
}
self.assertEqual({"access", "files"}, payload_modules)
def test_create_app_ignores_saved_desired_modules_that_are_not_installed(self) -> None:
@@ -3979,8 +4031,10 @@ finally:
with TestClient(app) as client:
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
payload_modules = {item["id"] for item in response.json()["modules"]}
self.assertEqual(response.status_code, 401, response.text)
payload_modules = {
item.id for item in app.state.govoplan_registry.manifests()
}
self.assertEqual({"access", "files"}, payload_modules)
def test_create_app_preserves_admin_when_configured_and_saved_state_is_older(self) -> None:
@@ -4012,8 +4066,10 @@ finally:
with TestClient(app) as client:
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
payload_modules = {item["id"] for item in response.json()["modules"]}
self.assertEqual(response.status_code, 401, response.text)
payload_modules = {
item.id for item in app.state.govoplan_registry.manifests()
}
self.assertEqual({"access", "admin", "files"}, payload_modules)
def test_module_lifecycle_enables_and_disables_routes_without_restart(self) -> None:
@@ -4023,27 +4079,37 @@ finally:
with TestClient(app) as client:
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual({"access"}, {item["id"] for item in response.json()["modules"]})
self.assertEqual(response.status_code, 503, response.text)
self.assertEqual((), lifecycle.active_module_ids())
self.assertFalse("/api/v1/files" in _route_paths(app))
result = lifecycle.apply_enabled_modules(("files",), migrate=False)
self.assertEqual(("files",), result.activated_modules)
self.assertEqual(("access", "files"), result.activated_modules)
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual({"access", "files"}, {item["id"] for item in response.json()["modules"]})
self.assertEqual(response.status_code, 401, response.text)
self.assertEqual(("access", "files"), lifecycle.active_module_ids())
self.assertTrue("/api/v1/files" in _route_paths(app))
self.assertEqual(401, client.get("/api/v1/files").status_code)
result = lifecycle.apply_enabled_modules((), migrate=False)
self.assertEqual(("files",), result.deactivated_modules)
self.assertEqual(("access", "files"), result.deactivated_modules)
response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual({"access"}, {item["id"] for item in response.json()["modules"]})
self.assertEqual(response.status_code, 503, response.text)
self.assertEqual((), lifecycle.active_module_ids())
disabled_response = client.get("/api/v1/files")
self.assertEqual(404, disabled_response.status_code)
self.assertEqual("Module is disabled: files", disabled_response.json()["detail"])
def test_module_lifecycle_live_apply_defaults_by_environment(self) -> None:
app, settings = self._app_for_modules(())
lifecycle = app.state.govoplan_lifecycle
self.assertTrue(lifecycle.live_apply_enabled())
settings.app_env = "production"
self.assertFalse(lifecycle.live_apply_enabled())
settings.module_live_apply_enabled = True
self.assertTrue(lifecycle.live_apply_enabled())
def test_module_permutations_start_when_absent_modules_are_physically_unavailable(self) -> None:
cases = (
@@ -4073,10 +4139,9 @@ finally:
def test_module_migrations_are_registered_only_for_enabled_modules(self) -> None:
core_plan = migration_metadata_plan(build_platform_registry(()))
self.assertEqual(1, len(core_plan.script_locations))
self.assertTrue(core_plan.script_locations[0].endswith("govoplan_access/backend/migrations/versions"))
self.assertEqual(1, len(core_plan.metadata))
self.assertEqual(("access",), tuple(item.module_id for item in core_plan.modules))
self.assertEqual((), core_plan.script_locations)
self.assertEqual((), core_plan.metadata)
self.assertEqual((), core_plan.modules)
files_plan = migration_metadata_plan(build_platform_registry(("files",)))
self.assertEqual(2, len(files_plan.script_locations))

View File

@@ -0,0 +1,131 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
from govoplan_core.celery_app import (
celery,
dispatch_platform_events,
purge_platform_events,
)
from govoplan_core.core.events import PlatformEvent
class PlatformEventWorkerTests(unittest.TestCase):
def test_dispatch_uses_a_durable_dataflow_consumer_and_commits(self) -> None:
session = MagicMock()
database = MagicMock()
database.SessionLocal.return_value.__enter__.return_value = session
outbox = MagicMock()
outbox.dispatch_pending.return_value = {
"selected": 1,
"delivered": 1,
"retrying": 0,
"quarantined": 0,
"dispatched": 1,
"observer_failed": 0,
}
dataflow = MagicMock()
registry = MagicMock()
with (
patch(
"govoplan_core.celery_app._platform_registry",
return_value=registry,
),
patch(
"govoplan_core.celery_app._platform_event_outbox",
return_value=outbox,
),
patch(
"govoplan_core.celery_app._dataflow_trigger_dispatcher",
return_value=dataflow,
),
patch(
"govoplan_core.db.session.get_database",
return_value=database,
),
):
result = dispatch_platform_events.run(25)
call = outbox.dispatch_pending.call_args
self.assertEqual(session, call.args[0])
self.assertEqual(25, call.kwargs["limit"])
consumer = call.kwargs["consumers"][0]
self.assertEqual(
"dataflow.event-triggers.v1",
consumer.consumer_id,
)
self.assertEqual(
frozenset({"public", "internal"}),
consumer.classifications,
)
event = PlatformEvent(
type="files.file.created",
module_id="files",
)
consumer.handler(event, consumer.delivery_key(event))
dataflow.ingest_event.assert_called_once_with(
session,
event=event,
)
session.commit.assert_called_once_with()
self.assertEqual(1, result["delivered"])
def test_retention_task_uses_the_configured_terminal_window(self) -> None:
session = MagicMock()
database = MagicMock()
database.SessionLocal.return_value.__enter__.return_value = session
outbox = MagicMock()
outbox.purge_terminal.return_value = {"deleted": 2}
with (
patch(
"govoplan_core.celery_app._platform_event_outbox",
return_value=outbox,
),
patch(
"govoplan_core.db.session.get_database",
return_value=database,
),
patch(
"govoplan_core.celery_app.settings."
"platform_event_outbox_terminal_retention_days",
30,
),
):
result = purge_platform_events.run(75)
call = outbox.purge_terminal.call_args
self.assertEqual(session, call.args[0])
self.assertEqual(75, call.kwargs["limit"])
before = call.kwargs["before"]
self.assertIsInstance(before, datetime)
self.assertEqual(timezone.utc, before.tzinfo)
session.commit.assert_called_once_with()
self.assertEqual({"deleted": 2}, result)
def test_worker_routes_and_periodic_tasks_are_registered(self) -> None:
self.assertEqual(
{"queue": "events"},
celery.conf.task_routes[
"govoplan.events.dispatch_outbox"
],
)
self.assertEqual(
{"queue": "events"},
celery.conf.task_routes[
"govoplan.events.purge_outbox"
],
)
self.assertEqual(
"govoplan.events.purge_outbox",
celery.conf.beat_schedule[
"platform-event-retention-daily"
]["task"],
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,119 @@
from __future__ import annotations
import unittest
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_ACCESS,
CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_EVIDENCE,
CAPABILITY_POSTBOX_MESSAGES,
PostboxAccessProvider,
PostboxDeliveryProvider,
PostboxDirectoryProvider,
PostboxEvidenceProvider,
PostboxMessagesProvider,
postbox_access_provider,
postbox_delivery_provider,
postbox_directory_provider,
postbox_evidence_provider,
postbox_messages_provider,
)
from govoplan_core.core.registry import PlatformRegistry
class _PostboxProvider:
def list_visible_postboxes(self, session, *, tenant_id, actor):
del session, tenant_id, actor
return ()
def resolve_postbox(self, session, *, tenant_id, target, materialize=False):
del session, tenant_id, target, materialize
return None
def delivery_catalog(self, session, *, tenant_id):
del session, tenant_id
return None
def explain_access(self, session, *, tenant_id, postbox_id, actor, action):
del session, tenant_id, postbox_id, actor, action
return None
def list_messages(
self,
session,
*,
tenant_id,
postbox_ids,
actor,
limit=100,
offset=0,
):
del session, tenant_id, postbox_ids, actor, limit, offset
return ()
def get_message(self, session, *, tenant_id, message_id, actor):
del session, tenant_id, message_id, actor
return None
def mark_message(self, session, *, tenant_id, message_id, actor, state):
del session, tenant_id, message_id, actor, state
return None
def deliver(self, session, request):
del session, request
return None
def link_evidence(self, session, *, tenant_id, message_id, attachment):
del session, tenant_id, message_id, attachment
return None
class PostboxContractTests(unittest.TestCase):
def test_protocols_and_registry_helpers_resolve_without_module_imports(self) -> None:
provider = _PostboxProvider()
self.assertIsInstance(provider, PostboxDirectoryProvider)
self.assertIsInstance(provider, PostboxAccessProvider)
self.assertIsInstance(provider, PostboxMessagesProvider)
self.assertIsInstance(provider, PostboxDeliveryProvider)
self.assertIsInstance(provider, PostboxEvidenceProvider)
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="postbox_contract_test",
name="Postbox contract test",
version="test",
capability_factories={
name: lambda context: provider
for name in (
CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_ACCESS,
CAPABILITY_POSTBOX_MESSAGES,
CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_EVIDENCE,
)
},
)
)
registry.configure_capability_context(
ModuleContext(registry=registry, settings=object())
)
self.assertIs(provider, postbox_directory_provider(registry))
self.assertIs(provider, postbox_access_provider(registry))
self.assertIs(provider, postbox_messages_provider(registry))
self.assertIs(provider, postbox_delivery_provider(registry))
self.assertIs(provider, postbox_evidence_provider(registry))
empty = PlatformRegistry()
self.assertIsNone(postbox_directory_provider(empty))
self.assertIsNone(postbox_access_provider(empty))
self.assertIsNone(postbox_messages_provider(empty))
self.assertIsNone(postbox_delivery_provider(empty))
self.assertIsNone(postbox_evidence_provider(empty))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_core.core.change_sequence import (
ChangeSequenceEntry,
ChangeSequenceRetentionFloor,
prune_sequence_entries,
)
from govoplan_core.core.principal_cache import (
auth_principal_revision,
invalidate_auth_principals,
)
from govoplan_core.db.base import Base
class AuthPrincipalRevisionTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(
bind=self.engine,
tables=[
ChangeSequenceEntry.__table__,
ChangeSequenceRetentionFloor.__table__,
],
)
self.SessionLocal = sessionmaker(bind=self.engine)
def tearDown(self) -> None:
self.engine.dispose()
def test_revisions_are_tenant_scoped_and_include_system_changes(self) -> None:
with self.SessionLocal() as session:
initial = auth_principal_revision(session, tenant_id="tenant-1")
self.assertEqual(initial.system, 0)
self.assertEqual(initial.tenant, 0)
system_entry = invalidate_auth_principals(
session,
tenant_id=None,
source_module="access",
resource_type="system_role",
resource_id="role-1",
)
tenant_entry = invalidate_auth_principals(
session,
tenant_id="tenant-1",
source_module="idm",
resource_type="function_assignment",
resource_id="assignment-1",
)
invalidate_auth_principals(
session,
tenant_id="tenant-2",
source_module="access",
resource_type="role",
resource_id="role-2",
)
session.commit()
revision = auth_principal_revision(session, tenant_id="tenant-1")
self.assertEqual(revision.system, system_entry.id)
self.assertEqual(revision.tenant, tenant_entry.id)
def test_retention_floor_keeps_revision_monotonic_after_pruning(self) -> None:
with self.SessionLocal() as session:
entry = invalidate_auth_principals(
session,
tenant_id="tenant-1",
source_module="access",
resource_type="role",
resource_id="role-1",
)
session.flush()
expected_revision = entry.id
prune_sequence_entries(
session,
before_or_equal_id=expected_revision,
tenant_id="tenant-1",
module_id="core",
collections=("core.auth-principal-revisions",),
)
session.commit()
self.assertEqual(
auth_principal_revision(session, tenant_id="tenant-1").tenant,
expected_revision,
)
if __name__ == "__main__":
unittest.main()

View File

@@ -15,6 +15,55 @@ from govoplan_core.server.fastapi import create_govoplan_app
class QueryMetricsTests(TestCase):
def test_non_sqlite_engine_uses_bounded_pool_configuration(self) -> None:
configured = {
"GOVOPLAN_DB_POOL_SIZE": "8",
"GOVOPLAN_DB_MAX_OVERFLOW": "16",
"GOVOPLAN_DB_POOL_TIMEOUT_SECONDS": "45",
"GOVOPLAN_DB_POOL_RECYCLE_SECONDS": "900",
}
engine = object()
with (
patch.dict(os.environ, configured, clear=False),
patch(
"govoplan_core.db.session.create_engine",
return_value=engine,
) as create_engine,
patch(
"govoplan_core.db.session.instrument_engine",
side_effect=lambda value: value,
),
):
result = create_database_engine(
"postgresql+psycopg://govoplan@example/govoplan",
)
self.assertIs(engine, result)
create_engine.assert_called_once_with(
"postgresql+psycopg://govoplan@example/govoplan",
pool_pre_ping=True,
connect_args={},
pool_size=8,
max_overflow=16,
pool_timeout=45,
pool_recycle=900,
)
def test_non_sqlite_engine_rejects_pool_values_outside_safe_bounds(self) -> None:
invalid_values = (
("GOVOPLAN_DB_POOL_SIZE", "0"),
("GOVOPLAN_DB_MAX_OVERFLOW", "201"),
("GOVOPLAN_DB_POOL_TIMEOUT_SECONDS", "not-a-number"),
("GOVOPLAN_DB_POOL_RECYCLE_SECONDS", "86401"),
)
for name, value in invalid_values:
with self.subTest(name=name, value=value):
with patch.dict(os.environ, {name: value}, clear=False):
with self.assertRaisesRegex(ValueError, name):
create_database_engine(
"postgresql+psycopg://govoplan@example/govoplan",
)
def test_collect_query_metrics_counts_instrumented_engine_queries(self) -> None:
engine = create_database_engine("sqlite:///:memory:")
try:

View File

@@ -0,0 +1,173 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from govoplan_core.core.access import (
CAPABILITY_ACCESS_DIRECTORY,
AccountRef,
GroupRef,
UserRef,
)
from govoplan_core.core.references import (
access_scope_reference_options,
validate_access_scope_reference,
)
class _Directory:
users = (
UserRef(
id="membership-1",
account_id="account-1",
tenant_id="tenant-1",
email="ada@example.test",
display_name="Ada",
),
UserRef(
id="membership-2",
account_id="account-2",
tenant_id="tenant-1",
email="inactive@example.test",
display_name="Inactive",
status="inactive",
),
)
groups = (
GroupRef(id="group-1", tenant_id="tenant-1", name="Finance"),
GroupRef(
id="group-2",
tenant_id="tenant-1",
name="Former team",
status="inactive",
),
)
def get_account(self, account_id):
if account_id not in {"account-1", "account-2"}:
return None
return AccountRef(id=account_id, email=f"{account_id}@example.test")
def get_user(self, user_id):
return next((item for item in self.users if item.id == user_id), None)
def get_users(self, user_ids):
return {item.id: item for item in self.users if item.id in set(user_ids)}
def users_for_tenant(self, tenant_id):
return tuple(item for item in self.users if item.tenant_id == tenant_id)
def get_group(self, group_id):
return next((item for item in self.groups if item.id == group_id), None)
def get_groups(self, group_ids):
return {item.id: item for item in self.groups if item.id in set(group_ids)}
def groups_for_tenant(self, tenant_id):
return tuple(item for item in self.groups if item.tenant_id == tenant_id)
def groups_for_user(self, user_id, *, tenant_id):
del user_id
return self.groups_for_tenant(tenant_id)
def display_label(self, subject):
return subject.label
class _Registry:
directory = _Directory()
def has_capability(self, name):
return name == CAPABILITY_ACCESS_DIRECTORY
def capability(self, name):
return self.directory if self.has_capability(name) else None
class ReferenceOptionTests(unittest.TestCase):
def setUp(self) -> None:
self.principal = SimpleNamespace(
tenant_id="tenant-1",
account_id="account-1",
display_name="Ada",
email="ada@example.test",
group_ids=frozenset({"group-1"}),
)
def test_user_values_are_canonical_account_ids(self) -> None:
options = access_scope_reference_options(
_Registry(),
self.principal,
scope_type="user",
administrative=True,
)
self.assertEqual(["account-1", "account-2"], [item.value for item in options])
self.assertEqual("membership-1", options[0].provenance["membership_id"])
self.assertTrue(options[1].disabled)
self.assertEqual("inactive", options[1].availability)
def test_unavailable_selected_values_remain_readable(self) -> None:
options = access_scope_reference_options(
_Registry(),
self.principal,
scope_type="group",
query="missing",
selected_values=("removed-group",),
administrative=True,
)
self.assertEqual(1, len(options))
self.assertEqual("removed-group", options[0].value)
self.assertEqual("unavailable", options[0].availability)
self.assertTrue(options[0].disabled)
def test_non_administrators_only_receive_permitted_scope_targets(self) -> None:
users = access_scope_reference_options(
_Registry(),
self.principal,
scope_type="user",
administrative=False,
)
groups = access_scope_reference_options(
_Registry(),
self.principal,
scope_type="group",
administrative=False,
)
self.assertEqual(["account-1"], [item.value for item in users])
self.assertEqual(["group-1"], [item.value for item in groups])
def test_inactive_targets_are_rejected_but_existing_values_survive(self) -> None:
with self.assertRaisesRegex(ValueError, "Inactive groups"):
validate_access_scope_reference(
_Registry(),
tenant_id="tenant-1",
scope_type="group",
scope_id="group-2",
)
self.assertEqual(
"group-2",
validate_access_scope_reference(
_Registry(),
tenant_id="tenant-1",
scope_type="group",
scope_id="group-2",
preserve_existing="group-2",
),
)
def test_missing_optional_access_provider_keeps_reduced_mode(self) -> None:
options = access_scope_reference_options(
None,
self.principal,
scope_type="user",
)
self.assertEqual("account-1", options[0].value)
self.assertEqual("core", options[0].source_module)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,79 @@
from __future__ import annotations
from datetime import datetime, timezone
import hashlib
import unittest
from govoplan_core.core.sanctions import (
CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS,
SanctionsSnapshotPayload,
SanctionsSnapshotProvider,
SanctionsSnapshotReference,
sanctions_snapshot_provider,
)
class _Provider:
def list_snapshots(self, session, principal, *, limit=100):
del session, principal, limit
return ()
def get_snapshot(self, session, principal, *, snapshot_ref):
del session, principal, snapshot_ref
return None
def read_snapshot(self, session, principal, *, snapshot_ref):
del session, principal, snapshot_ref
raise LookupError
class _Registry:
def __init__(self, provider):
self.provider = provider
def has_capability(self, name):
return name == CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS
def capability(self, name):
return self.provider if self.has_capability(name) else None
class SanctionsContractTests(unittest.TestCase):
def test_snapshot_evidence_is_versioned_and_bounded(self) -> None:
content = b"<CONSOLIDATED_LIST/>"
snapshot = SanctionsSnapshotReference(
ref="sanctions-snapshot:snapshot-1",
tenant_id="tenant-1",
provider_id="un.security_council",
publisher="United Nations Security Council",
jurisdiction="UN",
list_type="consolidated",
source_id="unsc-consolidated",
source_version="2026-07-22",
publication_at=None,
effective_at=None,
acquired_at=datetime.now(timezone.utc),
content_type="application/xml",
byte_count=len(content),
sha256=hashlib.sha256(content).hexdigest(),
parser_version="unsc-xml-v1",
raw_evidence_ref="connector-evidence:snapshot-1",
connector_run_id="run-1",
)
payload = SanctionsSnapshotPayload(
snapshot=snapshot,
content=content,
)
provider = _Provider()
self.assertEqual(content, payload.content)
self.assertIsInstance(provider, SanctionsSnapshotProvider)
self.assertIs(
provider,
sanctions_snapshot_provider(_Registry(provider)),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,163 @@
from __future__ import annotations
import unittest
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.search import (
SearchAuthorizationRequest,
SearchBackfillPage,
SearchBackfillRequest,
SearchDocument,
SearchProvider,
SearchProviderRegistration,
SearchQuery,
SearchResourceReference,
SearchResourceType,
SearchResult,
SearchSourceProvider,
SearchSourceProviderRegistration,
)
class _Provider:
def search(self, session, principal, *, query):
del session, principal
return (
SearchResult(
provider_id="test",
module_id="test",
resource_type="record",
resource_id="1",
title=query.text,
url="/test/1",
),
)
class _Source:
def resource_types(self):
return (
SearchResourceType(
provider_id="test.records",
module_id="test",
resource_type="record",
label="Records",
),
)
def backfill(self, session, *, request):
del session
return SearchBackfillPage(
documents=(),
next_cursor=None,
complete=True,
high_watermark=request.cursor,
)
def authorize(self, session, principal, *, requests):
del session, principal
return {
request.reference.key: True
for request in requests
}
class SearchContractTests(unittest.TestCase):
def test_query_and_restricted_document_are_bounded(self) -> None:
query = SearchQuery(text=" permit ", tenant_id="tenant-1", limit=20)
document = SearchDocument(
tenant_id="tenant-1",
module_id="cases",
resource_type="case",
resource_id="case-1",
title="Permit",
url="/cases/case-1",
acl_tokens=("account:account-1",),
)
self.assertEqual("permit", query.text)
self.assertEqual("restricted", document.visibility)
with self.assertRaises(ValueError):
SearchDocument(
tenant_id="tenant-1",
module_id="cases",
resource_type="case",
resource_id="case-1",
title="Permit",
url="/cases/case-1",
)
with self.assertRaisesRegex(ValueError, "body"):
SearchDocument(
tenant_id="tenant-1",
module_id="cases",
resource_type="case",
resource_id="case-1",
title="Permit",
url="/cases/case-1",
body="x" * 200_001,
acl_tokens=("account:account-1",),
)
def test_manifest_provider_registration_resolves_lazily(self) -> None:
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="test",
name="Test",
version="1",
search_providers=(
SearchProviderRegistration(
id="test.records",
factory=lambda context: _Provider(),
resource_types=("record",),
),
),
search_sources=(
SearchSourceProviderRegistration(
id="test.records",
factory=lambda context: _Source(),
),
),
)
)
registry.configure_capability_context(
ModuleContext(registry=registry, settings=object())
)
providers = registry.search_providers()
self.assertEqual(1, len(providers))
self.assertIsInstance(providers[0][1], SearchProvider)
sources = registry.search_sources()
self.assertEqual(1, len(sources))
self.assertIsInstance(sources[0][1], SearchSourceProvider)
request = SearchAuthorizationRequest(
reference=SearchResourceReference(
tenant_id="tenant-1",
module_id="test",
resource_type="record",
resource_id="1",
),
source_revision="1",
)
self.assertTrue(
sources[0][1].authorize(
object(),
object(),
requests=(request,),
)[request.reference.key]
)
page = sources[0][1].backfill(
object(),
request=SearchBackfillRequest(
tenant_id="tenant-1",
provider_id="test.records",
resource_type="record",
rebuild_id="rebuild-1",
),
)
self.assertTrue(page.complete)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,117 @@
from __future__ import annotations
import unittest
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.tabular_sources import (
CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER,
CAPABILITY_CONNECTORS_TABULAR_SOURCES,
TabularColumn,
TabularReadRequest,
TabularReadResult,
TabularSnapshotInput,
TabularSnapshotWriter,
TabularSource,
TabularSourceProvider,
TabularSourceValidationError,
parse_tabular_csv,
tabular_snapshot_writer,
tabular_source_provider,
)
class _TabularProvider:
source = TabularSource(
ref="snapshot:source-1",
provider="snapshot",
source_name="monthly_cases",
name="Monthly cases",
schema=(TabularColumn(name="case_id", data_type="string", nullable=False),),
fingerprint="abc123",
row_count=1,
)
def list_sources(self, session, principal, *, query="", limit=100):
del session, principal
if query and query.casefold() not in self.source.name.casefold():
return ()
return (self.source,)[:limit]
def get_source(self, session, principal, *, source_ref):
del session, principal
return self.source if source_ref == self.source.ref else None
def read_source(self, session, principal, *, request):
del session, principal
rows = ({"case_id": "A-1"},)
selected = rows[request.offset : request.offset + request.limit]
return TabularReadResult(
source=self.source,
rows=selected,
total_rows=len(rows),
truncated=len(selected) < len(rows),
)
def create_snapshot(self, session, principal, *, snapshot):
del session, principal, snapshot
return self.source
class TabularSourceContractTests(unittest.TestCase):
def test_provider_and_snapshot_writer_are_runtime_checkable(self) -> None:
provider = _TabularProvider()
self.assertIsInstance(provider, TabularSourceProvider)
self.assertIsInstance(provider, TabularSnapshotWriter)
def test_capabilities_resolve_without_importing_connectors(self) -> None:
provider = _TabularProvider()
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="tabular_contract_test",
name="Tabular contract test",
version="test",
capability_factories={
CAPABILITY_CONNECTORS_TABULAR_SOURCES: lambda context: provider,
CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER: lambda context: provider,
},
)
)
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
self.assertIs(provider, tabular_source_provider(registry))
self.assertIs(provider, tabular_snapshot_writer(registry))
self.assertIsNone(tabular_source_provider(PlatformRegistry()))
def test_read_and_snapshot_dtos_are_immutable_and_bounded_by_callers(self) -> None:
provider = _TabularProvider()
request = TabularReadRequest(source_ref=provider.source.ref, limit=1)
result = provider.read_source(object(), object(), request=request)
snapshot = TabularSnapshotInput(
name="Monthly cases",
source_name="monthly_cases",
rows=({"case_id": "A-1"},),
)
self.assertEqual(({"case_id": "A-1"},), result.rows)
self.assertEqual(provider.source, provider.create_snapshot(object(), object(), snapshot=snapshot))
def test_shared_csv_parser_preserves_identifier_zeroes_and_rejects_extra_values(self) -> None:
rows = parse_tabular_csv(
"case_id;amount;active\n0012;7.5;true\n\n",
delimiter=";",
max_rows=2,
)
self.assertEqual(
({"case_id": "0012", "amount": 7.5, "active": True},),
rows,
)
with self.assertRaises(TabularSourceValidationError):
parse_tabular_csv("id,name\n1,Ada,extra\n")
if __name__ == "__main__":
unittest.main()

262
tests/test_view_surfaces.py Normal file
View File

@@ -0,0 +1,262 @@
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,
NavItem,
)
from govoplan_core.core.registry import (
PlatformRegistry,
RegistryError,
manifest_view_surfaces,
)
from govoplan_core.core.views import ViewSurface
from govoplan_core.server.platform import create_platform_router
from govoplan_core.server.registry import available_module_manifests
def example_manifest(
*,
custom_surfaces: tuple[ViewSurface, ...] = (),
) -> ModuleManifest:
return ModuleManifest(
id="example",
name="Example",
version="test",
frontend=FrontendModule(
module_id="example",
nav_items=(
NavItem(
path="/example",
label="Example",
order=20,
),
),
routes=(
FrontendRoute(
path="/example",
component="ExamplePage",
order=20,
),
),
view_surfaces=custom_surfaces,
),
)
class ViewSurfaceContractTests(unittest.TestCase):
def test_manifest_derives_module_navigation_and_route_surfaces(self) -> None:
surfaces = manifest_view_surfaces(example_manifest())
self.assertEqual(
[
"example.module",
"example.nav.example",
"example.route.example",
],
[surface.id for surface in surfaces],
)
self.assertEqual(
"example.module",
next(
surface.parent_id
for surface in surfaces
if surface.id == "example.route.example"
),
)
def test_registry_includes_valid_custom_surface(self) -> None:
registry = PlatformRegistry()
registry.register(
example_manifest(
custom_surfaces=(
ViewSurface(
id="example.settings.connection",
module_id="example",
kind="section",
label="Connection settings",
),
)
)
)
registry.validate()
custom_surface = next(
surface
for surface in registry.view_surfaces()
if surface.id == "example.settings.connection"
)
self.assertEqual("example.module", custom_surface.parent_id)
def test_registry_rejects_surface_owned_by_another_module(self) -> None:
registry = PlatformRegistry()
registry.register(
example_manifest(
custom_surfaces=(
ViewSurface(
id="other.settings.connection",
module_id="other",
kind="section",
label="Connection settings",
),
)
)
)
with self.assertRaisesRegex(RegistryError, "belongs to"):
registry.validate()
def test_registry_rejects_unknown_surface_parent(self) -> None:
registry = PlatformRegistry()
registry.register(
example_manifest(
custom_surfaces=(
ViewSurface(
id="example.settings.connection",
module_id="example",
kind="section",
label="Connection settings",
parent_id="example.missing",
),
)
)
)
with self.assertRaisesRegex(RegistryError, "unknown parent"):
registry.validate()
def test_registry_rejects_cyclic_surface_hierarchy(self) -> None:
registry = PlatformRegistry()
registry.register(
example_manifest(
custom_surfaces=(
ViewSurface(
id="example.section.one",
module_id="example",
kind="section",
label="One",
parent_id="example.section.two",
),
ViewSurface(
id="example.section.two",
module_id="example",
kind="section",
label="Two",
parent_id="example.section.one",
),
)
)
)
with self.assertRaisesRegex(RegistryError, "contains a cycle"):
registry.validate()
def test_required_surface_must_be_visible_by_default(self) -> None:
registry = PlatformRegistry()
registry.register(
example_manifest(
custom_surfaces=(
ViewSurface(
id="example.recovery",
module_id="example",
kind="action",
label="Recovery",
required=True,
default_visible=False,
),
)
)
)
with self.assertRaisesRegex(RegistryError, "visible by default"):
registry.validate()
def test_registry_validates_automatically_derived_surface_ids(self) -> None:
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="example",
name="Example",
version="test",
frontend=FrontendModule(
module_id="example",
routes=(
FrontendRoute(
path=f"/{'segment-' * 30}",
component="LongRoutePage",
),
),
),
)
)
with self.assertRaisesRegex(RegistryError, "View surface id"):
registry.validate()
def test_platform_module_payload_exposes_normalized_surface_contract(self) -> None:
registry = PlatformRegistry()
registry.register(
example_manifest(
custom_surfaces=(
ViewSurface(
id="example.section.details",
module_id="example",
kind="section",
label="Details",
),
)
)
)
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["view_surface_contract_version"])
self.assertEqual(
"example.route.example",
frontend["routes"][0]["surface_id"],
)
surfaces = {
surface["id"]: surface
for surface in frontend["view_surfaces"]
}
self.assertEqual(
"example.module",
surfaces["example.section.details"]["parent_id"],
)
def test_access_admin_route_accepts_view_manager_permissions(self) -> None:
access = available_module_manifests()["access"]
admin_route = next(
route
for route in access.frontend.routes
if route.path == "/admin"
)
self.assertTrue(
{
"views:definition:read",
"views:assignment:read",
"views:system_definition:read",
"views:system_assignment:read",
}.issubset(admin_route.required_any)
)
if __name__ == "__main__":
unittest.main()

View File

@@ -13,8 +13,6 @@ import unittest
class WheelRuntimeTests(unittest.TestCase):
def test_built_wheel_contains_and_runs_core_migrations_without_source_checkout(self) -> None:
repository_root = Path(__file__).resolve().parents[1]
access_repository = repository_root.parent / "govoplan-access"
self.assertTrue(access_repository.is_dir(), "wheel runtime check requires the mandatory Access repository")
with tempfile.TemporaryDirectory(prefix="govoplan-core-wheel-") as directory:
temporary_root = Path(directory)
wheel_directory = temporary_root / "wheels"
@@ -30,21 +28,8 @@ class WheelRuntimeTests(unittest.TestCase):
str(repository_root),
cwd=temporary_root,
)
self._run(
sys.executable,
"-m",
"pip",
"wheel",
"--no-deps",
"--wheel-dir",
str(wheel_directory),
str(access_repository),
cwd=temporary_root,
)
core_wheels = tuple(wheel_directory.glob("govoplan_core-*.whl"))
access_wheels = tuple(wheel_directory.glob("govoplan_access-*.whl"))
self.assertEqual(1, len(core_wheels))
self.assertEqual(1, len(access_wheels))
virtual_environment = temporary_root / "venv"
self._run(sys.executable, "-m", "venv", str(virtual_environment), cwd=temporary_root)
@@ -56,7 +41,6 @@ class WheelRuntimeTests(unittest.TestCase):
"install",
"--no-deps",
str(core_wheels[0]),
str(access_wheels[0]),
cwd=temporary_root,
)
@@ -85,7 +69,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.assertIn("4f2a9c8e7b6d", result["heads"])
self.assertEqual(["c91f0a72be34"], result["heads"])
self.assertIn("core_scopes", result["tables"])
self.assertIn("core_system_settings", result["tables"])

526
webui/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.13",
"version": "0.1.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.13",
"version": "0.1.14",
"dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui",
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
@@ -15,6 +15,8 @@
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
"@govoplan/dataflow-webui": "file:../../govoplan-dataflow/webui",
"@govoplan/datasources-webui": "file:../../govoplan-datasources/webui",
"@govoplan/docs-webui": "file:../../govoplan-docs/webui",
"@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
@@ -23,16 +25,22 @@
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui"
"@govoplan/postbox-webui": "file:../../govoplan-postbox/webui",
"@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui",
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
"@govoplan/search-webui": "file:../../govoplan-search/webui",
"@govoplan/views-webui": "file:../../govoplan-views/webui",
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui"
},
"devDependencies": {
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"@xyflow/react": "^12.11.2",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": "7.18.2",
"read-excel-file": "^9.2.0",
"typescript": "^5.7.2",
"vite": "^6.0.6"
@@ -41,21 +49,21 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-router-dom": ">=7.18.2 <8"
}
},
"../../govoplan-access/webui": {
"name": "@govoplan/access-webui",
"version": "0.1.8",
"version": "0.1.11",
"devDependencies": {
"typescript": "^5.7.2"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.11",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
@@ -65,13 +73,13 @@
},
"../../govoplan-addresses/webui": {
"name": "@govoplan/addresses-webui",
"version": "0.1.8",
"version": "0.1.9",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.11",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
@@ -86,11 +94,11 @@
"typescript": "^5.7.2"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
@@ -102,11 +110,11 @@
"name": "@govoplan/audit-webui",
"version": "0.1.8",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
@@ -118,12 +126,12 @@
"name": "@govoplan/calendar-webui",
"version": "0.1.8",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.9",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
@@ -135,7 +143,7 @@
},
"../../govoplan-campaign/webui": {
"name": "@govoplan/campaign-webui",
"version": "0.1.8",
"version": "0.1.12",
"dependencies": {
"read-excel-file": "9.2.0"
},
@@ -143,11 +151,11 @@
"typescript": "^5.7.2"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.12",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
@@ -163,7 +171,42 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-dataflow/webui": {
"name": "@govoplan/dataflow-webui",
"version": "0.1.14",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"@xyflow/react": "^12.11.2",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-datasources/webui": {
"name": "@govoplan/datasources-webui",
"version": "0.1.14",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
@@ -173,14 +216,14 @@
},
"../../govoplan-docs/webui": {
"name": "@govoplan/docs-webui",
"version": "0.1.8",
"version": "0.1.10",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.10",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
@@ -192,14 +235,14 @@
},
"../../govoplan-files/webui": {
"name": "@govoplan/files-webui",
"version": "0.1.8",
"version": "0.1.9",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.9",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
@@ -213,12 +256,12 @@
"name": "@govoplan/idm-webui",
"version": "0.1.8",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.9",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
@@ -230,16 +273,16 @@
},
"../../govoplan-mail/webui": {
"name": "@govoplan/mail-webui",
"version": "0.1.8",
"version": "0.1.10",
"devDependencies": {
"typescript": "^5.7.2"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.10",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
@@ -251,12 +294,12 @@
"name": "@govoplan/notifications-webui",
"version": "0.1.8",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.9",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
@@ -275,7 +318,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
@@ -289,12 +332,12 @@
"name": "@govoplan/organizations-webui",
"version": "0.1.8",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.9",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
@@ -306,13 +349,45 @@
},
"../../govoplan-policy/webui": {
"name": "@govoplan/policy-webui",
"version": "0.1.8",
"version": "0.1.9",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-postbox/webui": {
"name": "@govoplan/postbox-webui",
"version": "0.1.1",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-risk-compliance/webui": {
"name": "@govoplan/risk-compliance-webui",
"version": "0.1.8",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
@@ -322,14 +397,14 @@
},
"../../govoplan-scheduling/webui": {
"name": "@govoplan/scheduling-webui",
"version": "0.1.8",
"version": "0.1.11",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.11",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
@@ -339,6 +414,56 @@
}
}
},
"../../govoplan-search/webui": {
"name": "@govoplan/search-webui",
"version": "0.1.14",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-views/webui": {
"name": "@govoplan/views-webui",
"version": "0.1.0",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-workflow/webui": {
"name": "@govoplan/workflow-webui",
"version": "0.1.14",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"@xyflow/react": "^12.11.2",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -1091,6 +1216,14 @@
"resolved": "../../govoplan-dashboard/webui",
"link": true
},
"node_modules/@govoplan/dataflow-webui": {
"resolved": "../../govoplan-dataflow/webui",
"link": true
},
"node_modules/@govoplan/datasources-webui": {
"resolved": "../../govoplan-datasources/webui",
"link": true
},
"node_modules/@govoplan/docs-webui": {
"resolved": "../../govoplan-docs/webui",
"link": true
@@ -1123,10 +1256,30 @@
"resolved": "../../govoplan-policy/webui",
"link": true
},
"node_modules/@govoplan/postbox-webui": {
"resolved": "../../govoplan-postbox/webui",
"link": true
},
"node_modules/@govoplan/risk-compliance-webui": {
"resolved": "../../govoplan-risk-compliance/webui",
"link": true
},
"node_modules/@govoplan/scheduling-webui": {
"resolved": "../../govoplan-scheduling/webui",
"link": true
},
"node_modules/@govoplan/search-webui": {
"resolved": "../../govoplan-search/webui",
"link": true
},
"node_modules/@govoplan/views-webui": {
"resolved": "../../govoplan-views/webui",
"link": true
},
"node_modules/@govoplan/workflow-webui": {
"resolved": "../../govoplan-workflow/webui",
"link": true
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1618,6 +1771,61 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1676,6 +1884,50 @@
"node": ">=14.6"
}
},
"node_modules/@xyflow/react": {
"version": "12.11.2",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz",
"integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.79",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"@types/react": ">=17",
"@types/react-dom": ">=17",
"react": ">=17",
"react-dom": ">=17"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@xyflow/system": {
"version": "0.0.79",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz",
"integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.38",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
@@ -1744,6 +1996,13 @@
],
"license": "CC-BY-4.0"
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"dev": true,
"license": "MIT"
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1772,6 +2031,120 @@
"dev": true,
"license": "MIT"
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"dev": true,
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dev": true,
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"dev": true,
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1967,9 +2340,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true,
"funding": [
{
@@ -2023,9 +2396,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"dev": true,
"funding": [
{
@@ -2043,7 +2416,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -2085,9 +2458,9 @@
}
},
"node_modules/react-router": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz",
"integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==",
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2108,13 +2481,13 @@
}
},
"node_modules/react-router-dom": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz",
"integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==",
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
"integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
"dev": true,
"license": "MIT",
"dependencies": {
"react-router": "7.18.0"
"react-router": "7.18.2"
},
"engines": {
"node": ">=20.0.0"
@@ -2294,6 +2667,16 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": {
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
@@ -2375,6 +2758,35 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
}
}
}

View File

@@ -1,26 +1,26 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.13",
"version": "0.1.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.13",
"version": "0.1.14",
"dependencies": {
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.8",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.8",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.10",
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.8",
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#v0.1.8",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8",
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.8",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.8",
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.8",
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.8",
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git#v0.1.8"
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.8",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.8",
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.8",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.8",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.11",
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.8",
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.8",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.8",
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.8",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.10",
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.8",
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.8",
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.8"
},
"devDependencies": {
"@types/react": "^19.0.2",
@@ -721,7 +721,7 @@
},
"node_modules/@govoplan/access-webui": {
"version": "0.1.8",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#f2afcaa09c8201b141b6593f8a1ba574354e4db8",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#f2afcaa09c8201b141b6593f8a1ba574354e4db8",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"lucide-react": "^1.23.0",
@@ -737,7 +737,7 @@
},
"node_modules/@govoplan/admin-webui": {
"version": "0.1.8",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#11ecf362a36d2244633194e55b0e9900669d72b7",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#11ecf362a36d2244633194e55b0e9900669d72b7",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"lucide-react": "^1.23.0",
@@ -753,7 +753,7 @@
},
"node_modules/@govoplan/audit-webui": {
"version": "0.1.8",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#d3d2c60d7dc180cbd402cfeae38fa4f47f96969a",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#d3d2c60d7dc180cbd402cfeae38fa4f47f96969a",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"lucide-react": "^1.23.0",
@@ -769,7 +769,7 @@
},
"node_modules/@govoplan/calendar-webui": {
"version": "0.1.8",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#9bcf41bb1fbb3d6dfc81adcd97d781ab411b695d",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#9bcf41bb1fbb3d6dfc81adcd97d781ab411b695d",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@vitejs/plugin-react": "^4.3.4",
@@ -787,8 +787,8 @@
}
},
"node_modules/@govoplan/campaign-webui": {
"version": "0.1.10",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#735e874bd03c55c626347f5356301fe221145b98",
"version": "0.1.11",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#4774a8025cea3fd0962a23741e6d0241d4062663",
"dependencies": {
"read-excel-file": "9.2.0"
},
@@ -807,7 +807,7 @@
},
"node_modules/@govoplan/dashboard-webui": {
"version": "0.1.8",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#4b960ad37f0de0ef5224a0db22e95f84f0c9b3ca",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#4b960ad37f0de0ef5224a0db22e95f84f0c9b3ca",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"lucide-react": "^1.23.0",
@@ -823,7 +823,7 @@
},
"node_modules/@govoplan/docs-webui": {
"version": "0.1.8",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#ec748e3fcfb96f52a99c004ec218058db73d01f6",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#ec748e3fcfb96f52a99c004ec218058db73d01f6",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@vitejs/plugin-react": "^4.3.4",
@@ -842,7 +842,7 @@
},
"node_modules/@govoplan/files-webui": {
"version": "0.1.8",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#8bf7296bf5b251c8309a5f0d023ff6428b31a353",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#8bf7296bf5b251c8309a5f0d023ff6428b31a353",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@vitejs/plugin-react": "^4.3.4",
@@ -861,7 +861,7 @@
},
"node_modules/@govoplan/idm-webui": {
"version": "0.1.8",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#b47f71916da8438f5f748d170b45f7b97fa3bfb3",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#b47f71916da8438f5f748d170b45f7b97fa3bfb3",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@vitejs/plugin-react": "^4.3.4",
@@ -879,10 +879,10 @@
}
},
"node_modules/@govoplan/mail-webui": {
"version": "0.1.8",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#5371cb3ad4b3882a3ffc377643183f66ab318619",
"version": "0.1.10",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#3e23029090224fd01741242008e4991e0e548de1",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.10",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -896,7 +896,7 @@
},
"node_modules/@govoplan/ops-webui": {
"version": "0.1.8",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#341773a4ff8a76914a631e716e6f3fff2c0dc5ec",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#341773a4ff8a76914a631e716e6f3fff2c0dc5ec",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@vitejs/plugin-react": "^4.3.4",
@@ -915,7 +915,7 @@
},
"node_modules/@govoplan/organizations-webui": {
"version": "0.1.8",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#39c081c4fb8f37547c5f20b6a4fcad003c164b26",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#39c081c4fb8f37547c5f20b6a4fcad003c164b26",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@vitejs/plugin-react": "^4.3.4",
@@ -934,7 +934,7 @@
},
"node_modules/@govoplan/policy-webui": {
"version": "0.1.8",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git#664eb38ab92c8ca2e755668eb125938c70ab27fb",
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#664eb38ab92c8ca2e755668eb125938c70ab27fb",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"lucide-react": "^1.23.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.13",
"version": "0.1.14",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -14,19 +14,25 @@
"./app": {
"types": "./src/app.ts",
"import": "./src/app.ts"
},
"./definition-graph": {
"types": "./src/definitionGraph.ts",
"import": "./src/definitionGraph.ts"
}
},
"scripts": {
"dev": "vite --host 127.0.0.1 --port 5173",
"prebuild": "npm run audit:i18n-structural",
"build": "tsc && vite build",
"preview": "vite preview --host 127.0.0.1 --port 4173",
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
"test:i18n-catalog": "node --test tests/i18n-catalog-validation.test.mjs",
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
"test:data-grid-actions": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/data-grid-actions.test.js",
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
"test:explorer-tree": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/explorer-tree.test.js",
"test:icon-button": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/icon-button.test.js",
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js",
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js && node .module-test-build/tests/definition-graph.test.js",
"test:module-permutations": "node scripts/test-module-permutations.mjs",
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js",
"test:metric-card": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/metric-card.test.js",
@@ -41,6 +47,8 @@
"@govoplan/audit-webui": "file:../../govoplan-audit/webui",
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
"@govoplan/dataflow-webui": "file:../../govoplan-dataflow/webui",
"@govoplan/datasources-webui": "file:../../govoplan-datasources/webui",
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
"@govoplan/docs-webui": "file:../../govoplan-docs/webui",
"@govoplan/files-webui": "file:../../govoplan-files/webui",
@@ -50,16 +58,22 @@
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui"
"@govoplan/postbox-webui": "file:../../govoplan-postbox/webui",
"@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui",
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
"@govoplan/search-webui": "file:../../govoplan-search/webui",
"@govoplan/views-webui": "file:../../govoplan-views/webui",
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui"
},
"devDependencies": {
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"@xyflow/react": "^12.11.2",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": "7.18.2",
"read-excel-file": "^9.2.0",
"typescript": "^5.7.2",
"vite": "^6.0.6"
@@ -68,7 +82,7 @@
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-router-dom": ">=7.18.2 <8"
},
"allowScripts": {
"esbuild@0.25.12": true

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.13",
"version": "0.1.14",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -22,19 +22,19 @@
"preview": "vite preview --host 127.0.0.1 --port 4173"
},
"dependencies": {
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.8",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.8",
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.8",
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#v0.1.8",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8",
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.8",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.8",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.10",
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.8",
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.8",
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git#v0.1.8"
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.8",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.8",
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.8",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.8",
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.8",
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.8",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.8",
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.8",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.10",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.11",
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.8",
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.8",
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.8"
},
"devDependencies": {
"lucide-react": "^1.23.0",

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import vm from "node:vm";
import ts from "typescript";
import { findDuplicateTranslationKeys } from "./i18n-catalog-validation.mjs";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const webuiDir = path.resolve(scriptDir, "..");
@@ -74,6 +75,8 @@ function scanStructuralSourcePositions(roots) {
function scanGeneratedCatalogs(files) {
const findings = [];
for (const file of files) {
const source = fs.readFileSync(file, "utf8");
findings.push(...findDuplicateTranslationKeys(source, file));
const translations = loadGeneratedTranslations(file);
for (const [language, dictionary] of Object.entries(translations)) {
for (const [key, value] of Object.entries(dictionary)) {

View File

@@ -0,0 +1,69 @@
import ts from "typescript";
export function findDuplicateTranslationKeys(sourceText, fileName = "generatedTranslations.ts") {
const source = ts.createSourceFile(
fileName,
sourceText,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS
);
const findings = [];
function visit(node) {
if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.name.text === "generatedTranslations" &&
node.initializer &&
ts.isObjectLiteralExpression(node.initializer)
) {
scanCatalog(node.initializer);
}
ts.forEachChild(node, visit);
}
function scanCatalog(catalog) {
for (const localeProperty of catalog.properties) {
if (!ts.isPropertyAssignment(localeProperty) || !ts.isObjectLiteralExpression(localeProperty.initializer)) {
continue;
}
const locale = propertyName(localeProperty.name);
const firstDeclarations = new Map();
for (const translationProperty of localeProperty.initializer.properties) {
if (!ts.isPropertyAssignment(translationProperty)) continue;
const key = propertyName(translationProperty.name);
if (!key) continue;
const first = firstDeclarations.get(key);
if (!first) {
firstDeclarations.set(key, translationProperty.name);
continue;
}
const duplicatePosition = source.getLineAndCharacterOfPosition(
translationProperty.name.getStart(source)
);
const firstPosition = source.getLineAndCharacterOfPosition(first.getStart(source));
findings.push(
`${fileName}:${duplicatePosition.line + 1}:${duplicatePosition.character + 1} ` +
`duplicate generated translation key ${JSON.stringify(key)} in locale ` +
`${JSON.stringify(locale)}; first declared at line ${firstPosition.line + 1}`
);
}
}
}
visit(source);
return findings;
}
function propertyName(name) {
if (
ts.isIdentifier(name) ||
ts.isStringLiteral(name) ||
ts.isNumericLiteral(name) ||
ts.isNoSubstitutionTemplateLiteral(name)
) {
return name.text;
}
return "";
}

View File

@@ -8,6 +8,8 @@ const packageByModule = {
calendar: "@govoplan/calendar-webui",
campaigns: "@govoplan/campaign-webui",
dashboard: "@govoplan/dashboard-webui",
dataflow: "@govoplan/dataflow-webui",
datasources: "@govoplan/datasources-webui",
docs: "@govoplan/docs-webui",
files: "@govoplan/files-webui",
idm: "@govoplan/idm-webui",
@@ -16,7 +18,10 @@ const packageByModule = {
organizations: "@govoplan/organizations-webui",
ops: "@govoplan/ops-webui",
policy: "@govoplan/policy-webui",
scheduling: "@govoplan/scheduling-webui"
postbox: "@govoplan/postbox-webui",
scheduling: "@govoplan/scheduling-webui",
views: "@govoplan/views-webui",
workflow: "@govoplan/workflow-webui"
};
const cases = [
@@ -27,19 +32,28 @@ const cases = [
{ name: "access-with-admin", modules: ["access", "admin"] },
{ name: "admin-with-policy-and-audit", modules: ["access", "admin", "policy", "audit"] },
{ name: "dashboard-only", modules: ["dashboard"] },
{ name: "dataflow-only", modules: ["dataflow"] },
{ name: "datasources-only", modules: ["datasources"] },
{ name: "dataflow-with-datasources", modules: ["datasources", "dataflow"] },
{ name: "workflow-only", modules: ["workflow"] },
{ name: "workflow-with-dataflow", modules: ["datasources", "dataflow", "workflow"] },
{ name: "views-only", modules: ["views"] },
{ name: "views-with-administration", modules: ["access", "admin", "views"] },
{ name: "calendar-only", modules: ["calendar"] },
{ name: "files-only", modules: ["files"] },
{ name: "mail-only", modules: ["mail"] },
{ name: "notifications-only", modules: ["notifications"] },
{ name: "organizations-only", modules: ["organizations"] },
{ name: "idm-with-organizations", modules: ["organizations", "idm"] },
{ name: "postbox-only", modules: ["postbox"] },
{ name: "campaign-only", modules: ["campaigns"] },
{ name: "campaign-with-postbox", modules: ["campaigns", "postbox"] },
{ name: "campaign-with-files-no-mail", modules: ["campaigns", "files"] },
{ name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] },
{ name: "scheduling-only", modules: ["scheduling"] },
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling"] }
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "workflow", "views", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "postbox"] }
];
const npmExec = process.env.npm_execpath;

View File

@@ -3,16 +3,19 @@ import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformPublicModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, EffectiveViewProjection, LoginResponse, PlatformModuleInfo, PlatformPublicModuleInfo, PlatformWebModule, UserUiPreferences, ViewsRuntimeUiCapability } from "./types";
import AppShell from "./layout/AppShell";
import PublicLandingPage from "./features/auth/PublicLandingPage";
import LoginModal from "./features/auth/LoginModal";
import { PermissionBoundary } from "./components/AccessBoundary";
import { firstAccessibleRoute, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, resolveInstalledPublicWebModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
import { firstAccessibleRoute, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, resolveInstalledPublicWebModules, resolveInstalledWebModules, routeContributionsForModules, uiCapability } from "./platform/modules";
import { PlatformModulesProvider } from "./platform/ModuleContext";
import { PlatformViewProvider } from "./platform/ViewContext";
import { PLATFORM_VIEW_CHANGED_EVENT } from "./platform/views";
import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
import { PlatformLanguageProvider, type PlatformLanguage } from "./i18n/LanguageContext";
import ViewSurfaceRouteBoundary from "./components/ViewSurfaceRouteBoundary";
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
@@ -37,18 +40,59 @@ export default function App() {
const [backendReachable, setBackendReachable] = useState(true);
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
const [reloginMessage, setReloginMessage] = useState("");
const [viewProjection, setViewProjection] = useState<EffectiveViewProjection | null>(null);
const localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
const localPublicWebModules = useMemo(() => resolveInstalledPublicWebModules(platformPublicModules), [platformPublicModules]);
const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]);
const navItems = useMemo(() => navItemsForModules(webModules), [webModules]);
const viewsRuntime = useMemo(
() => uiCapability<ViewsRuntimeUiCapability>("views.runtime", webModules),
[webModules]
);
const navItems = useMemo(
() => navItemsForModules(webModules, viewProjection),
[viewProjection, webModules]
);
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]);
const contextModules = auth ? webModules : publicWebModules;
const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]);
const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
useEffect(() => {
if (!auth || !viewsRuntime) {
setViewProjection(null);
return;
}
const currentAuth = auth;
const currentRuntime = viewsRuntime;
let cancelled = false;
async function loadEffectiveView() {
try {
const projection = await currentRuntime.loadEffectiveView(settings, currentAuth);
if (!cancelled) setViewProjection(projection);
} catch (error) {
if (!cancelled) setViewProjection(null);
console.error("Failed to load the effective View", error);
}
}
void loadEffectiveView();
window.addEventListener(PLATFORM_VIEW_CHANGED_EVENT, loadEffectiveView);
return () => {
cancelled = true;
window.removeEventListener(PLATFORM_VIEW_CHANGED_EVENT, loadEffectiveView);
};
}, [
auth?.user?.id,
auth?.active_tenant?.id,
auth?.tenant.id,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey,
viewsRuntime
]);
function updateSettings(next: ApiSettings) {
setSettings(next);
saveApiSettings(next);
@@ -311,6 +355,7 @@ export default function App() {
return (
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={publicWebModules}>
<PlatformViewProvider modules={publicWebModules} projection={null}>
<UnsavedChangesProvider>
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<div className="public-landing">
@@ -322,6 +367,7 @@ export default function App() {
</div>
</AppShell>
</UnsavedChangesProvider>
</PlatformViewProvider>
</PlatformModulesProvider>
</PlatformLanguageProvider>);
@@ -331,6 +377,7 @@ export default function App() {
return (
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={publicWebModules}>
<PlatformViewProvider modules={publicWebModules} projection={null}>
<UnsavedChangesProvider>
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
@@ -347,12 +394,13 @@ export default function App() {
</Suspense>
</AppShell>
</UnsavedChangesProvider>
</PlatformViewProvider>
</PlatformModulesProvider>
</PlatformLanguageProvider>);
}
const defaultRoute = firstAccessibleRoute(auth, webModules);
const defaultRoute = firstAccessibleRoute(auth, webModules, viewProjection);
const authAvailableLanguages = auth.available_languages?.map((item) => ({
code: item.code,
label: item.label,
@@ -373,6 +421,7 @@ export default function App() {
onLanguageChange={persistLanguagePreference}
moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={webModules}>
<PlatformViewProvider modules={webModules} projection={viewProjection}>
<UnsavedChangesProvider>
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
@@ -392,8 +441,14 @@ export default function App() {
path={route.path}
element={
<PermissionBoundary auth={auth} anyOf={route.anyOf} allOf={route.allOf} fallback={defaultRoute}>
{route.render({ settings, auth, onAuthChange: updateAuth })}
</PermissionBoundary>
<ViewSurfaceRouteBoundary
surfaceId={route.surfaceId}
settings={settings}
runtime={viewsRuntime}
fallbackPath={defaultRoute}>
{route.render({ settings, auth, onAuthChange: updateAuth })}
</ViewSurfaceRouteBoundary>
</PermissionBoundary>
} />
)}
@@ -412,6 +467,7 @@ export default function App() {
}
</AppShell>
</UnsavedChangesProvider>
</PlatformViewProvider>
</PlatformModulesProvider>
</PlatformLanguageProvider>);

View File

@@ -0,0 +1,76 @@
import type { ApiSettings, CredentialEnvelopeSummary, MailProfileScope } from "../types";
import { apiFetch } from "./client";
export type CredentialEnvelopePayload = {
scope_type: MailProfileScope;
scope_id?: string | null;
name: string;
description?: string | null;
credential_kind: string;
public_data?: Record<string, unknown>;
secret_data?: Record<string, string>;
allowed_modules?: string[];
allowed_server_refs?: string[];
inherit_to_lower_scopes?: boolean;
is_active?: boolean;
};
export type CredentialEnvelopeUpdatePayload = Partial<
Omit<CredentialEnvelopePayload, "scope_type" | "scope_id">
> & {
clear_secret?: boolean;
};
export async function listCredentialEnvelopes(
settings: ApiSettings,
params: {
scope_type: MailProfileScope;
scope_id?: string | null;
include_inactive?: boolean;
}
): Promise<CredentialEnvelopeSummary[]> {
const search = new URLSearchParams({ scope_type: params.scope_type });
if (params.scope_id) search.set("scope_id", params.scope_id);
if (params.include_inactive) search.set("include_inactive", "true");
const response = await apiFetch<{ credentials: CredentialEnvelopeSummary[] }>(
settings,
`/api/v1/credentials?${search.toString()}`
);
return response.credentials;
}
export function createCredentialEnvelope(
settings: ApiSettings,
payload: CredentialEnvelopePayload
): Promise<CredentialEnvelopeSummary> {
return apiFetch<CredentialEnvelopeSummary>(settings, "/api/v1/credentials", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateCredentialEnvelope(
settings: ApiSettings,
credentialId: string,
payload: CredentialEnvelopeUpdatePayload
): Promise<CredentialEnvelopeSummary> {
return apiFetch<CredentialEnvelopeSummary>(
settings,
`/api/v1/credentials/${encodeURIComponent(credentialId)}`,
{
method: "PATCH",
body: JSON.stringify(payload)
}
);
}
export function deleteCredentialEnvelope(
settings: ApiSettings,
credentialId: string
): Promise<CredentialEnvelopeSummary> {
return apiFetch<CredentialEnvelopeSummary>(
settings,
`/api/v1/credentials/${encodeURIComponent(credentialId)}`,
{ method: "DELETE" }
);
}

View File

@@ -1,4 +1,5 @@
import type {
MailCredentialEnvelope,
MailImapTransportSettings,
MailProfilePatternKey,
MailProfilePolicy,
@@ -11,11 +12,13 @@ import type {
} from "../types";
export type {
MailCredentialEnvelope,
MailCredentialPolicy,
MailProfilePatternKey,
MailProfilePolicy,
MailProfileScope,
MailSecurity,
MailServerEndpoint,
MailServerProfile
} from "../types";
@@ -24,6 +27,7 @@ export type MailImapTestPayload = MailImapTransportSettings;
export type MailTransportCredentialsPayload = MailTransportCredentials;
export type MailServerProfileCredentialsPayload = MailServerProfileCredentials;
export type MailServerProfileListResponse = { profiles: MailServerProfile[] };
export type MailCredentialListResponse = { credentials: MailCredentialEnvelope[] };
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
export type MailConnectionTestResponse = {
@@ -110,6 +114,7 @@ export type MailServerProfilePayload = {
slug?: string | null;
description?: string | null;
is_active?: boolean;
inherit_to_lower_scopes?: boolean;
scope_type?: MailProfileScope;
scope_id?: string | null;
smtp: MailSmtpTestPayload;

View File

@@ -0,0 +1,213 @@
import { useEffect, useRef, useState } from "react";
import type { ApiSettings } from "../types";
import { apiFetch } from "./client";
export type SharedNotificationSummary = {
total: number;
unread: number;
pending: number;
failed: number;
show_unread_badge?: boolean;
};
export type SharedNotificationSummarySnapshot = {
summary: SharedNotificationSummary | null;
loading: boolean;
error: unknown;
};
type Listener = (snapshot: SharedNotificationSummarySnapshot) => void;
type SummaryStore = {
settings: ApiSettings;
listeners: Map<Listener, number>;
snapshot: SharedNotificationSummarySnapshot;
inFlight: Promise<void> | null;
lastFetchedAt: number;
timerId: number | null;
focusListener: () => void;
changeListener: () => void;
};
const stores = new Map<string, SummaryStore>();
const EMPTY_SNAPSHOT: SharedNotificationSummarySnapshot = {
summary: null,
loading: false,
error: null
};
function storeKey(settings: ApiSettings, scopeKey: string): string {
return [
settings.apiBaseUrl,
settings.apiKey,
settings.accessToken,
scopeKey
].join("\u0000");
}
function notify(store: SummaryStore): void {
for (const listener of store.listeners.keys()) {
listener(store.snapshot);
}
}
function minimumInterval(store: SummaryStore): number {
return Math.max(
5_000,
Math.min(...Array.from(store.listeners.values()))
);
}
function refresh(store: SummaryStore, force = false): Promise<void> {
if (store.inFlight) return store.inFlight;
if (
!force &&
store.lastFetchedAt > 0 &&
Date.now() - store.lastFetchedAt < Math.min(minimumInterval(store), 5_000)
) {
return Promise.resolve();
}
if (store.snapshot.summary === null) {
store.snapshot = { ...store.snapshot, loading: true, error: null };
notify(store);
}
const request = apiFetch<SharedNotificationSummary>(
store.settings,
"/api/v1/notifications/summary"
)
.then((summary) => {
store.lastFetchedAt = Date.now();
store.snapshot = { summary, loading: false, error: null };
notify(store);
})
.catch((error) => {
store.snapshot = {
...store.snapshot,
loading: false,
error
};
notify(store);
})
.finally(() => {
if (store.inFlight === request) store.inFlight = null;
});
store.inFlight = request;
return request;
}
function schedule(store: SummaryStore): void {
if (store.timerId !== null) window.clearInterval(store.timerId);
if (store.listeners.size === 0) {
store.timerId = null;
return;
}
store.timerId = window.setInterval(
() => void refresh(store, true),
minimumInterval(store)
);
}
function createStore(settings: ApiSettings): SummaryStore {
const store = {} as SummaryStore;
store.settings = settings;
store.listeners = new Map();
store.snapshot = { ...EMPTY_SNAPSHOT };
store.inFlight = null;
store.lastFetchedAt = 0;
store.timerId = null;
store.focusListener = () => void refresh(store);
store.changeListener = () => void refresh(store, true);
return store;
}
function subscribe(
settings: ApiSettings,
scopeKey: string,
intervalMs: number,
listener: Listener
): { store: SummaryStore; unsubscribe: () => void } {
const key = storeKey(settings, scopeKey);
let store = stores.get(key);
if (!store) {
store = createStore(settings);
stores.set(key, store);
}
const firstListener = store.listeners.size === 0;
store.listeners.set(listener, Math.max(5_000, intervalMs));
if (firstListener) {
window.addEventListener("focus", store.focusListener);
window.addEventListener(
"govoplan:notifications-changed",
store.changeListener
);
}
listener(store.snapshot);
schedule(store);
void refresh(store);
return {
store,
unsubscribe: () => {
store?.listeners.delete(listener);
if (store?.listeners.size === 0) {
schedule(store);
window.removeEventListener("focus", store.focusListener);
window.removeEventListener(
"govoplan:notifications-changed",
store.changeListener
);
stores.delete(key);
} else if (store) {
schedule(store);
}
}
};
}
export function useSharedNotificationSummary(
settings: ApiSettings,
options: {
enabled: boolean;
scopeKey: string;
intervalMs?: number;
refreshKey?: string | number;
}
): SharedNotificationSummarySnapshot {
const [snapshot, setSnapshot] =
useState<SharedNotificationSummarySnapshot>(EMPTY_SNAPSHOT);
const activeStore = useRef<SummaryStore | null>(null);
const previousRefreshKey = useRef(options.refreshKey);
useEffect(() => {
if (!options.enabled) {
activeStore.current = null;
setSnapshot(EMPTY_SNAPSHOT);
return;
}
const subscription = subscribe(
settings,
options.scopeKey,
options.intervalMs ?? 60_000,
setSnapshot
);
activeStore.current = subscription.store;
return () => {
activeStore.current = null;
subscription.unsubscribe();
};
}, [
options.enabled,
options.intervalMs,
options.scopeKey,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey
]);
useEffect(() => {
if (previousRefreshKey.current === options.refreshKey) return;
previousRefreshKey.current = options.refreshKey;
if (activeStore.current) void refresh(activeStore.current, true);
}, [options.refreshKey]);
return snapshot;
}

View File

@@ -0,0 +1,618 @@
import { useEffect, useMemo, useState } from "react";
import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import type {
ApiSettings,
CredentialEnvelopeSummary,
MailProfileScope
} from "../types";
import {
createCredentialEnvelope,
deleteCredentialEnvelope,
listCredentialEnvelopes,
updateCredentialEnvelope
} from "../api/credentials";
import Button from "./Button";
import Card from "./Card";
import ConfirmDialog from "./ConfirmDialog";
import ConnectionTree, { type ConnectionTreeColumn } from "./ConnectionTree";
import Dialog from "./Dialog";
import DismissibleAlert from "./DismissibleAlert";
import FormField from "./FormField";
import LoadingFrame from "./LoadingFrame";
import PasswordField from "./PasswordField";
import {
ReferenceMultiSelect,
combineReferenceOptionProviders,
customReferenceOption,
staticReferenceOptionProvider,
type ReferenceOption
} from "./ReferenceSelect";
import { platformModuleReferenceProvider } from "../platform/referenceProviders";
import StatusBadge from "./StatusBadge";
import TableActionGroup from "./table/TableActionGroup";
import ToggleSwitch from "./ToggleSwitch";
import { useUnsavedDraftGuard } from "./UnsavedChangesGuard";
import {
usePlatformModules,
usePlatformUiCapabilities
} from "../platform/ModuleContext";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import type {
CredentialReferenceSelectorsUiCapability
} from "./ReferenceSelect";
export type CredentialEnvelopeTargetOption = {
id: string;
label: string;
secondary?: string | null;
};
export type CredentialEnvelopeServerOption = {
id: string;
label: string;
secondary?: string | null;
disabled?: boolean;
};
export type CredentialEnvelopeManagerProps = {
settings: ApiSettings;
scopeType: Extract<MailProfileScope, "system" | "tenant" | "user" | "group">;
scopeId?: string | null;
targetOptions?: CredentialEnvelopeTargetOption[];
serverOptions?: CredentialEnvelopeServerOption[];
targetLabel?: string;
title?: string;
canWrite: boolean;
};
type CredentialKind = "username_password" | "token" | "api_key";
type CredentialDraft = {
name: string;
description: string;
credentialKind: CredentialKind;
username: string;
secret: string;
allowedModules: string;
allowedServerRefs: string;
inheritToLowerScopes: boolean;
isActive: boolean;
clearSecret: boolean;
retainedPublicData: Record<string, unknown>;
};
const EMPTY_DRAFT: CredentialDraft = {
name: "",
description: "",
credentialKind: "username_password",
username: "",
secret: "",
allowedModules: "",
allowedServerRefs: "",
inheritToLowerScopes: false,
isActive: true,
clearSecret: false,
retainedPublicData: {}
};
export default function CredentialEnvelopeManager({
settings,
scopeType,
scopeId,
targetOptions = [],
serverOptions = [],
targetLabel = "Target",
title = "Credential envelopes",
canWrite
}: CredentialEnvelopeManagerProps) {
const modules = usePlatformModules();
const { translateText } = usePlatformLanguage();
const [selectedTargetId, setSelectedTargetId] = useState(scopeId ?? targetOptions[0]?.id ?? "");
const [credentials, setCredentials] = useState<CredentialEnvelopeSummary[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [editing, setEditing] = useState<CredentialEnvelopeSummary | "new" | null>(null);
const [draft, setDraft] = useState<CredentialDraft>(EMPTY_DRAFT);
const [savedDraftKey, setSavedDraftKey] = useState("");
const [deleting, setDeleting] = useState<CredentialEnvelopeSummary | null>(null);
const activeScopeId = scopeType === "system" || scopeType === "tenant"
? scopeId ?? null
: selectedTargetId || null;
const scopeReady = scopeType === "system" || scopeType === "tenant" || Boolean(activeScopeId);
const draftDirty = Boolean(editing) && credentialDraftKey(draft) !== savedDraftKey;
const referenceCapabilities =
usePlatformUiCapabilities<CredentialReferenceSelectorsUiCapability>(
"core.credentialReferenceSelectors"
);
const moduleReferenceOptions = useMemo<ReferenceOption[]>(
() => modules.map((module) => ({
value: module.id,
label: translateText(module.label),
description: `${module.id} · version ${module.version}`,
kind: "module",
sourceModule: "core",
provenance: { version: module.version }
})),
[modules, translateText]
);
const serverReferenceOptions = useMemo<ReferenceOption[]>(
() => serverOptions.map((server) => ({
value: server.id,
label: server.label,
description: server.secondary ?? server.id,
kind: "server",
disabled: server.disabled,
availability: server.disabled ? "inactive" : "available",
provenance: { announcedByConsumer: true }
})),
[serverOptions]
);
const moduleReferenceProvider = useMemo(
() => platformModuleReferenceProvider(settings, moduleReferenceOptions),
[moduleReferenceOptions, settings]
);
const serverReferenceProvider = useMemo(
() =>
combineReferenceOptionProviders([
staticReferenceOptionProvider(serverReferenceOptions),
...referenceCapabilities.map((capability) =>
capability.serverProvider(settings, {
scopeType,
scopeId: activeScopeId
})
)
]),
[
activeScopeId,
referenceCapabilities,
scopeType,
serverReferenceOptions,
settings
]
);
useUnsavedDraftGuard({
dirty: draftDirty,
onSave: saveDraft,
onDiscard: closeEditor
});
useEffect(() => {
if (scopeId) {
setSelectedTargetId(scopeId);
return;
}
if (
targetOptions.length > 0 &&
!targetOptions.some((option) => option.id === selectedTargetId)
) {
setSelectedTargetId(targetOptions[0].id);
}
}, [scopeId, selectedTargetId, targetOptions]);
useEffect(() => {
void loadCredentials();
}, [
activeScopeId,
scopeReady,
scopeType,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey
]);
async function loadCredentials() {
if (!scopeReady) {
setCredentials([]);
return;
}
setLoading(true);
setError("");
try {
setCredentials(
await listCredentialEnvelopes(settings, {
scope_type: scopeType,
scope_id: activeScopeId,
include_inactive: true
})
);
} catch (err) {
setCredentials([]);
setError(errorMessage(err));
} finally {
setLoading(false);
}
}
function openCreate() {
const next = { ...EMPTY_DRAFT, retainedPublicData: {} };
setEditing("new");
setDraft(next);
setSavedDraftKey(credentialDraftKey(next));
setError("");
setNotice("");
}
function openEdit(credential: CredentialEnvelopeSummary) {
const next = credentialDraft(credential);
setEditing(credential);
setDraft(next);
setSavedDraftKey(credentialDraftKey(next));
setError("");
setNotice("");
}
function closeEditor() {
if (saving) return;
setEditing(null);
setDraft(EMPTY_DRAFT);
setSavedDraftKey("");
}
async function saveDraft(): Promise<boolean> {
if (!editing || !scopeReady || !draft.name.trim() || !canWrite) return false;
if (editing === "new" && !draft.secret.trim()) {
setError("Enter a secret before creating the credential.");
return false;
}
const kindChanged = editing !== "new" && draft.credentialKind !== editing.credential_kind;
if (kindChanged && !draft.secret.trim()) {
setError("Enter a replacement secret when changing the credential type.");
return false;
}
setSaving(true);
setError("");
try {
const publicData = {
...draft.retainedPublicData,
username: draft.username.trim() || undefined
};
if (!draft.username.trim()) delete publicData.username;
const shared = {
name: draft.name.trim(),
description: draft.description.trim() || null,
credential_kind: draft.credentialKind,
public_data: publicData,
allowed_modules: splitValues(draft.allowedModules),
allowed_server_refs: splitValues(draft.allowedServerRefs),
inherit_to_lower_scopes: draft.inheritToLowerScopes,
is_active: draft.isActive
};
if (editing === "new") {
await createCredentialEnvelope(settings, {
scope_type: scopeType,
scope_id: activeScopeId,
...shared,
secret_data: secretPayload(draft)
});
} else {
await updateCredentialEnvelope(settings, editing.id, {
...shared,
...(draft.secret.trim() ? { secret_data: secretPayload(draft) } : {}),
clear_secret: draft.clearSecret
});
}
setNotice(editing === "new" ? "Credential created." : "Credential saved.");
closeEditor();
await loadCredentials();
return true;
} catch (err) {
setError(errorMessage(err));
return false;
} finally {
setSaving(false);
}
}
async function confirmDelete() {
if (!deleting) return;
setSaving(true);
setError("");
try {
await deleteCredentialEnvelope(settings, deleting.id);
setNotice("Credential deleted. Connections using it will remain configured but cannot authenticate.");
setDeleting(null);
await loadCredentials();
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
}
const columns = useMemo<ConnectionTreeColumn<CredentialEnvelopeSummary>[]>(
() => [
{
id: "credential",
header: "Credential",
width: "minmax(240px, 1.4fr)",
render: (credential) => (
<div className="connection-tree-main">
<strong>{credential.name}</strong>
<div className="connection-tree-muted-line">
{credential.description || credential.public_data.username
? String(credential.description || credential.public_data.username)
: credential.id}
</div>
</div>
)
},
{
id: "kind",
header: "Type",
width: "minmax(150px, 0.7fr)",
render: (credential) => credentialKindLabel(credential.credential_kind)
},
{
id: "availability",
header: "Availability",
width: "minmax(220px, 1fr)",
render: (credential) => availabilityLabel(credential)
},
{
id: "status",
header: "Status",
width: "120px",
render: (credential) => (
<StatusBadge
status={credential.is_active && credential.secret_configured ? "success" : "inactive"}
label={
credential.is_active
? credential.secret_configured
? "Ready"
: "No secret"
: "Inactive"
}
/>
)
}
],
[]
);
const kindChanged = editing !== null && editing !== "new" &&
draft.credentialKind !== editing.credential_kind;
const saveDisabled = saving || !canWrite || !draft.name.trim() ||
(editing === "new" && !draft.secret.trim()) ||
(kindChanged && !draft.secret.trim());
return (
<div className="credential-envelope-manager">
{targetOptions.length > 0 && (
<FormField label={targetLabel}>
<select
value={selectedTargetId}
disabled={saving}
onChange={(event) => setSelectedTargetId(event.target.value)}
>
{targetOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.label}{option.secondary ? ` - ${option.secondary}` : ""}
</option>
))}
</select>
</FormField>
)}
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{notice && !error && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
<Card
title={title}
actions={
<div className="button-row compact-actions">
<Button
type="button"
title="Reload credentials"
aria-label="Reload credentials"
onClick={() => void loadCredentials()}
disabled={loading}
>
<RefreshCw size={16} />
</Button>
<Button
type="button"
variant="primary"
onClick={openCreate}
disabled={!canWrite || !scopeReady || loading}
>
<Plus size={16} /> Add credential
</Button>
</div>
}
>
<LoadingFrame loading={loading}>
<ConnectionTree
rows={credentials}
columns={columns}
getRowKey={(credential) => credential.id}
emptyText="No credentials are configured for this scope."
renderActions={(credential) => (
<TableActionGroup
actions={[
{
id: "edit",
label: `Edit ${credential.name}`,
icon: <Pencil size={15} />,
onClick: () => openEdit(credential),
disabled: !canWrite || saving
},
{
id: "delete",
label: `Delete ${credential.name}`,
icon: <Trash2 size={15} />,
onClick: () => setDeleting(credential),
disabled: !canWrite || saving,
variant: "danger"
}
]}
/>
)}
/>
</LoadingFrame>
</Card>
<Dialog
open={Boolean(editing)}
title={editing === "new" ? "Add reusable credential" : "Edit reusable credential"}
onClose={closeEditor}
closeDisabled={saving}
className="admin-dialog admin-dialog-wide adaptive-config-dialog"
footerClassName="button-row compact-actions"
footer={
<>
<Button onClick={closeEditor} disabled={saving}>Cancel</Button>
<Button variant="primary" onClick={() => void saveDraft()} disabled={saveDisabled}>
<KeyRound size={16} /> {saving ? "Saving" : "Save credential"}
</Button>
</>
}
>
<div className="adaptive-config-form">
<section className="adaptive-config-section">
<header>
<h3>Identity</h3>
<p>The name and type are visible; secret values are never returned by the API.</p>
</header>
<div className="form-grid two">
<FormField label="Name">
<input value={draft.name} disabled={saving} onChange={(event) => setDraft({ ...draft, name: event.target.value })} autoFocus />
</FormField>
<FormField label="Type">
<select value={draft.credentialKind} disabled={saving} onChange={(event) => setDraft({ ...draft, credentialKind: event.target.value as CredentialKind, secret: "", clearSecret: false })}>
<option value="username_password">Username and password</option>
<option value="token">Access token</option>
<option value="api_key">API key</option>
</select>
</FormField>
<FormField label="Description">
<input value={draft.description} disabled={saving} onChange={(event) => setDraft({ ...draft, description: event.target.value })} />
</FormField>
<FormField label={draft.credentialKind === "username_password" ? "Username" : "Account or key label"}>
<input value={draft.username} disabled={saving} onChange={(event) => setDraft({ ...draft, username: event.target.value })} />
</FormField>
<FormField
label={secretFieldLabel(draft.credentialKind)}
help={editing !== "new" ? "Leave blank to retain the configured secret." : undefined}
>
<PasswordField value={draft.secret} onValueChange={(secret) => setDraft({ ...draft, secret, clearSecret: false })} disabled={saving} autoComplete="new-password" />
</FormField>
{editing !== "new" && (
<ToggleSwitch
checked={draft.clearSecret}
disabled={saving || Boolean(draft.secret)}
onChange={(clearSecret) => setDraft({ ...draft, clearSecret })}
label="Remove configured secret"
/>
)}
</div>
</section>
<section className="adaptive-config-section">
<header>
<h3>Availability</h3>
<p>Empty module or server lists mean every module or server allowed by scope.</p>
</header>
<div className="form-grid two">
<FormField label="Modules" help="Choose installed modules that may use this credential. Empty means every module allowed by scope.">
<ReferenceMultiSelect
values={splitValues(draft.allowedModules)}
onChange={(values) => setDraft({ ...draft, allowedModules: values.join(", ") })}
provider={moduleReferenceProvider}
aria-label="Credential modules"
placeholder="Add a module"
disabled={saving}
/>
</FormField>
<FormField label="Servers" help="Choose announced servers or enter an explicit module-qualified reference. Empty means every permitted server.">
<ReferenceMultiSelect
values={splitValues(draft.allowedServerRefs)}
onChange={(values) => setDraft({ ...draft, allowedServerRefs: values.join(", ") })}
provider={serverReferenceProvider}
createCustomOption={(value) => customReferenceOption(value, "server")}
aria-label="Credential servers"
placeholder="Add a server reference"
disabled={saving}
/>
</FormField>
<ToggleSwitch checked={draft.inheritToLowerScopes} disabled={saving} onChange={(inheritToLowerScopes) => setDraft({ ...draft, inheritToLowerScopes })} label="Visible to lower scopes" />
<ToggleSwitch checked={draft.isActive} disabled={saving} onChange={(isActive) => setDraft({ ...draft, isActive })} label="Active" />
</div>
</section>
</div>
</Dialog>
<ConfirmDialog
open={Boolean(deleting)}
title="Delete credential"
message={`Delete ${deleting?.name ?? "this credential"}? Connections that reference it will stop authenticating; the secret cannot be recovered.`}
confirmLabel="Delete"
tone="danger"
busy={saving}
onCancel={() => setDeleting(null)}
onConfirm={() => void confirmDelete()}
/>
</div>
);
}
function credentialDraft(credential: CredentialEnvelopeSummary): CredentialDraft {
return {
...EMPTY_DRAFT,
name: credential.name,
description: credential.description ?? "",
credentialKind: supportedCredentialKind(credential.credential_kind),
username: String(credential.public_data.username ?? ""),
allowedModules: credential.allowed_modules.join(", "),
allowedServerRefs: credential.allowed_server_refs.join(", "),
inheritToLowerScopes: credential.inherit_to_lower_scopes,
isActive: credential.is_active,
retainedPublicData: { ...credential.public_data }
};
}
function credentialDraftKey(draft: CredentialDraft): string {
return JSON.stringify(draft);
}
function splitValues(value: string): string[] {
return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))];
}
function secretPayload(draft: CredentialDraft): Record<string, string> {
const secret = draft.secret.trim();
if (!secret) return {};
if (draft.credentialKind === "token") return { token: secret };
if (draft.credentialKind === "api_key") return { api_key: secret };
return { password: secret };
}
function supportedCredentialKind(value: string): CredentialKind {
return value === "token" || value === "api_key" ? value : "username_password";
}
function secretFieldLabel(kind: CredentialKind): string {
if (kind === "token") return "Access token";
if (kind === "api_key") return "API key";
return "Password";
}
function credentialKindLabel(kind: string): string {
if (kind === "username_password") return "Username and password";
if (kind === "api_key") return "API key";
if (kind === "token") return "Access token";
return kind.split("_").join(" ");
}
function availabilityLabel(credential: CredentialEnvelopeSummary): string {
const modules = credential.allowed_modules.length
? credential.allowed_modules.join(", ")
: "All modules";
const servers = credential.allowed_server_refs.length
? `${credential.allowed_server_refs.length} server restriction${credential.allowed_server_refs.length === 1 ? "" : "s"}`
: "all servers";
return `${modules} · ${servers}${credential.inherit_to_lower_scopes ? " · inherited" : ""}`;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

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