9 Commits
Author SHA1 Message Date
zemion a2dd280c47 feat(records): enforce purpose-bound restricted access
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 19:07:04 +02:00
zemion ecfcdd1fea fix(webui): bind record lifecycle controls to help
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 11:36:43 +02:00
zemion 312a8e281e docs(records): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 11s
2026-08-23 19:31:24 +02:00
zemion a5eee2c23f feat(records): complete eAkte reference journey
Module Package Release / publish-packages (push) Successful in 12s
2026-08-22 18:42:09 +02:00
zemion 38f203a906 feat(records): add governed DSAR coverage 2026-08-21 02:31:10 +02:00
zemion e9d2827581 refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:45 +02:00
zemion 6159bde2b5 test: pin resident parking permit record journey 2026-08-18 21:32:34 +02:00
zemion e7ee0b8017 Adopt shared WebUI structural primitives 2026-08-18 13:17:32 +02:00
zemion 3800ad4662 Adopt shared WebUI layout primitives 2026-08-18 10:42:53 +02:00
29 changed files with 4955 additions and 229 deletions
+33 -9
View File
@@ -12,7 +12,17 @@ authority over their objects and bytes.
The first working vertical slice includes PostgreSQL/SQLite models and an The first working vertical slice includes PostgreSQL/SQLite models and an
Alembic migration, tenant-scoped APIs, optimistic concurrency and idempotent Alembic migration, tenant-scoped APIs, optimistic concurrency and idempotent
filing, temporal reads, search registration, a full-height Records workspace, filing, temporal reads, search registration, a full-height Records workspace,
and provider-neutral filing from Files and Cases. provider-neutral filing from Files, Cases, Forms Runtime, and Decisions, and a
governed close/retention/hold/appraisal/disposition/transfer lifecycle.
The `privacy.dsar.records` capability exports bounded record and lifecycle
metadata from an explicit record or authoritative source linkage. Canonical
account, identity, and membership selectors return minimized staff
accountability attribution; they do not turn a staff action into record-subject
ownership. Immutable eAkte evidence is retained, while the current record fact
is routed to manual lifecycle review. Source content, snapshots, opaque
contexts, payloads, hashes, replay keys, and archive manifests/receipts are not
exported by Records.
## Initial Ownership ## Initial Ownership
@@ -39,24 +49,38 @@ Implemented optional integrations:
- files - files
- cases - cases
- forms runtime
- decisions
- approvals
- audit
- search - search
Planned optional integrations include: Planned optional integrations include:
- forms runtime and decisions
- Campaigns, Postbox, and Reporting - Campaigns, Postbox, and Reporting
- DMS/archive providers - target-tested DMS/archive providers
- policy - policy
- audit
- transparency - transparency
## Current Boundary ## Current Boundary
The current kernel supports planned and open records. Restricted per-record The current kernel supports planned/open records, exact filing, closure,
grants, closure, retention calculation, holds, appraisal, disposition, retention calculation, holds, appraisal, independent disposition approval,
transfer, destruction, and external archive effects remain explicit later archive-neutral packaging, recovery evidence, and a clearly marked transfer
work packages. Enabling Records does not trigger destructive or external simulation. The simulation never claims archival custody. Restricted
effects. per-record grants, real target conformance, destructive execution, and real
archive effects remain explicit later work packages.
## Reference Journey
The executable resident-parking-permit fixture proves equivalent assisted and
authenticated digital eAkten. It files the exact form, attachment, case and
representation, decision, delivery, and correction revisions; preserves
authority, purpose, policy, chronology, and custody evidence; exercises hold
and independent approval; and verifies search, temporal reconstruction, and
source/package integrity after a SQLite backup/restore round trip. The Records
workspace keeps the selected `recordId` in the URL so an authorized actor can
resume the same record and inspect the complete evidence chain.
The detailed target and implementation sequence are documented in The detailed target and implementation sequence are documented in
[docs/EAKTE_ARCHITECTURE.md](docs/EAKTE_ARCHITECTURE.md). [docs/EAKTE_ARCHITECTURE.md](docs/EAKTE_ARCHITECTURE.md).
+25 -13
View File
@@ -40,13 +40,19 @@ packages tracked by Records #2-#6:
- tenant administration for versioned file-plan nodes and record classes, - tenant administration for versioned file-plan nodes and record classes,
explicit volume management, and lifecycle controls in the eAkte workspace. explicit volume management, and lifecycle controls in the eAkte workspace.
The remaining boundary is intentionally visible rather than implied: Records The cross-module reference journey from Records #8 is executable: equivalent
#7 requires selection and target testing of an archive/xdomea endpoint and assisted and authenticated digital permit records preserve exact form,
conformance profile; #8 completes the cross-module reference journey and attachment, case/representation, decision, delivery, and correction evidence;
production evidence. Restricted per-record access grants also remain a exercise closure, retention, hold, approval, and simulated transfer; and prove
dedicated access-policy slice. Destruction is represented only as an approved search and temporal reconstruction after database backup/restore. The
pending state; no content deletion or real archive effect is currently remaining boundary is intentionally visible rather than implied: Records #7
claimed. requires target testing of the selected d.velop d3 archive endpoint and its
conformance profile. Restricted per-record access is implemented through
revisioned, effective-dated subject/action/purpose grants. Current grants
govern historical reads and replays, `records.search` is an explicit purpose,
and the last active management grant cannot be revoked while a record remains
restricted. Destruction is represented only as an approved pending state; no
content deletion or real archive effect is currently claimed.
## Ownership Boundary ## Ownership Boundary
@@ -133,9 +139,11 @@ correction path.
filing independently. filing independently.
- The current security context always controls browsing, including historical - The current security context always controls browsing, including historical
views. views.
- Purpose-aware access may be narrower than ordinary read permission and can - Restricted access is narrower than ordinary module read permission. It
require case assignment, represented function, mandate, legal basis, or requires a current account, membership, group, role, function-assignment, or
reason-for-access capture. delegation grant whose action and exact allowed purpose match the request.
Manage implies write/read and write implies read; all other matches are
exact and fail closed without revealing the record.
- A record item can reference a message, decision, form submission, report, - A record item can reference a message, decision, form submission, report,
dataset materialization, external DMS object, physical item, or paper scan; dataset materialization, external DMS object, physical item, or paper scan;
it is not limited to Files. it is not limited to Files.
@@ -234,6 +242,10 @@ available in an evidence/details view.
5. Add destruction/recovery, TR-ESOR provider integration, migration, and 5. Add destruction/recovery, TR-ESOR provider integration, migration, and
signed reference-journey evidence. signed reference-journey evidence.
The first reference package should file the digital and assisted variants of The first reference package files the digital and assisted variants of the
the same service-to-decision journey into equivalent records and prove search, resident-parking-permit service-to-decision journey into equivalent records.
historical reconstruction, hold, transfer, restore, and access explanation. Its fixture and regression test prove search, historical reconstruction, hold,
independent approval, simulated transfer, restore, current access explanation,
source digest revalidation, and transfer-manifest integrity. Its UI contract
pins URL-based resume, exact-source filing presets, governance context,
evidence semantics, lifecycle actions, and recovery inspection.
+36 -9
View File
@@ -38,8 +38,8 @@ The native kernel currently provides:
- independent valid and recorded time with current/at/all temporal reads; - independent valid and recorded time with current/at/all temporal reads;
- tenant, purpose, actor/capacity, source authority, institutional context, - tenant, purpose, actor/capacity, source authority, institutional context,
provenance, and idempotency fields; provenance, and idempotency fields;
- provider-neutral exact-source capabilities, initially implemented by Files - provider-neutral exact-source capabilities implemented by Files versions,
file versions and Cases revisions; Cases revisions, Forms Runtime submissions, and Decisions revisions;
- tenant APIs, search projection, uninstall/retirement guards, and a Records - tenant APIs, search projection, uninstall/retirement guards, and a Records
workspace using shared WebUI controls. workspace using shared WebUI controls.
- immutable close/reopen, retention, hold, appraisal, and independently - immutable close/reopen, retention, hold, appraisal, and independently
@@ -49,17 +49,44 @@ The native kernel currently provides:
- Core recovery-ledger adoption, atomic terminal evidence, Audit projection, - Core recovery-ledger adoption, atomic terminal evidence, Audit projection,
source and package restore diagnostics, catalog administration, volumes, and source and package restore diagnostics, catalog administration, volumes, and
lifecycle UI. lifecycle UI.
- an executable assisted/digital resident-parking-permit reference package
that proves equivalent filing semantics, URL resume, search, temporal
reconstruction, and evidence integrity after database backup/restore.
- revisioned, effective-dated restricted-record grants for accounts,
memberships, groups, roles, function assignments, and delegations, with
exact read/write/manage actions and allowed purposes. Current grants govern
historical reads and idempotent replays; search requires `records.search`.
Restricted object grants remain a separate governed slice. A target-tested A target-tested archive adapter and any destructive effect remain deliberately unimplemented;
archive adapter and any destructive effect remain deliberately unimplemented;
approved destruction is only a pending lifecycle state. approved destruction is only a pending lifecycle state.
## First Implementation Slice ## Data-subject requests
Complete restricted access and one target-tested archive provider without Records publishes `privacy.dsar.records`. A record-subject search must include
moving source-module ownership into Records. Extend the executable reference an exact record, revision, item, volume, chronology, hold, disposition,
journey across assisted service, decision, filing, hold, restore, search, and transfer-package, or authoritative source-module reference. A canonical
archive simulation before claiming production maturity. account, identity, or membership match identifies staff accountability
activity only; creating, filing, reviewing, or transferring an eAkte does not
make that staff member the subject of its contents.
The provider exports bounded record identity, revision, filing, chronology,
hold, disposition, transfer, and the subject's own restricted-access grant
lifecycle metadata. It excludes source
content, record snapshots and search text, opaque institutional contexts and
payloads, grant reasons, digests, replay keys, launch URLs, approval identifiers, archive
manifests and receipts, and unrelated records. Exact source content remains in
the source owner's DSAR provider. Record revisions and lifecycle evidence are
immutable retention evidence; the current record fact receives a
non-executable manual-review action. Actual correction, closure, appraisal,
hold, disposition, or transfer must use the governed eAkte lifecycle.
## Next Boundary Slice
Complete one target-tested archive provider without moving source-module
ownership into Records. The executable reference journey
already covers assisted/digital intake, decision, filing, hold, restore,
search, and archive simulation; it is evidence for the native boundary, not a
claim of target archive conformance or transferred custody.
The complete native/external boundary, temporal and purpose-aware record model, The complete native/external boundary, temporal and purpose-aware record model,
disposition lifecycle, German public-sector provider profiles, and staged disposition lifecycle, German public-sector provider profiles, and staged
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/records", "name": "@govoplan/records",
"version": "0.1.19", "version": "0.1.23",
"private": true, "private": true,
"description": "GovOPlaN eAkte and institutional records module.", "description": "GovOPlaN eAkte and institutional records module.",
"type": "module", "type": "module",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-records" name = "govoplan-records"
version = "0.1.19" version = "0.1.23"
description = "GovOPlaN eAkte and institutional records module." description = "GovOPlaN eAkte and institutional records module."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+65
View File
@@ -261,6 +261,70 @@ class RecordRevision(Base, TimestampMixin):
snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class RecordAccessGrantRevision(Base, TimestampMixin):
__tablename__ = "record_access_grant_revisions"
__table_args__ = (
UniqueConstraint(
"tenant_id", "grant_id", "revision", name="uq_record_access_grant_revision"
),
UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_record_access_grant_idempotency"
),
Index(
"ix_record_access_grant_current",
"tenant_id",
"record_id",
"status",
"superseded_at",
),
Index(
"ix_record_access_grant_subject",
"tenant_id",
"subject_type",
"subject_id",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
grant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
record_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
previous_revision_id: Mapped[str | None] = mapped_column(
ForeignKey("record_access_grant_revisions.id", ondelete="RESTRICT"),
nullable=True,
index=True,
)
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
subject_type: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
subject_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
actions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
allowed_purposes: Mapped[list[str]] = mapped_column(
JSON, default=list, nullable=False
)
reason: Mapped[str] = mapped_column(Text, nullable=False)
valid_from: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
valid_to: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
superseded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
institutional_context: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
changed_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
class RecordVolumeRevision(Base, TimestampMixin): class RecordVolumeRevision(Base, TimestampMixin):
__tablename__ = "record_volume_revisions" __tablename__ = "record_volume_revisions"
__table_args__ = ( __table_args__ = (
@@ -588,6 +652,7 @@ class RecordTransferPackageRevision(Base, TimestampMixin):
__all__ = [ __all__ = [
"RecordAccessGrantRevision",
"RecordChronologyEntry", "RecordChronologyEntry",
"RecordClassRevision", "RecordClassRevision",
"RecordFilePlanRevision", "RecordFilePlanRevision",
File diff suppressed because it is too large Load Diff
+227 -12
View File
@@ -16,6 +16,7 @@ from govoplan_core.core.module_guards import (
) )
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
CapabilityDocumentation, CapabilityDocumentation,
DocumentationCondition,
DocumentationLink, DocumentationLink,
DocumentationTopic, DocumentationTopic,
FrontendModule, FrontendModule,
@@ -26,6 +27,7 @@ from govoplan_core.core.modules import (
ModuleManifest, ModuleManifest,
NavItem, NavItem,
PermissionDefinition, PermissionDefinition,
ProductAreaContribution,
RoleTemplate, RoleTemplate,
) )
from govoplan_core.core.provider_governance import declared_module_architecture from govoplan_core.core.provider_governance import declared_module_architecture
@@ -37,6 +39,10 @@ from govoplan_core.core.search import SearchSourceProviderRegistration
from govoplan_core.core.views import ViewSurface from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_records.backend.db import models as record_models from govoplan_records.backend.db import models as record_models
from govoplan_records.backend.dsar_provider import (
RECORDS_DSAR_CAPABILITY,
RecordsDsarProvider,
)
from govoplan_records.backend.search_source import create_records_search_source from govoplan_records.backend.search_source import create_records_search_source
from govoplan_records.backend.service import SqlRecordRegistry from govoplan_records.backend.service import SqlRecordRegistry
from govoplan_records.backend.archive import ( from govoplan_records.backend.archive import (
@@ -47,7 +53,7 @@ from govoplan_records.backend.archive import (
MODULE_ID = "records" MODULE_ID = "records"
MODULE_NAME = "Records" MODULE_NAME = "Records"
MODULE_VERSION = "0.1.19" MODULE_VERSION = "0.1.23"
READ_SCOPE = "records:workspace:read" READ_SCOPE = "records:workspace:read"
WRITE_SCOPE = "records:workspace:write" WRITE_SCOPE = "records:workspace:write"
ADMIN_SCOPE = "records:workspace:admin" ADMIN_SCOPE = "records:workspace:admin"
@@ -93,6 +99,11 @@ def _records_registry(context: ModuleContext) -> SqlRecordRegistry:
return SqlRecordRegistry(context.registry) return SqlRecordRegistry(context.registry)
def _dsar_provider(context: ModuleContext) -> RecordsDsarProvider:
del context
return RecordsDsarProvider()
def _simulated_archive_provider( def _simulated_archive_provider(
context: ModuleContext, context: ModuleContext,
) -> SimulatedRecordArchiveProvider: ) -> SimulatedRecordArchiveProvider:
@@ -135,11 +146,21 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
) )
.count() .count()
) )
active_restricted_grants = (
session.query(record_models.RecordAccessGrantRevision)
.filter(
record_models.RecordAccessGrantRevision.tenant_id == tenant_id,
record_models.RecordAccessGrantRevision.superseded_at.is_(None),
record_models.RecordAccessGrantRevision.status == "active",
)
.count()
)
return { return {
"records": records, "records": records,
"open_records": open_records, "open_records": open_records,
"active_holds": active_holds, "active_holds": active_holds,
"pending_dispositions": pending_dispositions, "pending_dispositions": pending_dispositions,
"active_restricted_grants": active_restricted_grants,
} }
@@ -183,6 +204,61 @@ ROLE_TEMPLATES = (
) )
DOCUMENTATION = ( DOCUMENTATION = (
DocumentationTopic(
id="records.data-subject-requests",
title="eAkte data-subject requests",
summary="Export minimized record metadata while preserving retention, hold, disposition, and archive evidence.",
body=(
"Records correlates exact record, revision, item, volume, chronology, hold, disposition, transfer-package, and provider-owned source references inside one tenant. Account, identity, and membership selectors expose staff accountability attribution and the subject's own restricted-record grants but never imply that the staff member is the subject of a record. A record-subject export therefore needs an authoritative record or source linkage. "
"The export includes bounded record, filing, chronology, hold, disposition, transfer, and access-grant lifecycle metadata. Source content, snapshots, search text, opaque contexts and payloads, grant reasons, hashes, replay keys, launch URLs, archive manifests and receipts, approval identifiers, and unrelated records are excluded. Immutable evidence is retained. The current record fact receives non-executable manual review and can change only through the governed eAkte lifecycle."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "records_manager", "operator", "module_admin", "auditor"),
order=90,
related_modules=(
"core",
"cases",
"files",
"forms_runtime",
"workflow_engine",
"approvals",
"dms",
),
links=(
DocumentationLink(
label="Records domain boundary",
href="govoplan-records/docs/RECORDS_DOMAIN_BOUNDARY.md",
kind="repository",
),
),
metadata={
"kind": "reference",
"help_contexts": [
"records.data-subject-requests",
"records.lifecycle",
],
},
translations={
"de": {
"title": "Datenschutzanfragen zur eAkte",
"summary": (
"Minimierte Aktenmetadaten ausgeben und dabei Aufbewahrungs-, Sperr-, Aussonderungs- und Archivnachweise bewahren."
),
"body": (
"Records gleicht innerhalb eines Mandanten exakte Verweise auf Akte, Revision, Eintrag, Band, Chronologie, "
"Sperre, Aussonderung, Übergabepaket und anbietergeführte Quellen ab. Konto-, Identitäts- und "
"Mitgliedschaftsselektoren zeigen Verantwortungszuordnungen von Beschäftigten und deren eigene Freigaben für besonders geschützte Akten, bedeuten aber niemals, dass die "
"beschäftigte Person Gegenstand einer Akte ist. Eine Auskunft zur aktenbetroffenen Person erfordert deshalb eine "
"führende Akten- oder Quellverknüpfung. Die Ausgabe enthält begrenzte Metadaten zu Akte, Veraktung, Chronologie, "
"Sperre, Aussonderung, Übergabe und Zugriffsfreigaben. Quellinhalt, Schnappschüsse, Suchtext, undurchsichtige Kontexte und Nutzdaten, Freigabebegründungen, "
"Prüfsummen, Wiederholungsschlüssel, Start-URLs, Archivmanifeste und -belege, Genehmigungskennungen und fremde "
"Akten bleiben ausgeschlossen. Unveränderliche Nachweise werden aufbewahrt. Der aktuelle Aktenfakt erhält eine "
"nicht ausführbare manuelle Prüfung und kann nur über den gesteuerten eAkte-Lebenszyklus verändert werden."
),
}
},
),
DocumentationTopic( DocumentationTopic(
id="records.workspace", id="records.workspace",
title="eAkte workspace", title="eAkte workspace",
@@ -192,11 +268,14 @@ DOCUMENTATION = (
"volumes, filing decisions, and chronology. Files and other source modules continue to own " "volumes, filing decisions, and chronology. Files and other source modules continue to own "
"their content. Filing resolves and preserves an exact source revision only after the source " "their content. Filing resolves and preserves an exact source revision only after the source "
"module confirms current access. The titlebar temporal selection changes valid and recorded " "module confirms current access. The titlebar temporal selection changes valid and recorded "
"time while current authorization always remains in force." "time while current authorization always remains in force. The selected record is preserved "
"as a recordId URL parameter so an authorized actor can resume the same evidence view. Restricted "
"records additionally require a current purpose-bound object grant for every read and mutation."
), ),
layer="configured", layer="configured",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("user", "records_manager", "operator", "module_admin", "auditor"), audience=("user", "records_manager", "operator", "module_admin", "auditor"),
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
order=100, order=100,
related_modules=OPTIONAL_DEPENDENCIES, related_modules=OPTIONAL_DEPENDENCIES,
links=( links=(
@@ -216,11 +295,14 @@ DOCUMENTATION = (
"Quellmodule bleiben Eigentümer ihrer Inhalte. Bei der Veraktung wird erst nach aktueller " "Quellmodule bleiben Eigentümer ihrer Inhalte. Bei der Veraktung wird erst nach aktueller "
"Zugriffsprüfung durch das Quellmodul eine exakte Quellrevision festgehalten. Die temporale " "Zugriffsprüfung durch das Quellmodul eine exakte Quellrevision festgehalten. Die temporale "
"Auswahl in der Titelleiste ändert Gültigkeits- und Erfassungszeit; die aktuelle Berechtigung " "Auswahl in der Titelleiste ändert Gültigkeits- und Erfassungszeit; die aktuelle Berechtigung "
"gilt stets weiter." "gilt stets weiter. Die ausgewählte Akte wird als recordId in der URL bewahrt. Besonders "
"geschützte Akten erfordern zusätzlich für jeden Lese- und Änderungsvorgang eine aktuelle "
"zweckgebundene Objektfreigabe."
), ),
} }
}, },
metadata={ metadata={
"kind": "workflow",
"help_contexts": [ "help_contexts": [
"records.workspace", "records.workspace",
"records.file-plan", "records.file-plan",
@@ -240,10 +322,55 @@ DOCUMENTATION = (
], ],
}, },
), ),
DocumentationTopic(
id="records.restricted-access",
title="Purpose-bound restricted record access",
summary="Grant effective-dated record access to explicit institutional subjects and purposes.",
body=(
"A restricted record is omitted unless the current account, membership, group, role, function assignment, "
"or delegation has an active object grant for the requested read, write, or manage action and the exact "
"declared purpose. Manage implies write and read; write implies read. The same current grant policy governs "
"historical views and idempotent replays. Search uses the separate purpose records.search. Creating a "
"restricted record establishes the creator as its first manager, and the final active management grant "
"cannot be revoked while the record remains restricted. Administrators should grant the narrowest subject, "
"actions, purposes, and validity interval that the assignment requires."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "records_manager", "module_admin", "auditor"),
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
order=105,
related_modules=("access", "search", "policy", "audit"),
translations={
"de": {
"title": "Zweckgebundener Zugriff auf besonders geschützte Akten",
"summary": "Zeitlich wirksame Aktenzugriffe für eindeutige institutionelle Subjekte und Zwecke vergeben.",
"body": (
"Eine besonders geschützte Akte wird nur angezeigt, wenn für das aktuelle Konto, die Mitgliedschaft, "
"Gruppe, Rolle, Funktionszuordnung oder Delegation eine aktive Objektfreigabe für die angeforderte "
"Lese-, Schreib- oder Verwaltungsaktion und den exakt erklärten Zweck besteht. Verwalten umfasst "
"Schreiben und Lesen; Schreiben umfasst Lesen. Dieselbe aktuelle Freigabelage gilt für historische "
"Ansichten und idempotente Wiederholungen. Die Suche verwendet den eigenen Zweck records.search. "
"Beim Anlegen einer besonders geschützten Akte wird die anlegende Person als erste Verwaltungsperson "
"eingetragen. Solange die Akte geschützt bleibt, kann die letzte aktive Verwaltungsfreigabe nicht "
"entzogen werden. Administratoren sollen Subjekt, Aktionen, Zwecke und Gültigkeitszeitraum so eng wie "
"für die Aufgabe erforderlich festlegen."
),
}
},
metadata={
"kind": "workflow",
"help_contexts": [
"records.restricted-access",
"records.field.access-mode",
"records.field.initial-purposes",
],
},
),
DocumentationTopic( DocumentationTopic(
id="records.filing", id="records.filing",
title="Exact record filing", title="Exact record filing",
summary="File immutable Files or Cases revisions through a provider-neutral capability.", summary="File immutable Files, Cases, Forms Runtime, or Decisions revisions through a provider-neutral capability.",
body=( body=(
"Every filing requires a record, purpose, filing reason, idempotency key, and exact source " "Every filing requires a record, purpose, filing reason, idempotency key, and exact source "
"revision. Records stores source identity, authority mode, digest and content metadata where " "revision. Records stores source identity, authority mode, digest and content metadata where "
@@ -254,7 +381,14 @@ DOCUMENTATION = (
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("user", "records_manager", "operator", "module_admin", "auditor"), audience=("user", "records_manager", "operator", "module_admin", "auditor"),
order=110, order=110,
related_modules=("files", "cases", "policy", "audit"), related_modules=(
"files",
"cases",
"forms_runtime",
"decisions",
"policy",
"audit",
),
links=( links=(
DocumentationLink( DocumentationLink(
label="Records domain boundary", label="Records domain boundary",
@@ -276,6 +410,7 @@ DOCUMENTATION = (
} }
}, },
metadata={ metadata={
"kind": "reference",
"help_contexts": [ "help_contexts": [
"records.action.file", "records.action.file",
"records.field.source-module", "records.field.source-module",
@@ -344,6 +479,66 @@ DOCUMENTATION = (
], ],
}, },
), ),
DocumentationTopic(
id="records.reference-journey",
title="Assisted and digital eAkte reference journey",
summary="Reconstruct the resident-parking-permit evidence chain and verify recovery without implying archive custody.",
body=(
"Equivalent assisted and authenticated digital resident-parking-permit records preserve exact "
"form, attachment, case and representation, formal decision, delivery receipt, and correction "
"revisions. The record view explains each source version, authority mode, relationship, filing "
"purpose, institutional context, legal basis, retention policy, chronology, hold, independent "
"approval, and transfer result. The selected recordId remains in the URL for authorized resume. "
"Automated evidence restores a database backup, searches and temporally reconstructs both records, "
"and revalidates source digests and the archive-neutral manifest. A simulated receipt explicitly "
"states that custody was not transferred; real archive conformance remains target-specific."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "records_manager", "operator", "module_admin", "auditor"),
order=130,
related_modules=(
"forms_runtime",
"files",
"cases",
"decisions",
"approvals",
"search",
),
links=(
DocumentationLink(
label="eAkte architecture",
href="govoplan-records/docs/EAKTE_ARCHITECTURE.md",
kind="repository",
),
),
translations={
"de": {
"title": "Referenzablauf für assistierte und digitale eAkten",
"summary": "Die Nachweiskette des Anwohnerparkausweises rekonstruieren und die Wiederherstellung prüfen, ohne Archivverwahrung zu behaupten.",
"body": (
"Gleichwertige assistierte und authentifizierte digitale Akten zum Anwohnerparkausweis "
"bewahren exakte Revisionen von Formular, Anlage, Vorgang und Vertretung, förmlicher "
"Entscheidung, Zustellbeleg und Korrektur. Die Aktenansicht erläutert Quellversion, "
"Autoritätsmodus, Beziehung, Veraktungszweck, institutionellen Kontext, Rechtsgrundlage, "
"Aufbewahrungsrichtlinie, Chronologie, Sperre, unabhängige Freigabe und Übergabeergebnis. "
"Die ausgewählte recordId bleibt zum berechtigten Fortsetzen in der URL. Automatisierte "
"Nachweise stellen eine Datenbanksicherung wieder her, suchen und rekonstruieren beide "
"Akten temporal und prüfen Quellprüfsummen sowie das archivneutrale Manifest erneut. Ein "
"simulierter Beleg stellt ausdrücklich klar, dass keine Verwahrung übertragen wurde."
),
}
},
metadata={
"help_contexts": [
"records.governance-context",
"records.record-items",
"records.chronology",
"records.lifecycle",
"records.lifecycle.recovery",
],
},
),
) )
@@ -392,6 +587,17 @@ manifest = ModuleManifest(
surface_id="records.navigation", surface_id="records.navigation",
), ),
), ),
product_areas=(
ProductAreaContribution(
id="records-documents",
module_id=MODULE_ID,
label="i18n:govoplan-core.product_area.records_documents",
icon="folder",
description="i18n:govoplan-core.product_area.records_documents_description",
surface_ids=("records.navigation", "records.workspace"),
order=30,
),
),
view_surfaces=( view_surfaces=(
ViewSurface( ViewSurface(
id="records.workspace.file-plan", id="records.workspace.file-plan",
@@ -431,10 +637,12 @@ manifest = ModuleManifest(
ModuleInterfaceProvider(name="records.registry", version="1.0.0"), ModuleInterfaceProvider(name="records.registry", version="1.0.0"),
ModuleInterfaceProvider(name="records.filing", version="1.0.0"), ModuleInterfaceProvider(name="records.filing", version="1.0.0"),
ModuleInterfaceProvider(name="records.archive", version="1.0.0"), ModuleInterfaceProvider(name="records.archive", version="1.0.0"),
ModuleInterfaceProvider(name=RECORDS_DSAR_CAPABILITY, version="0.1.0"),
), ),
capability_factories={ capability_factories={
CAPABILITY_RECORDS_FILING: _records_registry, CAPABILITY_RECORDS_FILING: _records_registry,
record_archive_capability(SIMULATION_PROVIDER_ID): _simulated_archive_provider, record_archive_capability(SIMULATION_PROVIDER_ID): _simulated_archive_provider,
RECORDS_DSAR_CAPABILITY: _dsar_provider,
}, },
capability_documentation={ capability_documentation={
CAPABILITY_RECORDS_FILING: CapabilityDocumentation( CAPABILITY_RECORDS_FILING: CapabilityDocumentation(
@@ -449,6 +657,11 @@ manifest = ModuleManifest(
), ),
contract_version="1.0.0", contract_version="1.0.0",
), ),
RECORDS_DSAR_CAPABILITY: CapabilityDocumentation(
label="Records data-subject request provider",
summary="Finds minimized eAkte and operator-attribution evidence without bypassing record retention.",
contract_version="0.1.0",
),
}, },
migration_spec=MigrationSpec( migration_spec=MigrationSpec(
module_id=MODULE_ID, module_id=MODULE_ID,
@@ -462,6 +675,7 @@ manifest = ModuleManifest(
record_models.RecordHoldRevision, record_models.RecordHoldRevision,
record_models.RecordItem, record_models.RecordItem,
record_models.RecordVolumeRevision, record_models.RecordVolumeRevision,
record_models.RecordAccessGrantRevision,
record_models.RecordRevision, record_models.RecordRevision,
record_models.RecordIdentity, record_models.RecordIdentity,
record_models.RecordClassRevision, record_models.RecordClassRevision,
@@ -477,6 +691,7 @@ manifest = ModuleManifest(
persistent_table_uninstall_guard( persistent_table_uninstall_guard(
record_models.RecordIdentity, record_models.RecordIdentity,
record_models.RecordRevision, record_models.RecordRevision,
record_models.RecordAccessGrantRevision,
record_models.RecordItem, record_models.RecordItem,
record_models.RecordChronologyEntry, record_models.RecordChronologyEntry,
record_models.RecordHoldRevision, record_models.RecordHoldRevision,
@@ -513,12 +728,13 @@ manifest = ModuleManifest(
), ),
), ),
purpose_aware_access=InformationGovernanceDimension( purpose_aware_access=InformationGovernanceDimension(
adoption="partial", adoption="enforced",
object_types=("record", "record_item"), object_types=("record", "record_item", "record_access_grant"),
evidence=("src/govoplan_records/backend/service.py",), evidence=(
limitation=( "src/govoplan_records/backend/service.py",
"Purposes are mandatory and preserved for record operations, but Policy-backed " "src/govoplan_records/backend/search_source.py",
"object-level purpose constraints and restricted-record grants are not implemented yet." "tests/test_records.py",
"tests/test_search_source.py",
), ),
), ),
retention=InformationGovernanceDimension( retention=InformationGovernanceDimension(
@@ -557,7 +773,6 @@ manifest = ModuleManifest(
documentation_ref="docs/EAKTE_ARCHITECTURE.md", documentation_ref="docs/EAKTE_ARCHITECTURE.md",
test_ref="tests/test_records.py", test_ref="tests/test_records.py",
known_limits=( known_limits=(
"Restricted object grants are tracked separately.",
"Archive packaging is native, but real target conformance and destructive effects remain external work.", "Archive packaging is native, but real target conformance and destructive effects remain external work.",
), ),
supported_authority_modes=( supported_authority_modes=(
@@ -0,0 +1,97 @@
"""Add purpose-bound restricted-record access grants.
Revision ID: 8a6c4e2f1b3d
Revises: 7f5b3d9a2c1e
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "8a6c4e2f1b3d"
down_revision = "7f5b3d9a2c1e"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"record_access_grant_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("grant_id", sa.String(length=255), nullable=False),
sa.Column("record_id", sa.String(length=255), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("subject_type", sa.String(length=40), nullable=False),
sa.Column("subject_id", sa.String(length=255), nullable=False),
sa.Column("actions", sa.JSON(), nullable=False),
sa.Column("allowed_purposes", sa.JSON(), nullable=False),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=False),
sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("institutional_context", sa.JSON(), nullable=False),
sa.Column("changed_by", sa.String(length=255), nullable=True),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["previous_revision_id"],
["record_access_grant_revisions.id"],
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id",
"grant_id",
"revision",
name="uq_record_access_grant_revision",
),
sa.UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_record_access_grant_idempotency",
),
)
for column in (
"tenant_id",
"grant_id",
"record_id",
"previous_revision_id",
"status",
"subject_type",
"subject_id",
"valid_from",
"valid_to",
"recorded_at",
"superseded_at",
"changed_by",
):
op.create_index(
op.f(f"ix_record_access_grant_revisions_{column}"),
"record_access_grant_revisions",
[column],
unique=False,
)
op.create_index(
"ix_record_access_grant_current",
"record_access_grant_revisions",
["tenant_id", "record_id", "status", "superseded_at"],
unique=False,
)
op.create_index(
"ix_record_access_grant_subject",
"record_access_grant_revisions",
["tenant_id", "subject_type", "subject_id"],
unique=False,
)
def downgrade() -> None:
op.drop_table("record_access_grant_revisions")
+94 -2
View File
@@ -15,6 +15,9 @@ from govoplan_records.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCO
from govoplan_records.backend.schemas import ( from govoplan_records.backend.schemas import (
FilePlanNodeWriteRequest, FilePlanNodeWriteRequest,
RecordAppraisalRequest, RecordAppraisalRequest,
RecordAccessGrantCreateRequest,
RecordAccessGrantListResponse,
RecordAccessGrantRevokeRequest,
RecordArchiveProviderResponse, RecordArchiveProviderResponse,
RecordCatalogResponse, RecordCatalogResponse,
RecordClassWriteRequest, RecordClassWriteRequest,
@@ -137,6 +140,7 @@ def create_router(registry: object | None = None) -> APIRouter:
record_state: str | None = Query(default=None, alias="state", max_length=40), record_state: str | None = Query(default=None, alias="state", max_length=40),
class_id: str | None = Query(default=None, max_length=255), class_id: str | None = Query(default=None, max_length=255),
file_plan_node_id: str | None = Query(default=None, max_length=255), file_plan_node_id: str | None = Query(default=None, max_length=255),
purpose: str | None = Query(default=None, max_length=255),
offset: int = Query(default=0, ge=0), offset: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=200), limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session), session: Session = Depends(get_session),
@@ -150,6 +154,7 @@ def create_router(registry: object | None = None) -> APIRouter:
state=record_state, state=record_state,
class_id=class_id, class_id=class_id,
file_plan_node_id=file_plan_node_id, file_plan_node_id=file_plan_node_id,
purpose=purpose,
offset=offset, offset=offset,
limit=limit, limit=limit,
) )
@@ -181,6 +186,7 @@ def create_router(registry: object | None = None) -> APIRouter:
def api_get_record( def api_get_record(
record_id: str, record_id: str,
revision: int | None = Query(default=None, ge=1), revision: int | None = Query(default=None, ge=1),
purpose: str | None = Query(default=None, max_length=255),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
) -> RecordDetailResponse: ) -> RecordDetailResponse:
@@ -188,7 +194,11 @@ def create_router(registry: object | None = None) -> APIRouter:
try: try:
return RecordDetailResponse( return RecordDetailResponse(
**records.get_record( **records.get_record(
session, principal, record_id=record_id, revision=revision session,
principal,
record_id=record_id,
revision=revision,
purpose=purpose,
) )
) )
except RecordStoreError as exc: except RecordStoreError as exc:
@@ -197,17 +207,97 @@ def create_router(registry: object | None = None) -> APIRouter:
@router.get("/{record_id}/recovery", response_model=RecordRecoveryStatusResponse) @router.get("/{record_id}/recovery", response_model=RecordRecoveryStatusResponse)
def api_record_recovery_status( def api_record_recovery_status(
record_id: str, record_id: str,
purpose: str = Query(min_length=1, max_length=255),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
) -> RecordRecoveryStatusResponse: ) -> RecordRecoveryStatusResponse:
_require(principal, ADMIN_SCOPE) _require(principal, ADMIN_SCOPE)
try: try:
return RecordRecoveryStatusResponse( return RecordRecoveryStatusResponse(
**records.recovery_status(session, principal, record_id=record_id) **records.recovery_status(
session, principal, record_id=record_id, purpose=purpose
)
) )
except RecordStoreError as exc: except RecordStoreError as exc:
raise _http_error(exc) from exc raise _http_error(exc) from exc
@router.get(
"/{record_id}/access-grants",
response_model=RecordAccessGrantListResponse,
)
def api_list_access_grants(
record_id: str,
purpose: str = Query(min_length=1, max_length=255),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> RecordAccessGrantListResponse:
_require(principal, ADMIN_SCOPE)
try:
return RecordAccessGrantListResponse(
grants=records.list_access_grants(
session, principal, record_id=record_id, purpose=purpose
)
)
except RecordStoreError as exc:
raise _http_error(exc) from exc
@router.post(
"/{record_id}/access-grants",
response_model=dict[str, Any],
status_code=status.HTTP_201_CREATED,
)
def api_create_access_grant(
record_id: str,
payload: RecordAccessGrantCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, ADMIN_SCOPE)
return _write(
session,
lambda: records.create_access_grant(
session,
principal,
record_id=record_id,
payload=payload.model_dump(mode="python"),
),
principal=principal,
operation_type="record.access_grant.create",
idempotency_key=payload.idempotency_key,
request=payload.model_dump(mode="json"),
resource_type="record_access_grant",
resource_id=record_id,
)
@router.post(
"/{record_id}/access-grants/{grant_id}/revoke",
response_model=dict[str, Any],
)
def api_revoke_access_grant(
record_id: str,
grant_id: str,
payload: RecordAccessGrantRevokeRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_require(principal, ADMIN_SCOPE)
return _write(
session,
lambda: records.revoke_access_grant(
session,
principal,
record_id=record_id,
grant_id=grant_id,
payload=payload.model_dump(mode="python"),
),
principal=principal,
operation_type="record.access_grant.revoke",
idempotency_key=payload.idempotency_key,
request={"grant_id": grant_id, **payload.model_dump(mode="json")},
resource_type="record_access_grant",
resource_id=grant_id,
)
@router.patch("/{record_id}", response_model=dict[str, Any]) @router.patch("/{record_id}", response_model=dict[str, Any])
def api_update_record( def api_update_record(
record_id: str, record_id: str,
@@ -216,6 +306,8 @@ def create_router(registry: object | None = None) -> APIRouter:
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]: ) -> dict[str, Any]:
_require(principal, WRITE_SCOPE) _require(principal, WRITE_SCOPE)
if payload.access_mode is not None:
_require(principal, ADMIN_SCOPE)
return _write( return _write(
session, session,
lambda: records.update_record( lambda: records.update_record(
+62
View File
@@ -10,6 +10,16 @@ class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
def _validate_purpose_values(values: list[str], field_name: str) -> None:
normalized = [item.strip() for item in values]
if any(not item or len(item) > 255 for item in normalized):
raise ValueError(
f"{field_name} must contain non-empty values up to 255 characters"
)
if len(normalized) != len(set(normalized)):
raise ValueError(f"{field_name} must contain unique values")
class FilePlanNodeWriteRequest(StrictModel): class FilePlanNodeWriteRequest(StrictModel):
node_id: str = Field(min_length=1, max_length=255) node_id: str = Field(min_length=1, max_length=255)
code: str = Field(min_length=1, max_length=120) code: str = Field(min_length=1, max_length=120)
@@ -74,6 +84,7 @@ class RecordCreateRequest(StrictModel):
"linked_reference", "linked_reference",
] = "native_authoritative" ] = "native_authoritative"
access_mode: Literal["tenant", "restricted"] = "tenant" access_mode: Literal["tenant", "restricted"] = "tenant"
initial_purposes: list[str] = Field(default_factory=list, max_length=100)
purpose: str = Field(min_length=1, max_length=255) purpose: str = Field(min_length=1, max_length=255)
classification: str | None = Field(default=None, max_length=120) classification: str | None = Field(default=None, max_length=120)
responsible_unit_id: str | None = Field(default=None, max_length=255) responsible_unit_id: str | None = Field(default=None, max_length=255)
@@ -90,6 +101,7 @@ class RecordCreateRequest(StrictModel):
def validate_interval(self): def validate_interval(self):
if self.valid_from and self.valid_to and self.valid_to <= self.valid_from: if self.valid_from and self.valid_to and self.valid_to <= self.valid_from:
raise ValueError("valid_to must be after valid_from") raise ValueError("valid_to must be after valid_from")
_validate_purpose_values(self.initial_purposes, "initial_purposes")
return self return self
@@ -101,6 +113,7 @@ class RecordUpdateRequest(StrictModel):
file_plan_node_id: str | None = Field(default=None, min_length=1, max_length=255) file_plan_node_id: str | None = Field(default=None, min_length=1, max_length=255)
state: Literal["planned", "open"] | None = None state: Literal["planned", "open"] | None = None
access_mode: Literal["tenant", "restricted"] | None = None access_mode: Literal["tenant", "restricted"] | None = None
initial_purposes: list[str] = Field(default_factory=list, max_length=100)
purpose: str = Field(min_length=1, max_length=255) purpose: str = Field(min_length=1, max_length=255)
classification: str | None = Field(default=None, max_length=120) classification: str | None = Field(default=None, max_length=120)
responsible_unit_id: str | None = Field(default=None, max_length=255) responsible_unit_id: str | None = Field(default=None, max_length=255)
@@ -116,6 +129,7 @@ class RecordUpdateRequest(StrictModel):
def validate_interval(self): def validate_interval(self):
if self.valid_from and self.valid_to and self.valid_to <= self.valid_from: if self.valid_from and self.valid_to and self.valid_to <= self.valid_from:
raise ValueError("valid_to must be after valid_from") raise ValueError("valid_to must be after valid_from")
_validate_purpose_values(self.initial_purposes, "initial_purposes")
return self return self
@@ -241,6 +255,51 @@ class RecordTransferDispatchRequest(StrictModel):
idempotency_key: str = Field(min_length=1, max_length=255) idempotency_key: str = Field(min_length=1, max_length=255)
class RecordAccessGrantCreateRequest(StrictModel):
grant_id: str | None = Field(default=None, max_length=255)
subject_type: Literal[
"account",
"membership",
"group",
"role",
"function_assignment",
"delegation",
]
subject_id: str = Field(min_length=1, max_length=255)
actions: list[Literal["read", "write", "manage"]] = Field(
min_length=1, max_length=3
)
allowed_purposes: list[str] = Field(min_length=1, max_length=100)
reason: str = Field(min_length=1, max_length=2_000)
purpose: str = Field(min_length=1, max_length=255)
valid_from: datetime | None = None
valid_to: datetime | None = None
recorded_at: datetime
institutional_context: dict[str, Any] = Field(default_factory=dict)
idempotency_key: str = Field(min_length=1, max_length=255)
@model_validator(mode="after")
def validate_grant(self):
if self.valid_from and self.valid_to and self.valid_to <= self.valid_from:
raise ValueError("valid_to must be after valid_from")
if len(self.actions) != len(set(self.actions)):
raise ValueError("actions must be unique")
_validate_purpose_values(self.allowed_purposes, "allowed_purposes")
return self
class RecordAccessGrantRevokeRequest(StrictModel):
expected_revision: int = Field(ge=1)
reason: str = Field(min_length=1, max_length=2_000)
purpose: str = Field(min_length=1, max_length=255)
recorded_at: datetime
idempotency_key: str = Field(min_length=1, max_length=255)
class RecordAccessGrantListResponse(StrictModel):
grants: list[dict[str, Any]]
class RecordListResponse(StrictModel): class RecordListResponse(StrictModel):
records: list[dict[str, Any]] records: list[dict[str, Any]]
total: int total: int
@@ -287,6 +346,9 @@ class RecordRecoveryStatusResponse(StrictModel):
__all__ = [ __all__ = [
"FilePlanNodeWriteRequest", "FilePlanNodeWriteRequest",
"RecordAppraisalRequest", "RecordAppraisalRequest",
"RecordAccessGrantCreateRequest",
"RecordAccessGrantListResponse",
"RecordAccessGrantRevokeRequest",
"RecordArchiveProviderResponse", "RecordArchiveProviderResponse",
"RecordCatalogResponse", "RecordCatalogResponse",
"RecordClassWriteRequest", "RecordClassWriteRequest",
+11 -10
View File
@@ -16,6 +16,7 @@ from govoplan_core.core.search import (
SearchResourceType, SearchResourceType,
) )
from govoplan_records.backend.db.models import RecordIdentity, RecordRevision from govoplan_records.backend.db.models import RecordIdentity, RecordRevision
from govoplan_records.backend.service import _eligible_record_ids
PROVIDER_ID = "records.objects" PROVIDER_ID = "records.objects"
@@ -97,15 +98,13 @@ class RecordsSearchSource:
] ]
resource_ids = {request.reference.resource_id for request in eligible} resource_ids = {request.reference.resource_id for request in eligible}
available_ids = ( available_ids = (
set( _eligible_record_ids(
db.scalars( db,
select(RecordRevision.record_id).where( principal,
RecordRevision.tenant_id == principal.tenant_id, tenant_id=principal.tenant_id,
RecordRevision.record_id.in_(resource_ids), action="read",
RecordRevision.superseded_at.is_(None), purpose="records.search",
) ).intersection(resource_ids)
).all()
)
if resource_ids if resource_ids
else set() else set()
) )
@@ -127,7 +126,9 @@ def _document(row: RecordRevision, identity: RecordIdentity) -> SearchDocument:
resource_type=RESOURCE_TYPE, resource_type=RESOURCE_TYPE,
resource_id=row.record_id, resource_id=row.record_id,
title=row.title, title=row.title,
url=f"/records?recordId={quote(row.record_id, safe='')}", url=(
f"/records?recordId={quote(row.record_id, safe='')}&purpose=records.search"
),
summary=(row.description or identity.record_number)[:4000], summary=(row.description or identity.record_number)[:4000],
body=row.search_text[:200_000], body=row.search_text[:200_000],
keywords=tuple( keywords=tuple(
File diff suppressed because it is too large Load Diff
+179 -4
View File
@@ -1,18 +1,193 @@
{ {
"id": "assisted-service-to-decision-record", "id": "resident-parking-permit-assisted-record",
"description": "A completed assisted service is filed, closed, held, appraised, independently approved, and exercised through the archive simulation boundary.", "description": "A completed assisted resident parking permit service is filed as an eAkte, closed, held, appraised, independently approved, restored from backup, and exercised through the archive simulation boundary.",
"record": { "record": {
"record_id": "record-1", "record_id": "record-1",
"record_number": "2026/0001", "record_number": "2026/0001",
"class_id": "class-permit" "class_id": "class-permit",
"title": "Permit application Ada Example"
}, },
"institutional_context": {
"service_id": "resident-parking-permit",
"case_id": "case-parking-2026-0001",
"applicant_party_id": "party-ada-example",
"representative_party_id": "party-alex-example",
"representation_id": "representation-2026-0001",
"authority_id": "authority-mobility-berlin",
"responsible_function_id": "function-parking-permits",
"mandate_id": "mandate-parking-permits-2026",
"purpose": "decide the resident parking permit application",
"legal_basis": "StVG section 6 and StVO section 45",
"retention_policy_ref": "records-policy:parking-permit:v1"
},
"digital_equivalent": {
"record": {
"record_id": "record-digital",
"record_number": "2026/0002",
"class_id": "class-permit",
"title": "Permit application Dana Digital"
},
"institutional_context_overrides": {
"case_id": "case-parking-2026-0002",
"applicant_party_id": "party-dana-digital",
"representative_party_id": "party-dana-digital",
"representation_id": "self-representation-2026-0002"
},
"intake_source": {
"id": "digital-submission",
"source_module": "forms_runtime",
"resource_type": "form_submission_revision",
"resource_id": "submission-parking-2026-0002",
"source_revision": "revision-2-submitted",
"label": "Digital resident parking permit submission",
"authority_mode": "native_authoritative",
"content_type": "application/json",
"size_bytes": 3968,
"relationship": "initiates",
"purpose": "document the submitted application",
"filing_reason": "The authenticated digital submission initiated the administrative procedure.",
"launch_url": "/forms-runtime/submissions/submission-parking-2026-0002",
"metadata": {
"evidence_role": "application",
"channel": "digital",
"authenticated": true,
"form_definition_revision": "resident-parking-permit:v4"
}
}
},
"sources": [
{
"id": "assisted-submission",
"source_module": "forms_runtime",
"resource_type": "form_submission_revision",
"resource_id": "submission-parking-2026-0001",
"source_revision": "revision-3-submitted",
"label": "Assisted resident parking permit submission",
"authority_mode": "native_authoritative",
"content_type": "application/json",
"size_bytes": 4096,
"relationship": "initiates",
"purpose": "document the submitted application",
"filing_reason": "The assisted, read-back submission initiated the administrative procedure.",
"launch_url": "/forms-runtime/submissions/submission-parking-2026-0001",
"metadata": {
"evidence_role": "application",
"channel": "assisted",
"read_back_confirmed": true,
"form_definition_revision": "resident-parking-permit:v4"
}
},
{
"id": "application-attachment",
"source_module": "files",
"resource_type": "file_version",
"resource_id": "file-registration-certificate",
"source_revision": "version-2",
"label": "Vehicle registration certificate.pdf",
"authority_mode": "external_authoritative",
"content_type": "application/pdf",
"size_bytes": 245760,
"relationship": "supports",
"purpose": "document application eligibility",
"filing_reason": "The submitted certificate supports the applicant's vehicle eligibility.",
"launch_url": "/files?fileId=file-registration-certificate",
"metadata": {
"evidence_role": "application_attachment",
"malware_scan": "clean"
}
},
{
"id": "case-and-representation",
"source_module": "cases",
"resource_type": "case_revision",
"resource_id": "case-parking-2026-0001",
"source_revision": "revision-5-representation-verified",
"label": "Resident parking permit case with verified representation",
"authority_mode": "native_authoritative",
"content_type": "application/json",
"size_bytes": 8192,
"relationship": "governs",
"purpose": "document case ownership and representation",
"filing_reason": "The case revision identifies the applicant, representative, authority, and verified representation used for the decision.",
"launch_url": "/cases/case-parking-2026-0001",
"metadata": {
"evidence_role": "case_context",
"representation_id": "representation-2026-0001",
"representation_status": "verified"
}
},
{
"id": "formal-decision",
"source_module": "decisions",
"resource_type": "decision_revision",
"resource_id": "decision-parking-2026-0001",
"source_revision": "revision-2-issued",
"label": "Resident parking permit decision",
"authority_mode": "native_authoritative",
"content_type": "application/json",
"size_bytes": 6144,
"relationship": "decides",
"purpose": "document the formal administrative outcome",
"filing_reason": "The issued decision records the competent authority, legal basis, outcome, and reasons.",
"launch_url": "/decisions/decision-parking-2026-0001",
"metadata": {
"evidence_role": "formal_decision",
"outcome": "approved",
"legal_basis": "StVG section 6 and StVO section 45"
}
},
{
"id": "delivery-receipt",
"source_module": "files",
"resource_type": "file_version",
"resource_id": "file-postbox-delivery-receipt",
"source_revision": "version-1",
"label": "Trusted postbox delivery receipt.json",
"authority_mode": "external_authoritative",
"content_type": "application/json",
"size_bytes": 2048,
"relationship": "proves_delivery",
"purpose": "document delivery of the formal decision",
"filing_reason": "The immutable receipt proves delivery of the issued decision to the represented applicant's trusted postbox.",
"launch_url": "/files?fileId=file-postbox-delivery-receipt",
"metadata": {
"evidence_role": "delivery_receipt",
"decision_revision": "revision-2-issued",
"delivery_channel": "trusted_postbox",
"delivery_status": "delivered"
}
},
{
"id": "correction-evidence",
"source_module": "files",
"resource_type": "file_version",
"resource_id": "file-decision-correction-evidence",
"source_revision": "version-1",
"label": "Decision address correction evidence.json",
"authority_mode": "external_authoritative",
"content_type": "application/json",
"size_bytes": 2304,
"relationship": "corrects",
"purpose": "preserve correction chronology without replacing prior evidence",
"filing_reason": "The correction evidence links the corrected delivery address to the original decision and receipt while preserving both.",
"launch_url": "/files?fileId=file-decision-correction-evidence",
"metadata": {
"evidence_role": "correction",
"corrects_source_id": "delivery-receipt",
"previous_evidence_retained": true
}
}
],
"expected": { "expected": {
"equivalent_record_count": 2,
"filed_item_count": 6,
"closed_state": "retention_running", "closed_state": "retention_running",
"appraised_state": "appraised", "appraised_state": "appraised",
"approved_state": "transfer_pending", "approved_state": "transfer_pending",
"hold_blocks_disposition": true, "hold_blocks_disposition": true,
"disposition_status": "approved", "disposition_status": "approved",
"transfer_status": "simulated_accepted", "transfer_status": "simulated_accepted",
"custody_transferred": false "custody_transferred": false,
"restored_search_query": "Ada Example"
} }
} }
+14
View File
@@ -0,0 +1,14 @@
from __future__ import annotations
from govoplan_records.backend.manifest import manifest
def test_records_german_workflow_and_reference_are_complete() -> None:
topics = {topic.id: topic for topic in manifest.documentation}
assert len(topics) == 5
for topic in topics.values():
assert all(topic.translations["de"].get(key) for key in ("title", "summary", "body"))
workflow = topics["records.workspace"]
assert workflow.metadata["kind"] == "workflow"
assert workflow.conditions
assert topics["records.filing"].metadata["kind"] == "reference"
+691
View File
@@ -0,0 +1,691 @@
from __future__ import annotations
import json
import unittest
from datetime import UTC, datetime, timedelta
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarProvider,
DsarRecordRef,
DsarSubjectRef,
)
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
create_data_subject_request,
search_data_subject_request,
)
from govoplan_records.backend.db.models import (
RecordAccessGrantRevision,
RecordChronologyEntry,
RecordClassRevision,
RecordDispositionRevision,
RecordFilePlanRevision,
RecordHoldRevision,
RecordIdentity,
RecordItem,
RecordRevision,
RecordTransferPackageRevision,
RecordVolumeRevision,
)
from govoplan_records.backend.dsar_provider import (
RECORDS_DSAR_CAPABILITY,
RecordsDsarProvider,
)
from govoplan_records.backend.manifest import manifest
NOW = datetime(2026, 8, 21, 15, 0, tzinfo=UTC)
class _Registry:
def __init__(self, provider: RecordsDsarProvider, *, active: bool = True) -> None:
self.provider = provider
self.active = active
def capability_names(self):
return (RECORDS_DSAR_CAPABILITY,)
def capability_owner(self, name):
self._assert_capability(name)
return "records"
def tenant_entitlement_resolver(self):
active = self.active
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type(
"State",
(),
{"effective_modules": ("records",) if active else ()},
)()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
self._assert_capability(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "records"})(),)
@staticmethod
def _assert_capability(name: str) -> None:
if name != RECORDS_DSAR_CAPABILITY:
raise KeyError(name)
class RecordsDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine)
self.provider = RecordsDsarProvider()
self.assertIsInstance(self.provider, DsarProvider)
self._seed_record_package()
self._seed_unrelated_records()
self._seed_operator_configuration()
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _seed_record_package(self) -> None:
identity = RecordIdentity(
id="identity-row-1",
tenant_id="tenant-1",
record_id="record-1",
record_number="2026/0001",
created_by="clerk-1",
)
self.session.add(identity)
self.session.flush()
self.session.add_all(
(
RecordRevision(
id="revision-row-1",
tenant_id="tenant-1",
record_id="record-1",
identity_id=identity.id,
revision=1,
class_id="class-permit",
file_plan_node_id="plan-permits",
title="Resident parking permit record",
description="Application by Ada Example",
state="retention_running",
source_authority_mode="native_authoritative",
access_mode="tenant",
purpose="decide resident parking permit",
classification="personal",
responsible_unit_id="unit-1",
responsible_function_id="function-permits",
external_reference={
"case_id": "case-1",
"account_id": "applicant-1",
"secret": "private-external-reference-do-not-export",
},
institutional_context={
"membership_id": "membership-applicant-1",
"secret": "private-context-do-not-export",
},
search_text="private-search-text-do-not-export",
valid_from=NOW,
recorded_at=NOW,
superseded_at=NOW + timedelta(minutes=1),
changed_by="clerk-1",
snapshot={"secret": "private-snapshot-do-not-export"},
),
RecordRevision(
id="revision-row-2",
tenant_id="tenant-1",
record_id="record-1",
identity_id=identity.id,
revision=2,
previous_revision_id="revision-row-1",
class_id="class-permit",
file_plan_node_id="plan-permits",
title="Resident parking permit record",
description="Application by Ada Example",
state="retention_running",
source_authority_mode="native_authoritative",
access_mode="tenant",
purpose="decide resident parking permit",
classification="personal",
responsible_unit_id="unit-1",
responsible_function_id="function-permits",
external_reference={
"case_id": "case-1",
"account_id": "applicant-1",
"secret": "private-external-reference-do-not-export",
},
institutional_context={
"membership_id": "membership-applicant-1",
"secret": "private-context-do-not-export",
},
search_text="private-search-text-do-not-export",
valid_from=NOW,
recorded_at=NOW + timedelta(minutes=1),
changed_by="clerk-1",
closed_at=NOW + timedelta(minutes=1),
retention_started_at=NOW + timedelta(minutes=1),
retention_due_at=NOW + timedelta(days=3650),
retention_rule={"secret": "private-rule-do-not-export"},
appraisal_state="appraised",
appraisal={"secret": "private-appraisal-do-not-export"},
snapshot={"secret": "private-snapshot-do-not-export"},
),
)
)
self.session.add(
RecordVolumeRevision(
id="volume-row-1",
tenant_id="tenant-1",
volume_id="volume-1",
record_id="record-1",
revision=1,
sequence=1,
label="Application",
state="closed",
valid_from=NOW,
recorded_at=NOW,
changed_by="clerk-1",
)
)
self.session.add(
RecordItem(
id="item-row-1",
tenant_id="tenant-1",
record_id="record-1",
volume_id="volume-1",
sequence=1,
source_module="cases",
resource_type="case_revision",
resource_id="case-1",
source_revision="revision-2",
label="Permit case",
relationship="decision_basis",
filing_reason="The application belongs to this record.",
purpose="document the permit decision",
authority_mode="native_authoritative",
content_sha256="a" * 64,
content_type="application/json",
size_bytes=2048,
source_valid_from=NOW,
source_recorded_at=NOW,
launch_url="https://secret.example/do-not-export",
filed_at=NOW + timedelta(minutes=1),
filed_by="clerk-1",
actor_assignment_id="private-assignment-do-not-export",
actor_delegation_id="private-delegation-do-not-export",
institutional_context={"secret": "private-item-context-do-not-export"},
source_metadata={"secret": "private-source-data-do-not-export"},
filing_metadata={"secret": "private-filing-data-do-not-export"},
idempotency_key="private-item-key-do-not-export",
request_sha256="b" * 64,
)
)
self.session.add(
RecordChronologyEntry(
id="chronology-row-1",
tenant_id="tenant-1",
record_id="record-1",
event_id="event-1",
event_type="record.item_filed",
record_revision=2,
summary="private-event-summary-do-not-export",
occurred_at=NOW + timedelta(minutes=1),
actor_id="clerk-1",
actor_assignment_id="private-assignment-do-not-export",
actor_delegation_id="private-delegation-do-not-export",
purpose="document the permit decision",
idempotency_key="private-event-key-do-not-export",
request_sha256="c" * 64,
institutional_context={"secret": "private-event-context-do-not-export"},
payload={"secret": "private-event-payload-do-not-export"},
)
)
self.session.add(
RecordHoldRevision(
id="hold-row-1",
tenant_id="tenant-1",
hold_id="hold-1",
record_id="record-1",
revision=1,
status="released",
reason="private-hold-reason-do-not-export",
authority="private-hold-authority-do-not-export",
scope={"secret": "private-hold-scope-do-not-export"},
effective_from=NOW + timedelta(minutes=2),
released_at=NOW + timedelta(minutes=3),
policy_refs=["private-policy-do-not-export"],
institutional_context={"secret": "private-hold-context-do-not-export"},
recorded_at=NOW + timedelta(minutes=2),
changed_by="clerk-1",
idempotency_key="private-hold-key-do-not-export",
request_sha256="d" * 64,
)
)
self.session.add(
RecordDispositionRevision(
id="disposition-row-1",
tenant_id="tenant-1",
disposition_id="disposition-1",
record_id="record-1",
revision=1,
action="transfer",
status="approved",
reason="private-disposition-reason-do-not-export",
subject_revision=2,
subject_sha256="e" * 64,
consequence_preview={"secret": "private-preview-do-not-export"},
policy_refs=["private-policy-do-not-export"],
approval_request_id="private-approval-do-not-export",
proposed_by="clerk-1",
reviewed_by="reviewer-1",
reviewed_at=NOW + timedelta(minutes=4),
institutional_context={
"secret": "private-disposition-context-do-not-export"
},
recorded_at=NOW + timedelta(minutes=3),
idempotency_key="private-disposition-key-do-not-export",
request_sha256="f" * 64,
)
)
self.session.add(
RecordTransferPackageRevision(
id="transfer-row-1",
tenant_id="tenant-1",
package_id="package-1",
record_id="record-1",
disposition_id="disposition-1",
revision=1,
record_revision=2,
provider_id="archive-1",
profile="xarchive",
status="accepted",
authority_mode="external_authoritative",
manifest={"secret": "private-manifest-do-not-export"},
manifest_sha256="1" * 64,
receipt={"secret": "private-receipt-do-not-export"},
receipt_sha256="2" * 64,
external_reference="private-archive-reference-do-not-export",
recovery_operation_id="private-recovery-id-do-not-export",
simulated=False,
institutional_context={
"secret": "private-transfer-context-do-not-export"
},
recorded_at=NOW + timedelta(minutes=5),
changed_by="clerk-1",
idempotency_key="private-transfer-key-do-not-export",
request_sha256="3" * 64,
)
)
def _seed_unrelated_records(self) -> None:
for suffix, tenant_id in (("2", "tenant-1"), ("3", "tenant-2")):
identity = RecordIdentity(
id=f"identity-row-{suffix}",
tenant_id=tenant_id,
record_id=f"record-{suffix}",
record_number=f"2026/000{suffix}",
created_by="other-clerk",
)
self.session.add(identity)
self.session.flush()
self.session.add(
RecordRevision(
id=f"unrelated-revision-row-{suffix}",
tenant_id=tenant_id,
record_id=f"record-{suffix}",
identity_id=identity.id,
revision=1,
class_id="class-permit",
file_plan_node_id="plan-permits",
title=f"Unrelated record {suffix}",
description="unrelated-private-description",
state="open",
source_authority_mode="native_authoritative",
access_mode="tenant",
purpose="unrelated purpose",
external_reference={},
institutional_context={},
search_text="unrelated-private-search",
recorded_at=NOW,
changed_by="other-clerk",
snapshot={"secret": "unrelated-private-snapshot"},
)
)
def _seed_operator_configuration(self) -> None:
self.session.add(
RecordFilePlanRevision(
id="plan-row-1",
tenant_id="tenant-1",
node_id="plan-permits",
revision=1,
code="10.20",
label="Permits",
active=True,
recorded_at=NOW,
institutional_context={"secret": "private-plan-context-do-not-export"},
changed_by="clerk-1",
idempotency_key="private-plan-key-do-not-export",
request_sha256="4" * 64,
)
)
self.session.add(
RecordClassRevision(
id="class-row-1",
tenant_id="tenant-1",
class_id="class-permit",
revision=1,
file_plan_node_id="plan-permits",
key="permit.application",
label="Permit application",
metadata_requirements=["private-metadata-rule-do-not-export"],
allowed_source_types=["cases:case_revision"],
retention_period_days=3650,
access_mode="tenant",
active=True,
recorded_at=NOW,
institutional_context={"secret": "private-class-context-do-not-export"},
changed_by="clerk-1",
idempotency_key="private-class-key-do-not-export",
request_sha256="5" * 64,
)
)
def test_direct_record_exports_minimized_complete_lifecycle(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="applicant-1",
external_references={"records.record": "record-1"},
),
)
resource_types = {record.resource_type for record in records}
self.assertTrue(
{
"record_identity",
"record_revision",
"record_current_fact",
"record_volume_revision",
"record_item",
"record_chronology_entry",
"record_hold_revision",
"record_disposition_revision",
"record_transfer_package_revision",
}.issubset(resource_types)
)
self.assertNotIn(
"record-2", json.dumps([record.to_dict() for record in records])
)
exported = json.dumps([record.to_dict() for record in records])
for secret in (
"private-snapshot-do-not-export",
"private-search-text-do-not-export",
"private-context-do-not-export",
"private-event-summary-do-not-export",
"private-event-payload-do-not-export",
"private-hold-reason-do-not-export",
"private-preview-do-not-export",
"private-manifest-do-not-export",
"private-receipt-do-not-export",
"private-archive-reference-do-not-export",
"private-item-key-do-not-export",
):
self.assertNotIn(secret, exported)
def test_authoritative_source_reference_correlates_the_record(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="applicant-1",
external_references={"cases.case": "case-1"},
),
)
item = next(
record for record in records if record.resource_type == "record_item"
)
self.assertTrue(item.data["source_reference_matches"])
self.assertEqual("record-1", item.data["record_id"])
def test_operator_search_returns_attribution_without_record_contents(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(account_id="clerk-1"),
)
self.assertTrue(records)
self.assertEqual(
{"records_operator_attribution"},
{record.resource_type for record in records},
)
exported = json.dumps([record.to_dict() for record in records])
self.assertNotIn("Application by Ada Example", exported)
self.assertNotIn("private-", exported)
self.assertIn("changed_file_plan", exported)
def test_account_search_exports_only_the_subjects_restricted_grant(self) -> None:
self.session.add(
RecordAccessGrantRevision(
id="grant-row-subject-1",
tenant_id="tenant-1",
grant_id="grant-subject-1",
record_id="record-1",
revision=1,
status="active",
subject_type="account",
subject_id="account-grantee-1",
actions=["read"],
allowed_purposes=["case-work"],
reason="Sensitive internal assignment reason",
valid_from=NOW,
recorded_at=NOW,
changed_by="records-admin-1",
institutional_context={"case_id": "case-1"},
idempotency_key="grant-subject-create-1",
request_sha256="a" * 64,
)
)
self.session.commit()
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(account_id="account-grantee-1"),
)
grants = [
record
for record in records
if record.resource_type == "record_access_grant_revision"
]
self.assertEqual(1, len(grants))
self.assertEqual(["case-work"], grants[0].data["allowed_purposes"])
self.assertNotIn("reason", grants[0].data)
self.assertNotIn("account-grantee-1", json.dumps(grants[0].data))
def test_direct_selectors_fail_closed_on_conflict_or_wrong_tenant(self) -> None:
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
external_references={
"records.record": "record-2",
"records.item": "item-row-1",
}
),
)
alias_conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
external_references={
"records.record": "record-1",
"records.record_id": "record-2",
}
),
)
wrong_tenant = self.provider.search_subject(
self.session,
tenant_id="tenant-2",
subject=DsarSubjectRef(external_references={"records.record": "record-1"}),
)
mismatched_actor = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="someone-else",
external_references={"records.record": "record-1"},
),
)
self.assertEqual((), conflict)
self.assertEqual((), alias_conflict)
self.assertEqual((), wrong_tenant)
self.assertEqual((), mismatched_actor)
def test_plan_retains_evidence_and_routes_current_fact_to_review(self) -> None:
subject = DsarSubjectRef(external_references={"records.record": "record-1"})
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=subject,
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=records,
)
self.assertEqual(1, sum(action.kind == "manual_review" for action in actions))
self.assertGreater(sum(action.kind == "retain" for action in actions), 7)
self.assertTrue(all(not action.executable for action in actions))
results = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=actions,
request_id="dsar-1",
)
self.assertTrue(all(result.status == "blocked" for result in results))
def test_foreign_records_and_actions_are_rejected(self) -> None:
subject = DsarSubjectRef(external_references={"records.record": "record-1"})
with self.assertRaisesRegex(ValueError, "foreign provider record"):
self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=(
DsarRecordRef(
provider_id="cases",
module_id="cases",
resource_type="case",
resource_id="case-1",
category="case",
title="Case",
),
),
)
with self.assertRaisesRegex(ValueError, "foreign provider action"):
self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=(
DsarErasureActionRef(
action_id="cases:retain:case:case-1",
provider_id="cases",
module_id="cases",
kind="retain",
resource_type="case",
resource_id="case-1",
title="Retain case",
rationale="Evidence",
executable=False,
),
),
request_id="dsar-1",
)
def test_core_workflow_reports_active_and_inactive_provider(self) -> None:
row = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-RECORDS-1",
request_kind="access",
subject=DsarSubjectRef(external_references={"records.record": "record-1"}),
purpose="Respond to a verified request.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=row,
expected_revision=1,
)
self.assertEqual(
[RECORDS_DSAR_CAPABILITY], row.coverage["provider_capabilities"]
)
self.assertGreater(row.search_result["record_count"], 8)
inactive = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-RECORDS-2",
request_kind="access",
subject=DsarSubjectRef(external_references={"records.record": "record-1"}),
purpose="Respond to a verified request.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider, active=False),
row=inactive,
expected_revision=1,
)
self.assertEqual([], inactive.coverage["provider_capabilities"])
self.assertEqual(
[RECORDS_DSAR_CAPABILITY],
inactive.coverage["inactive_provider_capabilities"],
)
self.assertEqual(0, inactive.search_result["record_count"])
def test_manifest_registers_and_documents_the_capability(self) -> None:
self.assertIn(RECORDS_DSAR_CAPABILITY, manifest.capability_factories)
self.assertIn(RECORDS_DSAR_CAPABILITY, manifest.capability_documentation)
self.assertIn(
RECORDS_DSAR_CAPABILITY,
{item.name for item in manifest.provides_interfaces},
)
self.assertTrue(
any(
topic.id == "records.data-subject-requests"
and {"admin", "user"}.issubset(topic.documentation_types)
for topic in manifest.documentation
)
)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -38,7 +38,7 @@ class ManifestTests(unittest.TestCase):
"enforced", manifest.information_governance.temporal_browsing.adoption "enforced", manifest.information_governance.temporal_browsing.adoption
) )
self.assertEqual( self.assertEqual(
"partial", manifest.information_governance.purpose_aware_access.adoption "enforced", manifest.information_governance.purpose_aware_access.adoption
) )
+2 -1
View File
@@ -28,6 +28,7 @@ class RecordsMigrationTests(unittest.TestCase):
self.assertTrue( self.assertTrue(
{ {
"record_chronology_entries", "record_chronology_entries",
"record_access_grant_revisions",
"record_class_revisions", "record_class_revisions",
"record_file_plan_revisions", "record_file_plan_revisions",
"record_hold_revisions", "record_hold_revisions",
@@ -45,7 +46,7 @@ class RecordsMigrationTests(unittest.TestCase):
) )
with engine.connect() as connection: with engine.connect() as connection:
self.assertIn( self.assertIn(
"7f5b3d9a2c1e", "8a6c4e2f1b3d",
set(MigrationContext.configure(connection).get_current_heads()), set(MigrationContext.configure(connection).get_current_heads()),
) )
finally: finally:
+647 -3
View File
@@ -2,8 +2,11 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
import hashlib
import json import json
from pathlib import Path from pathlib import Path
import sqlite3
import tempfile
import unittest import unittest
from sqlalchemy import create_engine from sqlalchemy import create_engine
@@ -24,6 +27,7 @@ from govoplan_core.core.temporal import (
reset_temporal_data_context, reset_temporal_data_context,
) )
from govoplan_records.backend.db.models import ( from govoplan_records.backend.db.models import (
RecordAccessGrantRevision,
RecordChronologyEntry, RecordChronologyEntry,
RecordClassRevision, RecordClassRevision,
RecordDispositionRevision, RecordDispositionRevision,
@@ -36,7 +40,11 @@ from govoplan_records.backend.db.models import (
RecordVolumeRevision, RecordVolumeRevision,
) )
from govoplan_records.backend.archive import SimulatedRecordArchiveProvider from govoplan_records.backend.archive import SimulatedRecordArchiveProvider
from govoplan_records.backend.service import RecordConflictError, SqlRecordRegistry from govoplan_records.backend.service import (
RecordConflictError,
RecordNotFoundError,
SqlRecordRegistry,
)
NOW = datetime(2026, 1, 6, 9, 0, tzinfo=UTC) NOW = datetime(2026, 1, 6, 9, 0, tzinfo=UTC)
@@ -48,6 +56,11 @@ class Principal:
account_id: str = "account-1" account_id: str = "account-1"
acting_assignment_id: str = "assignment-1" acting_assignment_id: str = "assignment-1"
acting_delegation_id: str | None = None acting_delegation_id: str | None = None
membership_id: str = "membership-1"
group_ids: tuple[str, ...] = ()
role_ids: tuple[str, ...] = ()
function_assignment_ids: tuple[str, ...] = ()
delegation_ids: tuple[str, ...] = ()
class SourceProvider: class SourceProvider:
@@ -73,6 +86,46 @@ class SourceProvider:
) )
class ReferenceJourneySourceProvider:
def __init__(self, provider_id: str, resource_types: tuple[str, ...]) -> None:
self.provider_id = provider_id
self._resource_types = resource_types
def resource_types(self):
return self._resource_types
def resolve(self, session, principal, *, locator, purpose):
del session, purpose
if principal.tenant_id != locator.tenant_id:
raise ValueError("Source access denied.")
if locator.source_module != self.provider_id:
raise ValueError("The source module does not match this provider.")
if locator.resource_type not in self._resource_types:
raise ValueError("The source type is not supported by this provider.")
metadata = dict(locator.metadata)
reference = dict(metadata.get("reference") or {})
digest_input = ":".join(
(
locator.source_module,
locator.resource_type,
locator.resource_id,
locator.source_revision,
)
).encode("utf-8")
return RecordSourceReference(
locator=locator,
label=str(reference.get("label") or locator.resource_id),
authority_mode=str(reference.get("authority_mode") or "linked_reference"),
content_sha256=hashlib.sha256(digest_input).hexdigest(),
content_type=str(reference.get("content_type") or "application/json"),
size_bytes=int(reference.get("size_bytes") or 0),
valid_from=NOW,
recorded_at=NOW,
launch_url=str(reference.get("launch_url") or "") or None,
metadata=metadata,
)
class UnknownOutcomeArchiveProvider: class UnknownOutcomeArchiveProvider:
provider_id = "unknown_simulation" provider_id = "unknown_simulation"
@@ -131,6 +184,35 @@ class Registry:
}.get(name) }.get(name)
class ReferenceJourneyRegistry(Registry):
def __init__(self) -> None:
super().__init__()
self.source_providers_by_module = {
"files": ReferenceJourneySourceProvider("files", ("file_version",)),
"forms_runtime": ReferenceJourneySourceProvider(
"forms_runtime", ("form_submission_revision",)
),
"cases": ReferenceJourneySourceProvider("cases", ("case_revision",)),
"decisions": ReferenceJourneySourceProvider(
"decisions", ("decision_revision",)
),
}
def capability_names(self):
return (
*(f"records.source.{name}" for name in self.source_providers_by_module),
"records.archive.simulation",
"records.archive.unknown_simulation",
)
def tenant_capability(self, name, session, *, tenant_id):
if name.startswith("records.source.") and tenant_id == "tenant-1":
return self.source_providers_by_module.get(
name.removeprefix("records.source.")
)
return super().tenant_capability(name, session, tenant_id=tenant_id)
class ApprovalProvider: class ApprovalProvider:
def __init__(self) -> None: def __init__(self) -> None:
self.approved = False self.approved = False
@@ -190,6 +272,7 @@ class RecordsTests(unittest.TestCase):
RecordClassRevision.__table__, RecordClassRevision.__table__,
RecordIdentity.__table__, RecordIdentity.__table__,
RecordRevision.__table__, RecordRevision.__table__,
RecordAccessGrantRevision.__table__,
RecordVolumeRevision.__table__, RecordVolumeRevision.__table__,
RecordItem.__table__, RecordItem.__table__,
RecordChronologyEntry.__table__, RecordChronologyEntry.__table__,
@@ -231,7 +314,12 @@ class RecordsTests(unittest.TestCase):
"file_plan_node_id": "plan-permits", "file_plan_node_id": "plan-permits",
"key": "permit.application", "key": "permit.application",
"label": "Permit application", "label": "Permit application",
"allowed_source_types": ["files:file_version"], "allowed_source_types": [
"files:file_version",
"forms_runtime:form_submission_revision",
"cases:case_revision",
"decisions:decision_revision",
],
"retention_period_days": 3650, "retention_period_days": 3650,
"access_mode": "tenant", "access_mode": "tenant",
"recorded_at": NOW, "recorded_at": NOW,
@@ -325,6 +413,129 @@ class RecordsTests(unittest.TestCase):
}, },
) )
def test_restricted_records_require_current_purpose_bound_grants(self) -> None:
record = self.records.create_record(
self.session,
self.principal,
payload={
"record_id": "restricted-1",
"record_number": "2026/R-0001",
"class_id": "class-permit",
"file_plan_node_id": "plan-permits",
"title": "Protected permit record",
"state": "open",
"source_authority_mode": "native_authoritative",
"access_mode": "restricted",
"initial_purposes": [
"case-work",
"grant-admin",
"records.search",
],
"purpose": "case-work",
"recorded_at": NOW + timedelta(minutes=1),
"valid_from": NOW,
"change_reason": "Protected record created.",
"idempotency_key": "restricted-create-1",
},
)
self.session.commit()
self.assertEqual("restricted", record["access_mode"])
self.assertEqual(
"restricted-1",
self.records.get_record(
self.session,
self.principal,
record_id="restricted-1",
purpose="case-work",
)["record"]["record_id"],
)
with self.assertRaises(RecordNotFoundError):
self.records.get_record(
self.session,
self.principal,
record_id="restricted-1",
purpose="unrelated-purpose",
)
initial_grant = self.records.list_access_grants(
self.session,
self.principal,
record_id="restricted-1",
purpose="grant-admin",
)[0]
with self.assertRaises(RecordConflictError):
self.records.revoke_access_grant(
self.session,
self.principal,
record_id="restricted-1",
grant_id=str(initial_grant["grant_id"]),
payload={
"expected_revision": 1,
"reason": "Would orphan restricted custody.",
"purpose": "grant-admin",
"recorded_at": NOW + timedelta(minutes=2),
"idempotency_key": "restricted-revoke-last-manager",
},
)
colleague = Principal(account_id="account-2", membership_id="membership-2")
self.assertEqual(
([], 0),
self.records.list_records(self.session, colleague, purpose="case-work"),
)
grant = self.records.create_access_grant(
self.session,
self.principal,
record_id="restricted-1",
payload={
"subject_type": "account",
"subject_id": "account-2",
"actions": ["read", "write"],
"allowed_purposes": ["case-work", "records.search"],
"reason": "Assigned case work.",
"purpose": "grant-admin",
"recorded_at": NOW + timedelta(minutes=2),
"idempotency_key": "restricted-grant-2",
},
)
self.session.commit()
records, total = self.records.list_records(
self.session, colleague, purpose="case-work"
)
self.assertEqual(1, total)
self.assertEqual("restricted-1", records[0]["record_id"])
detail = self.records.get_record(
self.session,
colleague,
record_id="restricted-1",
purpose="case-work",
)
self.assertEqual(grant["grant_id"], detail["access_explanation"]["grant_id"])
revoked = self.records.revoke_access_grant(
self.session,
self.principal,
record_id="restricted-1",
grant_id=str(grant["grant_id"]),
payload={
"expected_revision": 1,
"reason": "Assignment completed.",
"purpose": "grant-admin",
"recorded_at": NOW + timedelta(minutes=3),
"idempotency_key": "restricted-revoke-2",
},
)
self.session.commit()
self.assertEqual("revoked", revoked["status"])
with self.assertRaises(RecordNotFoundError):
self.records.get_record(
self.session,
colleague,
record_id="restricted-1",
revision=1,
purpose="case-work",
)
def test_update_replay_returns_the_original_revision(self) -> None: def test_update_replay_returns_the_original_revision(self) -> None:
self._create_record() self._create_record()
first_payload = { first_payload = {
@@ -515,7 +726,10 @@ class RecordsTests(unittest.TestCase):
).read_text(encoding="utf-8") ).read_text(encoding="utf-8")
) )
expected = fixture["expected"] expected = fixture["expected"]
self._create_record() record = self._create_record()
self.assertEqual(fixture["record"]["record_id"], record["record_id"])
self.assertEqual(fixture["record"]["record_number"], record["record_number"])
self.assertEqual(fixture["record"]["class_id"], record["class_id"])
closed = self.records.close_record( closed = self.records.close_record(
self.session, self.session,
self.principal, self.principal,
@@ -724,6 +938,436 @@ class RecordsTests(unittest.TestCase):
self.assertTrue(recovery["healthy"]) self.assertTrue(recovery["healthy"])
self.assertTrue(recovery["package_checks"][0]["manifest_verified"]) self.assertTrue(recovery["package_checks"][0]["manifest_verified"])
def test_assisted_service_reference_journey_survives_backup_and_restore(
self,
) -> None:
fixture = json.loads(
(
Path(__file__).parent / "fixtures/service_to_decision_journey.json"
).read_text(encoding="utf-8")
)
expected = fixture["expected"]
context = fixture["institutional_context"]
self.records = SqlRecordRegistry(ReferenceJourneyRegistry())
def create_journey_record(
record_data: dict[str, object],
record_context: dict[str, object],
*,
idempotency_suffix: str,
channel: str,
) -> dict[str, object]:
return self.records.create_record(
self.session,
self.principal,
payload={
**record_data,
"file_plan_node_id": "plan-permits",
"description": fixture["description"],
"state": "open",
"source_authority_mode": "native_authoritative",
"access_mode": "tenant",
"purpose": record_context["purpose"],
"responsible_unit_id": "unit-mobility-services",
"responsible_function_id": record_context[
"responsible_function_id"
],
"institutional_context": {**record_context, "channel": channel},
"recorded_at": NOW + timedelta(minutes=1),
"valid_from": NOW,
"change_reason": f"The {channel} application was accepted for processing.",
"idempotency_key": f"reference-record-create-{idempotency_suffix}",
},
)
def filing_request(
record_id: str,
source: dict[str, object],
record_context: dict[str, object],
*,
idempotency_suffix: str = "",
) -> RecordFilingRequest:
metadata = dict(source["metadata"])
return RecordFilingRequest(
tenant_id="tenant-1",
record_id=record_id,
source=RecordSourceLocator(
tenant_id="tenant-1",
source_module=str(source["source_module"]),
resource_type=str(source["resource_type"]),
resource_id=str(source["resource_id"]),
source_revision=str(source["source_revision"]),
metadata={
"reference": {
"label": source["label"],
"authority_mode": source["authority_mode"],
"content_type": source["content_type"],
"size_bytes": source["size_bytes"],
"launch_url": source["launch_url"],
},
**metadata,
},
),
purpose=str(source["purpose"]),
filing_reason=str(source["filing_reason"]),
relationship=str(source["relationship"]),
institutional_context={
**record_context,
"evidence_role": metadata["evidence_role"],
},
metadata={"fixture_source_id": source["id"]},
idempotency_key=(f"reference-file-{source['id']}{idempotency_suffix}"),
)
record = create_journey_record(
fixture["record"],
context,
idempotency_suffix="assisted",
channel="assisted",
)
self.assertEqual(fixture["record"]["title"], record["title"])
filed_results = []
for source in fixture["sources"]:
filed_results.append(
self.records.file(
self.session,
self.principal,
request=filing_request(str(record["record_id"]), source, context),
)
)
replay = self.records.file(
self.session,
self.principal,
request=filing_request(
str(record["record_id"]), fixture["sources"][0], context
),
)
self.assertTrue(replay.replayed)
self.assertEqual(filed_results[0].item_id, replay.item_id)
detail = self.records.get_record(
self.session, self.principal, record_id=str(record["record_id"])
)
self.assertEqual(expected["filed_item_count"], len(detail["items"]))
self.assertEqual(
[source["source_revision"] for source in fixture["sources"]],
[item["source"]["source_revision"] for item in detail["items"]],
)
self.assertEqual(
[source["relationship"] for source in fixture["sources"]],
[item["relationship"] for item in detail["items"]],
)
self.assertEqual(
{
"application",
"application_attachment",
"case_context",
"formal_decision",
"delivery_receipt",
"correction",
},
{item["source_metadata"]["evidence_role"] for item in detail["items"]},
)
digital_fixture = fixture["digital_equivalent"]
digital_context = {
**context,
**digital_fixture["institutional_context_overrides"],
}
digital_record = create_journey_record(
digital_fixture["record"],
digital_context,
idempotency_suffix="digital",
channel="digital",
)
digital_sources = [digital_fixture["intake_source"]]
for source in fixture["sources"][1:]:
digital_sources.append(
{
**source,
"id": f"digital-{source['id']}",
"resource_id": f"{source['resource_id']}-digital",
"label": f"Digital equivalent · {source['label']}",
}
)
for source in digital_sources:
self.records.file(
self.session,
self.principal,
request=filing_request(
str(digital_record["record_id"]),
source,
digital_context,
idempotency_suffix="-digital",
),
)
digital_detail = self.records.get_record(
self.session,
self.principal,
record_id=str(digital_record["record_id"]),
)
def equivalence_fields(item):
return (
item["source"]["source_module"],
item["source"]["resource_type"],
item["relationship"],
item["source_metadata"]["evidence_role"],
)
self.assertEqual(
[equivalence_fields(item) for item in detail["items"]],
[equivalence_fields(item) for item in digital_detail["items"]],
)
all_records, total_records = self.records.list_records(
self.session, self.principal
)
self.assertEqual(expected["equivalent_record_count"], total_records)
self.assertEqual(
{fixture["record"]["record_id"], digital_fixture["record"]["record_id"]},
{item["record_id"] for item in all_records},
)
lifecycle_start = datetime.now(UTC).replace(microsecond=0) + timedelta(
minutes=10
)
closed = self.records.close_record(
self.session,
self.principal,
record_id=str(record["record_id"]),
payload={
"expected_revision": 1,
"purpose": "close completed resident parking permit record",
"reason": "Decision, delivery, and correction evidence are complete.",
"recorded_at": lifecycle_start,
"idempotency_key": "reference-close",
},
)
self.assertEqual(expected["closed_state"], closed["state"])
appraised = self.records.appraise_record(
self.session,
self.principal,
record_id=str(record["record_id"]),
payload={
"expected_revision": 2,
"outcome": "transfer",
"purpose": "appraise completed resident parking permit record",
"reason": "Offer the complete record after governed review.",
"policy_refs": [context["retention_policy_ref"]],
"override_retention_not_due": True,
"recorded_at": lifecycle_start + timedelta(minutes=1),
"idempotency_key": "reference-appraise",
},
)
self.assertEqual(expected["appraised_state"], appraised["state"])
hold = self.records.apply_hold(
self.session,
self.principal,
record_id=str(record["record_id"]),
payload={
"expected_record_revision": 3,
"reason": "Preserve the record while a correction is reviewed.",
"authority": "Mobility authority review 2026-17",
"purpose": "preserve correction evidence",
"policy_refs": [context["retention_policy_ref"]],
"institutional_context": context,
"recorded_at": lifecycle_start + timedelta(minutes=2),
"idempotency_key": "reference-hold",
},
)
with self.assertRaisesRegex(RecordConflictError, "active record hold"):
self.records.propose_disposition(
self.session,
self.principal,
record_id=str(record["record_id"]),
payload={
"expected_record_revision": 3,
"action": "transfer",
"reason": "Attempt transfer while evidence is held.",
"purpose": "prove hold enforcement",
"recorded_at": lifecycle_start + timedelta(minutes=3),
"idempotency_key": "reference-disposition-blocked",
},
)
self.records.release_hold(
self.session,
self.principal,
record_id=str(record["record_id"]),
hold_id=str(hold["hold_id"]),
payload={
"expected_hold_revision": 1,
"reason": "Correction review completed with prior evidence preserved.",
"purpose": "resume governed disposition",
"recorded_at": lifecycle_start + timedelta(minutes=4),
"idempotency_key": "reference-hold-release",
},
)
disposition = self.records.propose_disposition(
self.session,
self.principal,
record_id=str(record["record_id"]),
payload={
"disposition_id": "reference-disposition",
"expected_record_revision": 3,
"action": "transfer",
"reason": "The complete record is ready for an independently reviewed offer.",
"purpose": "prepare governed archive offer",
"policy_refs": [context["retention_policy_ref"]],
"institutional_context": context,
"recorded_at": lifecycle_start + timedelta(minutes=5),
"idempotency_key": "reference-disposition",
},
)
self.records.registry.approvals.approved = True
finalized = self.records.finalize_disposition(
self.session,
self.principal,
record_id=str(record["record_id"]),
disposition_id=str(disposition["disposition_id"]),
payload={
"expected_disposition_revision": 1,
"purpose": "record independent disposition approval",
"recorded_at": lifecycle_start + timedelta(minutes=6),
"idempotency_key": "reference-disposition-finalize",
},
)
self.assertEqual(expected["approved_state"], finalized["record"]["state"])
package = self.records.prepare_transfer_package(
self.session,
self.principal,
record_id=str(record["record_id"]),
payload={
"package_id": "reference-transfer-package",
"disposition_id": str(disposition["disposition_id"]),
"expected_record_revision": 4,
"provider_id": "simulation",
"profile": "govoplan-simulation-v1",
"purpose": "validate archive transfer boundary",
"recorded_at": lifecycle_start + timedelta(minutes=7),
"idempotency_key": "reference-transfer-prepare",
},
)
receipt = self.records.dispatch_transfer_package(
self.session,
self.principal,
record_id=str(record["record_id"]),
package_id=str(package["package_id"]),
payload={
"expected_package_revision": 1,
"purpose": "validate archive receipt handling",
"recorded_at": lifecycle_start + timedelta(minutes=8),
"idempotency_key": "reference-transfer-dispatch",
},
)
self.assertEqual(expected["transfer_status"], receipt["status"])
self.assertEqual(
expected["custody_transferred"],
receipt["receipt"]["metadata"]["custody_transferred"],
)
self.session.commit()
with tempfile.TemporaryDirectory(
prefix="govoplan-records-restore-"
) as temp_dir:
backup_path = Path(temp_dir) / "restored-records.sqlite3"
source_connection = self.engine.raw_connection()
try:
with sqlite3.connect(backup_path) as backup_connection:
source_connection.driver_connection.backup(backup_connection)
finally:
source_connection.close()
restored_engine = create_engine(f"sqlite+pysqlite:///{backup_path}")
restored_session = Session(restored_engine)
try:
restored_records = SqlRecordRegistry(ReferenceJourneyRegistry())
matches, total = restored_records.list_records(
restored_session,
self.principal,
query=expected["restored_search_query"],
)
self.assertEqual(1, total)
self.assertEqual(record["record_id"], matches[0]["record_id"])
restored = restored_records.get_record(
restored_session,
self.principal,
record_id=str(record["record_id"]),
)
self.assertEqual(expected["filed_item_count"], len(restored["items"]))
self.assertEqual(
{**context, "channel": "assisted"},
restored["record"]["institutional_context"],
)
self.assertEqual("released", restored["holds"][0]["status"])
self.assertEqual(
expected["disposition_status"],
restored["dispositions"][0]["status"],
)
self.assertEqual(
[context["retention_policy_ref"]],
restored["dispositions"][0]["policy_refs"],
)
self.assertEqual(
expected["transfer_status"],
restored["transfer_packages"][0]["status"],
)
self.assertTrue(restored["transfer_packages"][0]["simulated"])
self.assertEqual(
expected["filed_item_count"],
sum(
entry["event_type"] == "record.item_filed"
for entry in restored["chronology"]
),
)
recovery = restored_records.recovery_status(
restored_session,
self.principal,
record_id=str(record["record_id"]),
)
self.assertTrue(recovery["healthy"])
self.assertEqual(
expected["filed_item_count"], len(recovery["source_checks"])
)
self.assertTrue(
all(
check["status"] == "verified"
for check in recovery["source_checks"]
)
)
self.assertTrue(
all(
check["manifest_verified"]
for check in recovery["package_checks"]
)
)
token = bind_temporal_data_context(
TemporalDataContext(
validity_mode="at",
valid_at=lifecycle_start - timedelta(minutes=1),
recorded_at=lifecycle_start - timedelta(minutes=1),
)
)
try:
historical = restored_records.get_record(
restored_session,
self.principal,
record_id=str(record["record_id"]),
)
self.assertEqual("open", historical["record"]["state"])
self.assertEqual(
expected["filed_item_count"], len(historical["items"])
)
self.assertEqual([], historical["transfer_packages"])
finally:
reset_temporal_data_context(token)
finally:
restored_session.close()
restored_engine.dispose()
def test_disposition_waits_for_optional_approvals_and_can_be_withdrawn( def test_disposition_waits_for_optional_approvals_and_can_be_withdrawn(
self, self,
) -> None: ) -> None:
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[1]
class ReferenceJourneyUiTests(unittest.TestCase):
def test_actor_can_resume_and_reconstruct_the_record_journey(self) -> None:
page = (ROOT / "webui/src/features/records/RecordsPage.tsx").read_text(
encoding="utf-8"
)
lifecycle = (
ROOT / "webui/src/features/records/RecordLifecyclePanel.tsx"
).read_text(encoding="utf-8")
api = (ROOT / "webui/src/api/records.ts").read_text(encoding="utf-8")
for marker in (
'searchParams.get("recordId")',
'next.set("recordId", selectedRecordId)',
"filingPresetFrom(searchParams)",
'searchParams.get("sourceRevision")',
'<WorkspaceActionBar',
"refreshable",
'reloadAction={{ onReload: reload',
'data-help-context-id="records.governance-context"',
'data-help-context-id="records.record-items"',
'data-help-context-id="records.chronology"',
"item.source.source_revision",
"item.authority_mode",
"item.relationship",
"item.purpose",
"item.source_metadata.evidence_role",
"detail.access_explanation.current_authorization",
):
self.assertIn(marker, page)
for action in (
'openAction("close")',
'openAction("reopen")',
'openAction("appraise")',
'openAction("hold")',
'openAction("release-hold", holdId)',
'openAction("disposition")',
'openAction("finalize")',
'openAction("prepare-transfer")',
'openAction("dispatch-transfer")',
"openRecovery",
"Release all active holds first.",
"Simulation only",
"custody",
"item.policy_refs",
):
self.assertIn(action, lifecycle)
for endpoint in (
'"close"',
'"reopen"',
'"appraise"',
'"holds"',
'"dispositions"',
'"transfer-packages"',
"/recovery`",
):
self.assertIn(endpoint, api)
if __name__ == "__main__":
unittest.main()
+77 -1
View File
@@ -14,7 +14,11 @@ from govoplan_core.core.search import (
SearchBackfillRequest, SearchBackfillRequest,
SearchResourceReference, SearchResourceReference,
) )
from govoplan_records.backend.db.models import RecordIdentity, RecordRevision from govoplan_records.backend.db.models import (
RecordAccessGrantRevision,
RecordIdentity,
RecordRevision,
)
from govoplan_records.backend.search_source import ( from govoplan_records.backend.search_source import (
ADMIN_SCOPE, ADMIN_SCOPE,
PROVIDER_ID, PROVIDER_ID,
@@ -32,6 +36,7 @@ class RecordsSearchSourceTests(unittest.TestCase):
self.engine = create_engine("sqlite+pysqlite:///:memory:") self.engine = create_engine("sqlite+pysqlite:///:memory:")
RecordIdentity.__table__.create(self.engine) RecordIdentity.__table__.create(self.engine)
RecordRevision.__table__.create(self.engine) RecordRevision.__table__.create(self.engine)
RecordAccessGrantRevision.__table__.create(self.engine)
self.session = Session(self.engine) self.session = Session(self.engine)
identity = RecordIdentity( identity = RecordIdentity(
id="identity-1", id="identity-1",
@@ -160,6 +165,77 @@ class RecordsSearchSourceTests(unittest.TestCase):
)[other_tenant_reference.key] )[other_tenant_reference.key]
) )
def test_restricted_search_requires_an_explicit_search_purpose_grant(self) -> None:
self.session.add_all(
(
RecordIdentity(
id="identity-restricted",
tenant_id="tenant-1",
record_id="record-restricted",
record_number="EA-RESTRICTED",
),
RecordRevision(
tenant_id="tenant-1",
record_id="record-restricted",
identity_id="identity-restricted",
revision=1,
class_id="class-1",
file_plan_node_id="plan-1",
title="Restricted decision",
state="open",
source_authority_mode="native_authoritative",
access_mode="restricted",
purpose="case-work",
institutional_context={},
external_reference={},
search_text="restricted decision",
valid_from=NOW,
recorded_at=NOW,
snapshot={},
),
)
)
self.session.commit()
reference = SearchResourceReference(
tenant_id="tenant-1",
module_id="records",
resource_type=RESOURCE_TYPE,
resource_id="record-restricted",
)
request = SearchAuthorizationRequest(reference=reference, source_revision="1")
self.assertFalse(
self.source.authorize(
self.session, _principal({READ_SCOPE}), requests=(request,)
)[reference.key]
)
self.session.add(
RecordAccessGrantRevision(
tenant_id="tenant-1",
grant_id="search-grant-1",
record_id="record-restricted",
revision=1,
status="active",
subject_type="account",
subject_id="account-1",
actions=["read"],
allowed_purposes=["records.search"],
reason="Search is required for assigned work.",
valid_from=NOW,
recorded_at=NOW,
changed_by="account-1",
institutional_context={},
idempotency_key="search-grant-create-1",
request_sha256="a" * 64,
)
)
self.session.commit()
self.assertTrue(
self.source.authorize(
self.session, _principal({READ_SCOPE}), requests=(request,)
)[reference.key]
)
def _principal(scopes: set[str]) -> ApiPrincipal: def _principal(scopes: set[str]) -> ApiPrincipal:
return ApiPrincipal( return ApiPrincipal(
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/records-webui", "name": "@govoplan/records-webui",
"version": "0.1.19", "version": "0.1.23",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+65 -3
View File
@@ -89,9 +89,15 @@ export type RecordItem = {
content_sha256?: string | null; content_sha256?: string | null;
content_type?: string | null; content_type?: string | null;
size_bytes?: number | null; size_bytes?: number | null;
source_valid_from?: string | null;
source_valid_to?: string | null;
source_recorded_at?: string | null;
launch_url?: string | null; launch_url?: string | null;
filed_at: string; filed_at: string;
filed_by?: string | null; filed_by?: string | null;
institutional_context: Record<string, unknown>;
source_metadata: Record<string, unknown>;
filing_metadata: Record<string, unknown>;
}; };
export type RecordChronology = { export type RecordChronology = {
@@ -147,10 +153,30 @@ export type RecordTransferPackage = {
provider_id: string; provider_id: string;
profile: string; profile: string;
status: string; status: string;
authority_mode: string;
manifest: Record<string, unknown>;
manifest_sha256: string; manifest_sha256: string;
receipt: Record<string, unknown>;
receipt_sha256?: string | null; receipt_sha256?: string | null;
external_reference?: string | null; external_reference?: string | null;
recovery_operation_id?: string | null;
simulated: boolean; simulated: boolean;
institutional_context: Record<string, unknown>;
recorded_at: string;
};
export type RecordAccessGrant = {
grant_id: string;
record_id: string;
revision: number;
status: "active" | "revoked" | string;
subject_type: "account" | "membership" | "group" | "role" | "function_assignment" | "delegation";
subject_id: string;
actions: Array<"read" | "write" | "manage">;
allowed_purposes: string[];
reason: string;
valid_from: string;
valid_to?: string | null;
recorded_at: string; recorded_at: string;
}; };
@@ -168,6 +194,8 @@ export type RecordDetail = {
purpose: string; purpose: string;
current_authorization: boolean; current_authorization: boolean;
access_mode: string; access_mode: string;
grant_id?: string | null;
grant_subject_type?: string | null;
limitations: string[]; limitations: string[];
}; };
}; };
@@ -198,6 +226,7 @@ export function listRecords(
state?: string; state?: string;
classId?: string; classId?: string;
filePlanNodeId?: string; filePlanNodeId?: string;
purpose?: string;
offset?: number; offset?: number;
limit?: number; limit?: number;
}, },
@@ -208,13 +237,19 @@ export function listRecords(
state: options.state, state: options.state,
class_id: options.classId, class_id: options.classId,
file_plan_node_id: options.filePlanNodeId, file_plan_node_id: options.filePlanNodeId,
purpose: options.purpose,
offset: options.offset, offset: options.offset,
limit: options.limit ?? 50 limit: options.limit ?? 50
}), { signal }); }), { signal });
} }
export function getRecord(settings: ApiSettings, recordId: string, signal?: AbortSignal): Promise<RecordDetail> { export function getRecord(
return apiFetch(settings, `/api/v1/records/${encodeURIComponent(recordId)}`, { signal }); settings: ApiSettings,
recordId: string,
purpose?: string,
signal?: AbortSignal
): Promise<RecordDetail> {
return apiFetch(settings, apiPath(`/api/v1/records/${encodeURIComponent(recordId)}`, { purpose }), { signal });
} }
export function getRecordCatalog(settings: ApiSettings, signal?: AbortSignal): Promise<RecordCatalog> { export function getRecordCatalog(settings: ApiSettings, signal?: AbortSignal): Promise<RecordCatalog> {
@@ -351,9 +386,36 @@ export function dispatchRecordTransfer(
export function getRecordRecoveryStatus( export function getRecordRecoveryStatus(
settings: ApiSettings, settings: ApiSettings,
recordId: string, recordId: string,
purpose: string,
signal?: AbortSignal signal?: AbortSignal
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
return apiFetch(settings, `/api/v1/records/${encodeURIComponent(recordId)}/recovery`, { signal }); return apiFetch(settings, apiPath(`/api/v1/records/${encodeURIComponent(recordId)}/recovery`, { purpose }), { signal });
}
export function listRecordAccessGrants(
settings: ApiSettings,
recordId: string,
purpose: string,
signal?: AbortSignal
): Promise<{ grants: RecordAccessGrant[] }> {
return apiFetch(settings, apiPath(`/api/v1/records/${encodeURIComponent(recordId)}/access-grants`, { purpose }), { signal });
}
export function createRecordAccessGrant(
settings: ApiSettings,
recordId: string,
payload: Record<string, unknown>
): Promise<RecordAccessGrant> {
return recordMutation(settings, recordId, "access-grants", payload);
}
export function revokeRecordAccessGrant(
settings: ApiSettings,
recordId: string,
grantId: string,
payload: Record<string, unknown>
): Promise<RecordAccessGrant> {
return recordMutation(settings, recordId, `access-grants/${encodeURIComponent(grantId)}/revoke`, payload);
} }
function recordMutation<T>( function recordMutation<T>(
@@ -0,0 +1,237 @@
import { useEffect, useState, type FormEvent } from "react";
import {
Button,
Dialog,
DialogForm,
DismissibleAlert,
FormField,
LoadingIndicator,
StatePanel,
StatusBadge,
type ApiSettings
} from "@govoplan/core-webui";
import {
createRecordAccessGrant,
listRecordAccessGrants,
revokeRecordAccessGrant,
type RecordAccessGrant,
type RecordEntry
} from "../../api/records";
type SubjectType = RecordAccessGrant["subject_type"];
export function RecordAccessGrantsDialog({
open,
settings,
record,
managementPurpose,
onClose,
onChanged
}: {
open: boolean;
settings: ApiSettings;
record: RecordEntry | null;
managementPurpose: string;
onClose: () => void;
onChanged: () => void;
}) {
const [grants, setGrants] = useState<RecordAccessGrant[]>([]);
const [subjectType, setSubjectType] = useState<SubjectType>("account");
const [subjectId, setSubjectId] = useState("");
const [read, setRead] = useState(true);
const [write, setWrite] = useState(false);
const [manage, setManage] = useState(false);
const [allowedPurposes, setAllowedPurposes] = useState("");
const [reason, setReason] = useState("");
const [validTo, setValidTo] = useState("");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const reload = () => {
if (!open || !record || !managementPurpose.trim()) return;
const controller = new AbortController();
setLoading(true);
setError("");
listRecordAccessGrants(
settings,
record.record_id,
managementPurpose.trim(),
controller.signal
).then((result) => setGrants(result.grants)).catch((cause) => {
if ((cause as Error).name !== "AbortError") {
setError(errorMessage(cause, "The restricted access grants could not be loaded."));
}
}).finally(() => setLoading(false));
return () => controller.abort();
};
useEffect(reload, [managementPurpose, open, record?.record_id, settings]);
useEffect(() => {
if (!open) return;
setSubjectType("account");
setSubjectId("");
setRead(true);
setWrite(false);
setManage(false);
setAllowedPurposes("");
setReason("");
setValidTo("");
}, [open]);
async function submit(event: FormEvent) {
event.preventDefault();
if (!record) return;
const actions = [read ? "read" : "", write ? "write" : "", manage ? "manage" : ""].filter(Boolean);
const purposes = splitValues(allowedPurposes);
if (!subjectId.trim() || actions.length === 0 || purposes.length === 0 || !reason.trim()) return;
setSaving(true);
setError("");
try {
await createRecordAccessGrant(settings, record.record_id, {
subject_type: subjectType,
subject_id: subjectId.trim(),
actions,
allowed_purposes: purposes,
reason: reason.trim(),
purpose: managementPurpose.trim(),
recorded_at: new Date().toISOString(),
valid_to: validTo ? new Date(validTo).toISOString() : null,
institutional_context: {},
idempotency_key: randomId()
});
setSubjectId("");
setAllowedPurposes("");
setReason("");
setValidTo("");
onChanged();
reload();
} catch (cause) {
setError(errorMessage(cause, "The restricted access grant could not be created."));
} finally {
setSaving(false);
}
}
async function revoke(grant: RecordAccessGrant) {
if (!record || !window.confirm("Revoke this restricted-record access grant?")) return;
setSaving(true);
setError("");
try {
await revokeRecordAccessGrant(settings, record.record_id, grant.grant_id, {
expected_revision: grant.revision,
reason: "Access grant revoked by a Records administrator.",
purpose: managementPurpose.trim(),
recorded_at: new Date().toISOString(),
idempotency_key: randomId()
});
onChanged();
reload();
} catch (cause) {
setError(errorMessage(cause, "The restricted access grant could not be revoked."));
} finally {
setSaving(false);
}
}
const actionsSelected = read || write || manage;
const canSubmit = Boolean(
record && managementPurpose.trim() && subjectId.trim() && actionsSelected &&
splitValues(allowedPurposes).length && reason.trim()
);
return (
<Dialog
open={open}
title="Restricted record access"
onClose={onClose}
closeDisabled={saving}
portal
className="records-dialog records-access-dialog"
helpContextId="records.restricted-access"
footer={<Button type="button" variant="secondary" onClick={onClose} disabled={saving}>Close</Button>}
>
<p>
Grants are effective-dated and match an exact subject, action, and declared purpose.
Current grants also govern historical record views.
</p>
<p><strong>Management purpose:</strong> {managementPurpose || "Not supplied"}</p>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{loading ? <LoadingIndicator label="Loading access grants" /> : grants.length === 0 ? (
<StatePanel size="inline" description="No current access grants are available." />
) : (
<div className="records-grant-list">
{grants.map((grant) => (
<div className="records-grant-row" key={grant.grant_id}>
<div>
<strong>{humanize(grant.subject_type)} · {grant.subject_id}</strong>
<span>{grant.actions.map(humanize).join(", ")} · {grant.allowed_purposes.join(", ")}</span>
<small>{grant.reason}</small>
</div>
<StatusBadge status={grant.status === "active" ? "active" : "neutral"} label={humanize(grant.status)} />
{grant.status === "active" && (
<Button
type="button"
variant="danger"
onClick={() => revoke(grant)}
disabled={saving}
helpContextId="records.restricted-access"
helpModuleId="records"
>
Revoke
</Button>
)}
</div>
))}
</div>
)}
<DialogForm id="records-access-grant-form" className="records-dialog-form" onSubmit={submit}>
<h3>Add access grant</h3>
<FormField label="Subject type">
<select value={subjectType} onChange={(event) => setSubjectType(event.target.value as SubjectType)}>
<option value="account">Account</option>
<option value="membership">Membership</option>
<option value="group">Group</option>
<option value="role">Role</option>
<option value="function_assignment">Function assignment</option>
<option value="delegation">Delegation</option>
</select>
</FormField>
<FormField label="Subject identifier"><input value={subjectId} onChange={(event) => setSubjectId(event.target.value)} required /></FormField>
<FormField label="Actions">
<div className="records-grant-actions">
<label><input type="checkbox" checked={read} onChange={(event) => setRead(event.target.checked)} /> Read</label>
<label><input type="checkbox" checked={write} onChange={(event) => setWrite(event.target.checked)} /> Write</label>
<label><input type="checkbox" checked={manage} onChange={(event) => setManage(event.target.checked)} /> Manage</label>
</div>
</FormField>
<FormField label="Allowed purposes"><textarea value={allowedPurposes} onChange={(event) => setAllowedPurposes(event.target.value)} rows={3} placeholder="One purpose per line" required /></FormField>
<FormField label="Valid until"><input type="datetime-local" value={validTo} onChange={(event) => setValidTo(event.target.value)} /></FormField>
<FormField label="Reason"><textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={3} required /></FormField>
<Button type="submit" variant="primary" disabledReason={saving ? "The grant is being saved." : !canSubmit ? "Complete the subject, action, purpose, and reason." : undefined}>
{saving ? "Saving" : "Add grant"}
</Button>
</DialogForm>
</Dialog>
);
}
function splitValues(value: string): string[] {
return Array.from(new Set(value.split(/[\n,]/).map((item) => item.trim()).filter(Boolean)));
}
function humanize(value: string): string {
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function randomId(): string {
return typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function errorMessage(reason: unknown, fallback: string): string {
return reason instanceof Error && reason.message ? reason.message : fallback;
}
@@ -1,5 +1,5 @@
import { useEffect, useState, type FormEvent } from "react"; import { useEffect, useState, type FormEvent } from "react";
import { import { DialogForm,
Button, Button,
Dialog, Dialog,
DismissibleAlert, DismissibleAlert,
@@ -146,7 +146,7 @@ export function RecordCatalogDialog({
footer={<><Button type="button" variant="ghost" onClick={onClose} disabled={saving}>Cancel</Button><Button type="submit" form="records-catalog-form" variant="primary" disabledReason={saving ? "The catalog revision is being saved." : !isValid ? "Complete all required catalog fields." : undefined}>{saving ? "Saving" : "Save revision"}</Button></>} footer={<><Button type="button" variant="ghost" onClick={onClose} disabled={saving}>Cancel</Button><Button type="submit" form="records-catalog-form" variant="primary" disabledReason={saving ? "The catalog revision is being saved." : !isValid ? "Complete all required catalog fields." : undefined}>{saving ? "Saving" : "Save revision"}</Button></>}
> >
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<form id="records-catalog-form" className="records-dialog-form" onSubmit={submit}> <DialogForm id="records-catalog-form" className="records-dialog-form" onSubmit={submit}>
<FormField label="Catalog object"> <FormField label="Catalog object">
<select value={kind} onChange={(event) => { setKind(event.target.value as typeof kind); setSelectedId(""); }}> <select value={kind} onChange={(event) => { setKind(event.target.value as typeof kind); setSelectedId(""); }}>
<option value="file-plan">File-plan node</option> <option value="file-plan">File-plan node</option>
@@ -162,9 +162,9 @@ export function RecordCatalogDialog({
<FormField label={kind === "file-plan" ? "Node ID" : "Class ID"}><input value={identifier} onChange={(event) => setIdentifier(event.target.value)} disabled={Boolean(selectedId)} required /></FormField> <FormField label={kind === "file-plan" ? "Node ID" : "Class ID"}><input value={identifier} onChange={(event) => setIdentifier(event.target.value)} disabled={Boolean(selectedId)} required /></FormField>
<FormField label={kind === "file-plan" ? "Code" : "Key"}><input value={code} onChange={(event) => setCode(event.target.value)} required /></FormField> <FormField label={kind === "file-plan" ? "Code" : "Key"}><input value={code} onChange={(event) => setCode(event.target.value)} required /></FormField>
<FormField label="Label"><input value={label} onChange={(event) => setLabel(event.target.value)} required /></FormField> <FormField label="Label"><input value={label} onChange={(event) => setLabel(event.target.value)} required /></FormField>
{kind === "file-plan" ? <FormField label="Parent node"><select value={parentId} onChange={(event) => setParentId(event.target.value)}><option value="">Top level</option>{catalog.file_plan.filter((item) => item.node_id !== identifier).map((item) => <option key={item.node_id} value={item.node_id}>{item.code} · {item.label}</option>)}</select></FormField> : <><FormField label="File-plan node"><select value={filePlanNodeId} onChange={(event) => setFilePlanNodeId(event.target.value)} required>{catalog.file_plan.map((item) => <option key={item.node_id} value={item.node_id}>{item.code} · {item.label}</option>)}</select></FormField><FormField label="Retention period (days)"><input type="number" min="0" value={retentionDays} onChange={(event) => setRetentionDays(event.target.value)} /></FormField><FormField label="Retention trigger"><select value={closureTrigger} onChange={(event) => setClosureTrigger(event.target.value)}><option value="record_closed">Record closure</option><option value="calendar_month_end">End of closure month</option><option value="calendar_year_end">End of closure year</option><option value="explicit">Explicit date at closure</option></select></FormField></>} {kind === "file-plan" ? <FormField label="Parent node"><select value={parentId} onChange={(event) => setParentId(event.target.value)}><option value="">Top level</option>{catalog.file_plan.filter((item) => item.node_id !== identifier).map((item) => <option key={item.node_id} value={item.node_id}>{item.code} · {item.label}</option>)}</select></FormField> : <><FormField label="File-plan node"><select value={filePlanNodeId} onChange={(event) => setFilePlanNodeId(event.target.value)} required>{catalog.file_plan.map((item) => <option key={item.node_id} value={item.node_id}>{item.code} · {item.label}</option>)}</select></FormField><FormField label="Retention period (days)" helpContextId="records.field.retention-trigger" helpModuleId="records"><input type="number" min="0" value={retentionDays} onChange={(event) => setRetentionDays(event.target.value)} /></FormField><FormField label="Retention trigger" helpContextId="records.field.retention-trigger" helpModuleId="records"><select value={closureTrigger} onChange={(event) => setClosureTrigger(event.target.value)}><option value="record_closed">Record closure</option><option value="calendar_month_end">End of closure month</option><option value="calendar_year_end">End of closure year</option><option value="explicit">Explicit date at closure</option></select></FormField></>}
<FormField label="Description"><textarea rows={4} value={description} onChange={(event) => setDescription(event.target.value)} /></FormField> <FormField label="Description"><textarea rows={4} value={description} onChange={(event) => setDescription(event.target.value)} /></FormField>
</form> </DialogForm>
</Dialog> </Dialog>
); );
} }
@@ -10,7 +10,7 @@ import {
RotateCcw RotateCcw
} from "lucide-react"; } from "lucide-react";
import { useEffect, useMemo, useState, type FormEvent } from "react"; import { useEffect, useMemo, useState, type FormEvent } from "react";
import { import { DialogForm,
Button, Button,
Dialog, Dialog,
DismissibleAlert, DismissibleAlert,
@@ -122,7 +122,7 @@ export function RecordLifecyclePanel({
openAction("recovery"); openAction("recovery");
setSaving(true); setSaving(true);
try { try {
setRecovery(await getRecordRecoveryStatus(settings, record.record_id)); setRecovery(await getRecordRecoveryStatus(settings, record.record_id, purpose.trim()));
} catch (reason) { } catch (reason) {
setError(errorMessage(reason, "Recovery evidence could not be loaded.")); setError(errorMessage(reason, "Recovery evidence could not be loaded."));
} finally { } finally {
@@ -258,10 +258,10 @@ export function RecordLifecyclePanel({
{availableActions.canReopen && <Button type="button" variant="ghost" onClick={() => openAction("reopen")}><RotateCcw size={16} aria-hidden="true" /> Reopen</Button>} {availableActions.canReopen && <Button type="button" variant="ghost" onClick={() => openAction("reopen")}><RotateCcw size={16} aria-hidden="true" /> Reopen</Button>}
{availableActions.canAppraise && <Button type="button" variant="ghost" onClick={() => openAction("appraise")}><CheckCheck size={16} aria-hidden="true" /> Appraise</Button>} {availableActions.canAppraise && <Button type="button" variant="ghost" onClick={() => openAction("appraise")}><CheckCheck size={16} aria-hidden="true" /> Appraise</Button>}
<Button type="button" variant="ghost" onClick={() => openAction("hold")} disabledReason={!canWrite ? "Your account may not apply a hold." : undefined}><LockKeyhole size={16} aria-hidden="true" /> Apply hold</Button> <Button type="button" variant="ghost" onClick={() => openAction("hold")} disabledReason={!canWrite ? "Your account may not apply a hold." : undefined}><LockKeyhole size={16} aria-hidden="true" /> Apply hold</Button>
{availableActions.canPropose && <Button type="button" variant="primary" onClick={() => openAction("disposition")} disabledReason={activeHolds.length ? "Release all active holds first." : undefined}><ArchiveRestore size={16} aria-hidden="true" /> Propose disposition</Button>} {availableActions.canPropose && <Button type="button" variant="primary" helpContextId="records.lifecycle.disposition" helpModuleId="records" onClick={() => openAction("disposition")} disabledReason={activeHolds.length ? "Release all active holds first." : undefined}><ArchiveRestore size={16} aria-hidden="true" /> Propose disposition</Button>}
{availableActions.canFinalize && <Button type="button" variant="primary" onClick={() => openAction("finalize")} disabledReason={activeHolds.length ? "Release all active holds first." : undefined}><CheckCheck size={16} aria-hidden="true" /> Finalize approved disposition</Button>} {availableActions.canFinalize && <Button type="button" variant="primary" helpContextId="records.lifecycle.finalize" helpModuleId="records" onClick={() => openAction("finalize")} disabledReason={activeHolds.length ? "Release all active holds first." : undefined}><CheckCheck size={16} aria-hidden="true" /> Finalize approved disposition</Button>}
{currentDisposition && ["review_pending", "review_unavailable"].includes(currentDisposition.status) && <Button type="button" variant="ghost" onClick={() => openAction("withdraw")}><Ban size={16} aria-hidden="true" /> Withdraw proposal</Button>} {currentDisposition && ["review_pending", "review_unavailable"].includes(currentDisposition.status) && <Button type="button" variant="ghost" helpContextId="records.lifecycle.disposition" helpModuleId="records" onClick={() => openAction("withdraw")}><Ban size={16} aria-hidden="true" /> Withdraw proposal</Button>}
{availableActions.canPrepare && <Button type="button" variant="primary" onClick={() => openAction("prepare-transfer")} disabledReason={healthyProviders.length === 0 ? "No healthy archive profile is available." : activeHolds.length ? "Release all active holds first." : undefined}><Box size={16} aria-hidden="true" /> Prepare transfer</Button>} {availableActions.canPrepare && <Button type="button" variant="primary" helpContextId="records.lifecycle.prepare-transfer" helpModuleId="records" onClick={() => openAction("prepare-transfer")} disabledReason={healthyProviders.length === 0 ? "No healthy archive profile is available." : activeHolds.length ? "Release all active holds first." : undefined}><Box size={16} aria-hidden="true" /> Prepare transfer</Button>}
{availableActions.canDispatch && <Button type="button" variant="primary" onClick={() => openAction("dispatch-transfer")} disabledReason={!currentPackage?.simulated ? "Real archive dispatch needs a target-specific recovery profile." : activeHolds.length ? "Release all active holds first." : undefined}><Play size={16} aria-hidden="true" /> Run simulation</Button>} {availableActions.canDispatch && <Button type="button" variant="primary" onClick={() => openAction("dispatch-transfer")} disabledReason={!currentPackage?.simulated ? "Real archive dispatch needs a target-specific recovery profile." : activeHolds.length ? "Release all active holds first." : undefined}><Play size={16} aria-hidden="true" /> Run simulation</Button>}
{canAdmin && <Button type="button" variant="ghost" onClick={openRecovery}><ArchiveRestore size={16} aria-hidden="true" /> Recovery evidence</Button>} {canAdmin && <Button type="button" variant="ghost" onClick={openRecovery}><ArchiveRestore size={16} aria-hidden="true" /> Recovery evidence</Button>}
</div> </div>
@@ -286,7 +286,7 @@ export function RecordLifecyclePanel({
> >
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{action === "recovery" ? <RecoveryResult value={recovery} loading={saving} /> : ( {action === "recovery" ? <RecoveryResult value={recovery} loading={saving} /> : (
<form id="records-lifecycle-form" className="records-dialog-form" onSubmit={submit}> <DialogForm id="records-lifecycle-form" className="records-dialog-form" onSubmit={submit}>
{action === "volume" && <FormField label="Volume label" helpContextId="records.field.volume-label"><input value={label} onChange={(event) => setLabel(event.target.value)} required /></FormField>} {action === "volume" && <FormField label="Volume label" helpContextId="records.field.volume-label"><input value={label} onChange={(event) => setLabel(event.target.value)} required /></FormField>}
{["appraise", "disposition"].includes(action ?? "") && <FormField label="Disposition" helpContextId="records.field.disposition"><select value={outcome} onChange={(event) => setOutcome(event.target.value as typeof outcome)}><option value="retain">Retain</option><option value="transfer">Transfer</option><option value="destroy">Destroy</option><option value="reclassify">Reclassify</option></select></FormField>} {["appraise", "disposition"].includes(action ?? "") && <FormField label="Disposition" helpContextId="records.field.disposition"><select value={outcome} onChange={(event) => setOutcome(event.target.value as typeof outcome)}><option value="retain">Retain</option><option value="transfer">Transfer</option><option value="destroy">Destroy</option><option value="reclassify">Reclassify</option></select></FormField>}
{action === "hold" && <FormField label="Authority" helpContextId="records.field.hold-authority"><input value={authority} onChange={(event) => setAuthority(event.target.value)} required /></FormField>} {action === "hold" && <FormField label="Authority" helpContextId="records.field.hold-authority"><input value={authority} onChange={(event) => setAuthority(event.target.value)} required /></FormField>}
@@ -296,7 +296,7 @@ export function RecordLifecyclePanel({
{action === "finalize" && currentDisposition?.approval_request_id && <DismissibleAlert tone="info" dismissible={false} compact>Approval request <a href="/approvals">{currentDisposition.approval_request_id}</a> must be approved by another account.</DismissibleAlert>} {action === "finalize" && currentDisposition?.approval_request_id && <DismissibleAlert tone="info" dismissible={false} compact>Approval request <a href="/approvals">{currentDisposition.approval_request_id}</a> must be approved by another account.</DismissibleAlert>}
{requiresReason(action) && <FormField label="Reason" helpContextId="records.field.lifecycle-reason"><textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={4} required /></FormField>} {requiresReason(action) && <FormField label="Reason" helpContextId="records.field.lifecycle-reason"><textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={4} required /></FormField>}
<FormField label="Purpose"><input value={purpose} onChange={(event) => setPurpose(event.target.value)} required /></FormField> <FormField label="Purpose"><input value={purpose} onChange={(event) => setPurpose(event.target.value)} required /></FormField>
</form> </DialogForm>
)} )}
</Dialog> </Dialog>
</> </>
@@ -314,7 +314,7 @@ function LifecycleEvidence({ detail, onRelease, canWrite }: { detail: RecordDeta
<div><dt>Appraisal</dt><dd>{record.appraisal_state ? humanize(record.appraisal_state) : "Not appraised"}</dd></div> <div><dt>Appraisal</dt><dd>{record.appraisal_state ? humanize(record.appraisal_state) : "Not appraised"}</dd></div>
</dl> </dl>
{detail.volumes.length > 0 && <div className="records-evidence-list"><strong>Volumes</strong>{detail.volumes.map((volume) => <div key={volume.volume_id}><span>{volume.sequence}. {volume.label}</span><StatusBadge status={volume.state === "open" ? "active" : "neutral"} label={humanize(volume.state)} /></div>)}</div>} {detail.volumes.length > 0 && <div className="records-evidence-list"><strong>Volumes</strong>{detail.volumes.map((volume) => <div key={volume.volume_id}><span>{volume.sequence}. {volume.label}</span><StatusBadge status={volume.state === "open" ? "active" : "neutral"} label={humanize(volume.state)} /></div>)}</div>}
{detail.holds.length > 0 && <div className="records-evidence-list"><strong>Holds</strong>{detail.holds.map((hold) => <div key={hold.hold_id}><span><b>{hold.authority}</b> · {hold.reason}</span><span className="records-evidence-actions"><StatusBadge status={hold.status === "active" ? "danger" : "neutral"} label={humanize(hold.status)} />{hold.status === "active" && <Button type="button" variant="ghost" iconOnly aria-label="Release hold" title="Release hold" onClick={() => onRelease(hold.hold_id)} disabledReason={!canWrite ? "Your account may not release holds." : undefined}><Ban size={15} aria-hidden="true" /></Button>}</span></div>)}</div>} {detail.holds.length > 0 && <div className="records-evidence-list"><strong>Holds</strong>{detail.holds.map((hold) => <div key={hold.hold_id}><span><b>{hold.authority}</b> · {hold.reason}{hold.policy_refs.length > 0 ? ` · policy ${hold.policy_refs.join(", ")}` : ""}</span><span className="records-evidence-actions"><StatusBadge status={hold.status === "active" ? "danger" : "neutral"} label={humanize(hold.status)} />{hold.status === "active" && <Button type="button" variant="ghost" iconOnly aria-label="Release hold" title="Release hold" onClick={() => onRelease(hold.hold_id)} disabledReason={!canWrite ? "Your account may not release holds." : undefined}><Ban size={15} aria-hidden="true" /></Button>}</span></div>)}</div>}
{detail.dispositions.map((item) => <DispositionEvidence key={item.disposition_id} item={item} />)} {detail.dispositions.map((item) => <DispositionEvidence key={item.disposition_id} item={item} />)}
{detail.transfer_packages.map((item) => <TransferEvidence key={item.package_id} item={item} />)} {detail.transfer_packages.map((item) => <TransferEvidence key={item.package_id} item={item} />)}
</div> </div>
@@ -322,11 +322,20 @@ function LifecycleEvidence({ detail, onRelease, canWrite }: { detail: RecordDeta
} }
function DispositionEvidence({ item }: { item: RecordDisposition }) { function DispositionEvidence({ item }: { item: RecordDisposition }) {
return <div className="records-evidence-list"><strong>Disposition</strong><div><span><b>{humanize(item.action)}</b> · {item.reason}</span><StatusBadge status={item.status === "approved" ? "active" : item.status === "review_unavailable" ? "danger" : "warning"} label={humanize(item.status)} /></div>{item.approval_request_id && <small>Approval: {item.approval_request_id}</small>}</div>; return <div className="records-evidence-list"><strong>Disposition</strong><div><span><b>{humanize(item.action)}</b> · {item.reason}</span><StatusBadge status={item.status === "approved" ? "active" : item.status === "review_unavailable" ? "danger" : "warning"} label={humanize(item.status)} /></div>{item.policy_refs.length > 0 && <small>Policy: {item.policy_refs.join(", ")}</small>}{item.approval_request_id && <small>Approval: {item.approval_request_id}</small>}</div>;
} }
function TransferEvidence({ item }: { item: RecordTransferPackage }) { function TransferEvidence({ item }: { item: RecordTransferPackage }) {
return <div className="records-evidence-list"><strong>Transfer package</strong><div><span><b>{item.profile}</b> · {item.manifest_sha256.slice(0, 12)}</span><StatusBadge status={item.status === "simulated_accepted" ? "warning" : item.status === "accepted" ? "active" : "neutral"} label={humanize(item.status)} /></div>{item.simulated && <small>Simulation only; no archival custody.</small>}</div>; const receiptMetadata = typeof item.receipt.metadata === "object" && item.receipt.metadata != null
? item.receipt.metadata as Record<string, unknown>
: {};
const custodyTransferred = receiptMetadata.custody_transferred === true;
const custodySummary = item.simulated
? "Simulation only; no archival custody."
: custodyTransferred
? "Archive custody was transferred."
: "Archive custody was not transferred.";
return <div className="records-evidence-list"><strong>Transfer package</strong><div><span><b>{item.profile}</b> · {item.manifest_sha256.slice(0, 12)}</span><StatusBadge status={item.status === "simulated_accepted" ? "warning" : item.status === "accepted" ? "active" : "neutral"} label={humanize(item.status)} /></div><small>{custodySummary} Authority: {humanize(item.authority_mode)}.</small></div>;
} }
function RecoveryResult({ value, loading }: { value: Record<string, unknown> | null; loading: boolean }) { function RecoveryResult({ value, loading }: { value: Record<string, unknown> | null; loading: boolean }) {
+175 -38
View File
@@ -3,7 +3,6 @@ import {
FilePlus2, FilePlus2,
Pencil, Pencil,
Plus, Plus,
RefreshCw,
Search, Search,
Settings2 Settings2
} from "lucide-react"; } from "lucide-react";
@@ -15,18 +14,22 @@ import {
type FormEvent type FormEvent
} from "react"; } from "react";
import { useSearchParams } from "react-router"; import { useSearchParams } from "react-router";
import { import { DialogForm,
Button, Button,
DataGrid, DataGrid,
Dialog, Dialog,
DismissibleAlert, DismissibleAlert,
DocumentationHelpLink, DocumentationHelpLink,
FilterBar,
FormField, FormField,
i18nMessage, i18nMessage,
LoadingIndicator, LoadingIndicator,
PageScrollViewport, PageScrollViewport,
StatePanel,
StatusBadge, StatusBadge,
useTemporalDataContext, useTemporalDataContext,
WorkspaceActionBar,
WorkspaceFrame,
type DataGridColumn, type DataGridColumn,
type PlatformRouteContext type PlatformRouteContext
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
@@ -48,6 +51,7 @@ import {
type RecordSourceProvider type RecordSourceProvider
} from "../../api/records"; } from "../../api/records";
import { RecordCatalogDialog } from "./RecordCatalogDialog"; import { RecordCatalogDialog } from "./RecordCatalogDialog";
import { RecordAccessGrantsDialog } from "./RecordAccessGrantsDialog";
import { RecordLifecyclePanel } from "./RecordLifecyclePanel"; import { RecordLifecyclePanel } from "./RecordLifecyclePanel";
import { import {
RECORDS_DOCUMENTATION, RECORDS_DOCUMENTATION,
@@ -75,6 +79,12 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [submittedQuery, setSubmittedQuery] = useState(""); const [submittedQuery, setSubmittedQuery] = useState("");
const [accessPurpose, setAccessPurpose] = useState(
() => searchParams.get("purpose") ?? ""
);
const [submittedAccessPurpose, setSubmittedAccessPurpose] = useState(
() => searchParams.get("purpose") ?? ""
);
const [selectedNodeId, setSelectedNodeId] = useState(""); const [selectedNodeId, setSelectedNodeId] = useState("");
const [selectedRecordId, setSelectedRecordId] = useState( const [selectedRecordId, setSelectedRecordId] = useState(
() => searchParams.get("recordId") ?? "" () => searchParams.get("recordId") ?? ""
@@ -91,6 +101,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [fileOpen, setFileOpen] = useState(false); const [fileOpen, setFileOpen] = useState(false);
const [catalogOpen, setCatalogOpen] = useState(false); const [catalogOpen, setCatalogOpen] = useState(false);
const [accessGrantsOpen, setAccessGrantsOpen] = useState(false);
const canWrite = auth.scopes.includes("records:workspace:write") || const canWrite = auth.scopes.includes("records:workspace:write") ||
auth.scopes.includes("records:workspace:admin"); auth.scopes.includes("records:workspace:admin");
const canAdmin = auth.scopes.includes("records:workspace:admin"); const canAdmin = auth.scopes.includes("records:workspace:admin");
@@ -130,6 +141,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
listRecords(settings, { listRecords(settings, {
query: submittedQuery, query: submittedQuery,
filePlanNodeId: selectedNodeId || undefined, filePlanNodeId: selectedNodeId || undefined,
purpose: submittedAccessPurpose || undefined,
offset: (page - 1) * PAGE_SIZE, offset: (page - 1) * PAGE_SIZE,
limit: PAGE_SIZE limit: PAGE_SIZE
}, controller.signal). }, controller.signal).
@@ -150,7 +162,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
}). }).
finally(() => setLoading(false)); finally(() => setLoading(false));
return () => controller.abort(); return () => controller.abort();
}, [page, reloadKey, selectedNodeId, selectionKey, settings, submittedQuery]); }, [page, reloadKey, selectedNodeId, selectionKey, settings, submittedAccessPurpose, submittedQuery]);
useEffect(() => { useEffect(() => {
if (!selectedRecordId) { if (!selectedRecordId) {
@@ -161,7 +173,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
const controller = new AbortController(); const controller = new AbortController();
setDetailLoading(true); setDetailLoading(true);
setDetailError(""); setDetailError("");
getRecord(settings, selectedRecordId, controller.signal). getRecord(settings, selectedRecordId, submittedAccessPurpose || undefined, controller.signal).
then(setDetail). then(setDetail).
catch((reason) => { catch((reason) => {
if ((reason as Error).name !== "AbortError") { if ((reason as Error).name !== "AbortError") {
@@ -171,7 +183,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
}). }).
finally(() => setDetailLoading(false)); finally(() => setDetailLoading(false));
return () => controller.abort(); return () => controller.abort();
}, [reloadKey, selectedRecordId, selectionKey, settings]); }, [reloadKey, selectedRecordId, selectionKey, settings, submittedAccessPurpose]);
useEffect(() => { useEffect(() => {
const current = searchParams.get("recordId") ?? ""; const current = searchParams.get("recordId") ?? "";
@@ -179,8 +191,10 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
const next = new URLSearchParams(searchParams); const next = new URLSearchParams(searchParams);
if (selectedRecordId) next.set("recordId", selectedRecordId); if (selectedRecordId) next.set("recordId", selectedRecordId);
else next.delete("recordId"); else next.delete("recordId");
if (submittedAccessPurpose) next.set("purpose", submittedAccessPurpose);
else next.delete("purpose");
setSearchParams(next, { replace: true }); setSearchParams(next, { replace: true });
}, [searchParams, selectedRecordId, setSearchParams]); }, [searchParams, selectedRecordId, setSearchParams, submittedAccessPurpose]);
const classesById = useMemo( const classesById = useMemo(
() => new Map(catalog.classes.map((item) => [item.class_id, item])), () => new Map(catalog.classes.map((item) => [item.class_id, item])),
@@ -228,13 +242,19 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
event.preventDefault(); event.preventDefault();
setPage(1); setPage(1);
setSubmittedQuery(query.trim()); setSubmittedQuery(query.trim());
setSubmittedAccessPurpose(accessPurpose.trim());
} }
return ( return (
<main className="records-page" data-help-context-id="records.workspace"> <main className="records-page" data-help-context-id="records.workspace">
<div className="records-shell"> <WorkspaceFrame className="records-shell" label="Records workspace" interfaceId="records.workspace" helpContextId="records.workspace" helpModuleId="records">
<div className="records-toolbar"> <WorkspaceActionBar
<Button scope="workspace"
variant="collection"
refreshable
reloadAction={{ onReload: reload, loading, label: "Reload records" }}
className="records-toolbar"
createAction={<Button
type="button" type="button"
variant="primary" variant="primary"
onClick={() => setCreateOpen(true)} onClick={() => setCreateOpen(true)}
@@ -243,18 +263,15 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
> >
<Plus size={16} aria-hidden="true" /> <Plus size={16} aria-hidden="true" />
New record New record
</Button> </Button>}
<Button type="button" variant="ghost" onClick={reload} disabledReason={loading ? "Records are already loading." : undefined}> contextActions={<>
<RefreshCw size={16} aria-hidden="true" /> {canAdmin && (
Refresh
</Button>
{canAdmin && (
<Button type="button" variant="ghost" onClick={() => setCatalogOpen(true)} helpContextId="records.catalog.admin"> <Button type="button" variant="ghost" onClick={() => setCatalogOpen(true)} helpContextId="records.catalog.admin">
<Settings2 size={16} aria-hidden="true" /> <Settings2 size={16} aria-hidden="true" />
Catalog Catalog
</Button> </Button>
)} )}
<form className="records-search" onSubmit={submitSearch}> <FilterBar as="form" surface="control" wrap="never" width="compact" className="records-search" onSubmit={submitSearch}>
<Search size={17} aria-hidden="true" /> <Search size={17} aria-hidden="true" />
<input <input
value={query} value={query}
@@ -262,18 +279,25 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
aria-label="Search records" aria-label="Search records"
placeholder="Search records" placeholder="Search records"
/> />
</form> <input
<span className="records-result-count"> value={accessPurpose}
onChange={(event) => setAccessPurpose(event.target.value)}
aria-label="Access purpose"
placeholder="Access purpose"
/>
</FilterBar>
<span className="records-result-count">
{i18nMessage("i18n:govoplan-records.record_count", { value0: total })} {i18nMessage("i18n:govoplan-records.record_count", { value0: total })}
</span> </span>
{!temporalIsDefault && ( {!temporalIsDefault && (
<StatusBadge <StatusBadge
status="warning" status="warning"
label={selection.validityMode === "all" ? "All valid-time data" : "Historical data"} label={selection.validityMode === "all" ? "All valid-time data" : "Historical data"}
/> />
)} )}
<DocumentationHelpLink reference={RECORDS_DOCUMENTATION} /> </>}
</div> helpAction={<DocumentationHelpLink reference={RECORDS_DOCUMENTATION} />}
/>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{filingPreset && {filingPreset &&
@@ -319,7 +343,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
</button> </button>
))} ))}
{!loading && catalog.file_plan.length === 0 && ( {!loading && catalog.file_plan.length === 0 && (
<p className="records-empty-note">No file-plan nodes are configured.</p> <StatePanel size="inline" description="No file-plan nodes are configured." />
)} )}
</PageScrollViewport> </PageScrollViewport>
</aside> </aside>
@@ -360,10 +384,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
{detailLoading && <LoadingIndicator label="Loading record" />} {detailLoading && <LoadingIndicator label="Loading record" />}
{detailError && <DismissibleAlert tone="danger" resetKey={detailError}>{detailError}</DismissibleAlert>} {detailError && <DismissibleAlert tone="danger" resetKey={detailError}>{detailError}</DismissibleAlert>}
{!detailLoading && !detailError && !detail && ( {!detailLoading && !detailError && !detail && (
<div className="records-detail-empty"> <StatePanel size="fill" icon={<Archive size={28} />} title="Records" description="Select a record to inspect its contents and chronology." />
<Archive size={28} aria-hidden="true" />
<p>Select a record to inspect its contents and chronology.</p>
</div>
)} )}
{!detailLoading && detail && ( {!detailLoading && detail && (
<RecordDetailPanel <RecordDetailPanel
@@ -373,26 +394,33 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
archiveProviders={archiveProviders} archiveProviders={archiveProviders}
canWrite={canWrite} canWrite={canWrite}
canAdmin={canAdmin} canAdmin={canAdmin}
accessPurpose={submittedAccessPurpose}
hasSources={sources.length > 0} hasSources={sources.length > 0}
onEdit={() => setEditOpen(true)} onEdit={() => setEditOpen(true)}
onFile={() => setFileOpen(true)} onFile={() => setFileOpen(true)}
onManageAccess={() => setAccessGrantsOpen(true)}
onChanged={reload} onChanged={reload}
/> />
)} )}
</section> </section>
</div> </div>
</div> </WorkspaceFrame>
<RecordDialog <RecordDialog
open={createOpen} open={createOpen}
mode="create" mode="create"
settings={settings} settings={settings}
catalog={catalog} catalog={catalog}
canAdmin={canAdmin}
onClose={() => setCreateOpen(false)} onClose={() => setCreateOpen(false)}
onSaved={(saved) => { onSaved={(saved) => {
setCreateOpen(false); setCreateOpen(false);
setSelectedNodeId(""); setSelectedNodeId("");
setSelectedRecordId(saved.record_id); setSelectedRecordId(saved.record_id);
if (saved.access_mode === "restricted") {
setAccessPurpose(saved.purpose);
setSubmittedAccessPurpose(saved.purpose);
}
reload(); reload();
}} }}
/> />
@@ -401,6 +429,7 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
mode="edit" mode="edit"
settings={settings} settings={settings}
catalog={catalog} catalog={catalog}
canAdmin={canAdmin}
record={detail?.record ?? null} record={detail?.record ?? null}
onClose={() => setEditOpen(false)} onClose={() => setEditOpen(false)}
onSaved={(saved) => { onSaved={(saved) => {
@@ -433,6 +462,14 @@ export default function RecordsPage({ settings, auth }: PlatformRouteContext) {
reload(); reload();
}} }}
/> />
<RecordAccessGrantsDialog
open={accessGrantsOpen}
settings={settings}
record={detail?.record ?? null}
managementPurpose={submittedAccessPurpose}
onClose={() => setAccessGrantsOpen(false)}
onChanged={reload}
/>
</main> </main>
); );
} }
@@ -444,9 +481,11 @@ function RecordDetailPanel({
archiveProviders, archiveProviders,
canWrite, canWrite,
canAdmin, canAdmin,
accessPurpose,
hasSources, hasSources,
onEdit, onEdit,
onFile, onFile,
onManageAccess,
onChanged onChanged
}: { }: {
detail: RecordDetail; detail: RecordDetail;
@@ -455,12 +494,15 @@ function RecordDetailPanel({
archiveProviders: RecordArchiveProvider[]; archiveProviders: RecordArchiveProvider[];
canWrite: boolean; canWrite: boolean;
canAdmin: boolean; canAdmin: boolean;
accessPurpose: string;
hasSources: boolean; hasSources: boolean;
onEdit: () => void; onEdit: () => void;
onFile: () => void; onFile: () => void;
onManageAccess: () => void;
onChanged: () => void; onChanged: () => void;
}) { }) {
const record = detail.record; const record = detail.record;
const governanceContext = recordContextFacts(record.institutional_context);
return ( return (
<PageScrollViewport className="records-detail-scroll"> <PageScrollViewport className="records-detail-scroll">
<div className="records-detail-header"> <div className="records-detail-header">
@@ -469,6 +511,16 @@ function RecordDetailPanel({
<h1>{record.title}</h1> <h1>{record.title}</h1>
</div> </div>
<div className="records-detail-actions"> <div className="records-detail-actions">
{record.access_mode === "restricted" && canAdmin && (
<Button
type="button"
variant="ghost"
onClick={onManageAccess}
disabledReason={!accessPurpose ? "Enter the management purpose in the access-purpose filter first." : undefined}
>
Manage access
</Button>
)}
<Button type="button" variant="ghost" onClick={onEdit} disabledReason={!canWrite ? "Your account may not revise records." : undefined}> <Button type="button" variant="ghost" onClick={onEdit} disabledReason={!canWrite ? "Your account may not revise records." : undefined}>
<Pencil size={16} aria-hidden="true" /> <Pencil size={16} aria-hidden="true" />
Edit Edit
@@ -493,6 +545,17 @@ function RecordDetailPanel({
{record.description && <p className="records-description">{record.description}</p>} {record.description && <p className="records-description">{record.description}</p>}
{governanceContext.length > 0 && (
<section className="records-detail-section" data-help-context-id="records.governance-context">
<div className="records-section-heading"><h2>Governance context</h2></div>
<dl className="records-context-facts">
{governanceContext.map(([label, value]) => (
<div key={label}><dt>{label}</dt><dd>{value}</dd></div>
))}
</dl>
</section>
)}
<RecordLifecyclePanel <RecordLifecyclePanel
detail={detail} detail={detail}
archiveProviders={archiveProviders} archiveProviders={archiveProviders}
@@ -508,7 +571,7 @@ function RecordDetailPanel({
<span>{detail.items.length}</span> <span>{detail.items.length}</span>
</div> </div>
{detail.items.length === 0 ? ( {detail.items.length === 0 ? (
<p className="records-empty-note">No items have been filed in this temporal view.</p> <StatePanel size="inline" description="No items have been filed in this temporal view." />
) : ( ) : (
<div className="records-item-list"> <div className="records-item-list">
{detail.items.map((item) => ( {detail.items.map((item) => (
@@ -522,6 +585,15 @@ function RecordDetailPanel({
<time>{formatDateTime(item.filed_at)}</time> <time>{formatDateTime(item.filed_at)}</time>
</div> </div>
<p>{item.filing_reason}</p> <p>{item.filing_reason}</p>
<dl className="records-item-evidence">
<div><dt>Authority</dt><dd>{humanize(item.authority_mode)}</dd></div>
<div><dt>Relationship</dt><dd>{humanize(item.relationship)}</dd></div>
<div><dt>Purpose</dt><dd>{item.purpose}</dd></div>
<div><dt>Source recorded</dt><dd>{formatDateTime(item.source_recorded_at)}</dd></div>
{typeof item.source_metadata.evidence_role === "string" && (
<div><dt>Evidence role</dt><dd>{humanize(item.source_metadata.evidence_role)}</dd></div>
)}
</dl>
</div> </div>
))} ))}
</div> </div>
@@ -552,6 +624,11 @@ function RecordDetailPanel({
<p>{detail.access_explanation.reason}</p> <p>{detail.access_explanation.reason}</p>
<dl> <dl>
<div><dt>Record purpose</dt><dd>{record.purpose}</dd></div> <div><dt>Record purpose</dt><dd>{record.purpose}</dd></div>
<div><dt>Access mode</dt><dd>{humanize(record.access_mode)}</dd></div>
<div><dt>Evaluated purpose</dt><dd>{detail.access_explanation.purpose || "Tenant access"}</dd></div>
{detail.access_explanation.grant_subject_type && (
<div><dt>Matched grant</dt><dd>{humanize(detail.access_explanation.grant_subject_type)}</dd></div>
)}
<div><dt>Authorization</dt><dd>{detail.access_explanation.current_authorization ? "Current authorization applied" : "Not evaluated"}</dd></div> <div><dt>Authorization</dt><dd>{detail.access_explanation.current_authorization ? "Current authorization applied" : "Not evaluated"}</dd></div>
</dl> </dl>
{detail.access_explanation.limitations.map((limitation) => ( {detail.access_explanation.limitations.map((limitation) => (
@@ -567,6 +644,7 @@ function RecordDialog({
mode, mode,
settings, settings,
catalog, catalog,
canAdmin,
record, record,
onClose, onClose,
onSaved onSaved
@@ -575,6 +653,7 @@ function RecordDialog({
mode: "create" | "edit"; mode: "create" | "edit";
settings: PlatformRouteContext["settings"]; settings: PlatformRouteContext["settings"];
catalog: RecordCatalog; catalog: RecordCatalog;
canAdmin: boolean;
record?: RecordEntry | null; record?: RecordEntry | null;
onClose: () => void; onClose: () => void;
onSaved: (record: RecordEntry) => void; onSaved: (record: RecordEntry) => void;
@@ -587,6 +666,8 @@ function RecordDialog({
const [state, setState] = useState<"planned" | "open">("open"); const [state, setState] = useState<"planned" | "open">("open");
const [classification, setClassification] = useState(""); const [classification, setClassification] = useState("");
const [purpose, setPurpose] = useState(""); const [purpose, setPurpose] = useState("");
const [accessMode, setAccessMode] = useState<"tenant" | "restricted">("tenant");
const [initialPurposes, setInitialPurposes] = useState("");
const [changeReason, setChangeReason] = useState(""); const [changeReason, setChangeReason] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -600,15 +681,22 @@ function RecordDialog({
setState(record?.state === "planned" ? "planned" : "open"); setState(record?.state === "planned" ? "planned" : "open");
setClassification(record?.classification ?? ""); setClassification(record?.classification ?? "");
setPurpose(record?.purpose ?? ""); setPurpose(record?.purpose ?? "");
setAccessMode(record?.access_mode ?? firstClass?.access_mode ?? "tenant");
setInitialPurposes(record?.access_mode === "restricted" ? record.purpose : "");
setChangeReason(""); setChangeReason("");
setError(""); setError("");
}, [firstClass?.class_id, open, record]); }, [firstClass?.class_id, open, record]);
const selectedClass = catalog.classes.find((item) => item.class_id === classId); const selectedClass = catalog.classes.find((item) => item.class_id === classId);
const effectiveAccessMode = selectedClass?.access_mode === "restricted" ? "restricted" : accessMode;
const needsInitialPurposes = effectiveAccessMode === "restricted" &&
(mode === "create" || record?.access_mode !== "restricted");
const parsedInitialPurposes = splitValues(initialPurposes);
const restrictedPurposeMissing = needsInitialPurposes && !parsedInitialPurposes.includes(purpose.trim());
async function submit(event: FormEvent) { async function submit(event: FormEvent) {
event.preventDefault(); event.preventDefault();
if (!selectedClass || !title.trim() || !purpose.trim() || !changeReason.trim() || (mode === "create" && !recordNumber.trim())) return; if (!selectedClass || !title.trim() || !purpose.trim() || !changeReason.trim() || restrictedPurposeMissing || (mode === "create" && !recordNumber.trim())) return;
setSaving(true); setSaving(true);
setError(""); setError("");
try { try {
@@ -620,6 +708,10 @@ function RecordDialog({
...(mode === "create" || record?.state === "planned" || record?.state === "open" ? { state } : {}), ...(mode === "create" || record?.state === "planned" || record?.state === "open" ? { state } : {}),
classification: classification.trim() || null, classification: classification.trim() || null,
purpose: purpose.trim(), purpose: purpose.trim(),
...(mode === "create" || effectiveAccessMode !== record?.access_mode
? { access_mode: effectiveAccessMode }
: {}),
...(needsInitialPurposes ? { initial_purposes: parsedInitialPurposes } : {}),
recorded_at: new Date().toISOString(), recorded_at: new Date().toISOString(),
change_reason: changeReason.trim(), change_reason: changeReason.trim(),
idempotency_key: randomId() idempotency_key: randomId()
@@ -629,7 +721,6 @@ function RecordDialog({
...common, ...common,
record_number: recordNumber.trim(), record_number: recordNumber.trim(),
source_authority_mode: "native_authoritative", source_authority_mode: "native_authoritative",
access_mode: "tenant",
valid_from: new Date().toISOString(), valid_from: new Date().toISOString(),
institutional_context: {} institutional_context: {}
}) })
@@ -657,14 +748,14 @@ function RecordDialog({
footer={ footer={
<> <>
<Button type="button" variant="ghost" onClick={onClose} disabled={saving}>Cancel</Button> <Button type="button" variant="ghost" onClick={onClose} disabled={saving}>Cancel</Button>
<Button type="submit" form="records-record-form" variant="primary" disabledReason={saving ? "The record is being saved." : !selectedClass || !title.trim() || !purpose.trim() || !changeReason.trim() || (mode === "create" && !recordNumber.trim()) ? "Complete all required record fields." : undefined}> <Button type="submit" form="records-record-form" variant="primary" disabledReason={saving ? "The record is being saved." : restrictedPurposeMissing ? "Include the action purpose in the initial allowed purposes." : !selectedClass || !title.trim() || !purpose.trim() || !changeReason.trim() || (mode === "create" && !recordNumber.trim()) ? "Complete all required record fields." : undefined}>
{saving ? "Saving" : "Save record"} {saving ? "Saving" : "Save record"}
</Button> </Button>
</> </>
} }
> >
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<form id="records-record-form" className="records-dialog-form" onSubmit={submit}> <DialogForm id="records-record-form" className="records-dialog-form" onSubmit={submit}>
<FormField label="Record number" helpContextId="records.field.record-number"> <FormField label="Record number" helpContextId="records.field.record-number">
<input value={recordNumber} onChange={(event) => setRecordNumber(event.target.value)} disabled={mode === "edit"} required /> <input value={recordNumber} onChange={(event) => setRecordNumber(event.target.value)} disabled={mode === "edit"} required />
</FormField> </FormField>
@@ -692,13 +783,34 @@ function RecordDialog({
<FormField label="Purpose" documentation={RECORDS_FIELD_DOCUMENTATION.purpose}> <FormField label="Purpose" documentation={RECORDS_FIELD_DOCUMENTATION.purpose}>
<input value={purpose} onChange={(event) => setPurpose(event.target.value)} required /> <input value={purpose} onChange={(event) => setPurpose(event.target.value)} required />
</FormField> </FormField>
<FormField label="Access mode" helpContextId="records.field.access-mode">
<select
value={effectiveAccessMode}
onChange={(event) => setAccessMode(event.target.value as "tenant" | "restricted")}
disabled={selectedClass?.access_mode === "restricted" || (mode === "edit" && !canAdmin)}
>
<option value="tenant">Tenant-wide permission</option>
<option value="restricted">Purpose-bound grants</option>
</select>
</FormField>
{needsInitialPurposes && (
<FormField label="Initial allowed purposes" helpContextId="records.field.initial-purposes">
<textarea
value={initialPurposes}
onChange={(event) => setInitialPurposes(event.target.value)}
rows={3}
placeholder="One purpose per line"
required
/>
</FormField>
)}
<FormField label="Description" helpContextId="records.field.description"> <FormField label="Description" helpContextId="records.field.description">
<textarea value={description} onChange={(event) => setDescription(event.target.value)} rows={4} /> <textarea value={description} onChange={(event) => setDescription(event.target.value)} rows={4} />
</FormField> </FormField>
<FormField label="Change reason" helpContextId="records.field.change-reason"> <FormField label="Change reason" helpContextId="records.field.change-reason">
<textarea value={changeReason} onChange={(event) => setChangeReason(event.target.value)} rows={3} required /> <textarea value={changeReason} onChange={(event) => setChangeReason(event.target.value)} rows={3} required />
</FormField> </FormField>
</form> </DialogForm>
</Dialog> </Dialog>
); );
} }
@@ -810,7 +922,7 @@ function FilingDialog({
<DismissibleAlert tone="info" dismissible={false} compact> <DismissibleAlert tone="info" dismissible={false} compact>
The source module verifies your current access and resolves this exact revision before Records stores the reference. The source module verifies your current access and resolves this exact revision before Records stores the reference.
</DismissibleAlert> </DismissibleAlert>
<form id="records-filing-form" className="records-dialog-form" onSubmit={submit}> <DialogForm id="records-filing-form" className="records-dialog-form" onSubmit={submit}>
<FormField label="Source module" helpContextId="records.field.source-module"> <FormField label="Source module" helpContextId="records.field.source-module">
<select value={sourceModule} onChange={(event) => setSourceModule(event.target.value)} required> <select value={sourceModule} onChange={(event) => setSourceModule(event.target.value)} required>
{providers.map((item) => <option key={item.source_module} value={item.source_module}>{humanize(item.source_module)}</option>)} {providers.map((item) => <option key={item.source_module} value={item.source_module}>{humanize(item.source_module)}</option>)}
@@ -839,7 +951,7 @@ function FilingDialog({
<FormField label="Filing reason" documentation={RECORDS_FIELD_DOCUMENTATION.filingReason}> <FormField label="Filing reason" documentation={RECORDS_FIELD_DOCUMENTATION.filingReason}>
<textarea value={filingReason} onChange={(event) => setFilingReason(event.target.value)} rows={4} required /> <textarea value={filingReason} onChange={(event) => setFilingReason(event.target.value)} rows={4} required />
</FormField> </FormField>
</form> </DialogForm>
</Dialog> </Dialog>
); );
} }
@@ -879,10 +991,35 @@ function formatBytes(value?: number | null): string {
return `${(value / 1024 / 1024).toFixed(1)} MiB`; return `${(value / 1024 / 1024).toFixed(1)} MiB`;
} }
function recordContextFacts(context: Record<string, unknown>): Array<[string, string]> {
const fields: Array<[string, string]> = [
["Service", "service_id"],
["Case", "case_id"],
["Applicant", "applicant_party_id"],
["Representative", "representative_party_id"],
["Representation", "representation_id"],
["Authority", "authority_id"],
["Responsible function", "responsible_function_id"],
["Mandate", "mandate_id"],
["Legal basis", "legal_basis"],
["Retention policy", "retention_policy_ref"]
];
return fields.flatMap(([label, key]) => {
const value = context[key];
if (typeof value === "string" && value.trim()) return [[label, value]];
if (Array.isArray(value) && value.length > 0) return [[label, value.map(String).join(", ")]];
return [];
});
}
function humanize(value: string): string { function humanize(value: string): string {
return value.replace(/[_:.\-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); return value.replace(/[_:.\-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
} }
function splitValues(value: string): string[] {
return Array.from(new Set(value.split(/[\n,]/).map((item) => item.trim()).filter(Boolean)));
}
function randomId(): string { function randomId(): string {
return typeof crypto !== "undefined" && "randomUUID" in crypto return typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID() ? crypto.randomUUID()
+116 -2
View File
@@ -41,6 +41,16 @@ const en = {
"Retention input": "Retention input", "Retention input": "Retention input",
"Not classified": "Not classified", "Not classified": "Not classified",
"Not configured": "Not configured", "Not configured": "Not configured",
"Governance context": "Governance context",
"Service": "Service",
"Case": "Case",
"Applicant": "Applicant",
"Representative": "Representative",
"Representation": "Representation",
"Responsible function": "Responsible function",
"Mandate": "Mandate",
"Legal basis": "Legal basis",
"Retention policy": "Retention policy",
"Contents": "Contents", "Contents": "Contents",
"No items have been filed in this temporal view.": "No items have been filed in this temporal view.", "No items have been filed in this temporal view.": "No items have been filed in this temporal view.",
"Chronology": "Chronology", "Chronology": "Chronology",
@@ -66,6 +76,9 @@ const en = {
"Source object ID": "Source object ID", "Source object ID": "Source object ID",
"Exact source revision": "Exact source revision", "Exact source revision": "Exact source revision",
"Filing reason": "Filing reason", "Filing reason": "Filing reason",
"Relationship": "Relationship",
"Source recorded": "Source recorded",
"Evidence role": "Evidence role",
"Not set": "Not set", "Not set": "Not set",
"Size unavailable": "Size unavailable", "Size unavailable": "Size unavailable",
"Your account may view records but may not create them.": "Your account may view records but may not create them.", "Your account may view records but may not create them.": "Your account may view records but may not create them.",
@@ -121,6 +134,8 @@ const en = {
"Disposition": "Disposition", "Disposition": "Disposition",
"Transfer package": "Transfer package", "Transfer package": "Transfer package",
"Simulation only; no archival custody.": "Simulation only; no archival custody.", "Simulation only; no archival custody.": "Simulation only; no archival custody.",
"Archive custody was transferred.": "Archive custody was transferred.",
"Archive custody was not transferred.": "Archive custody was not transferred.",
"Volume label": "Volume label", "Volume label": "Volume label",
"Authority": "Authority", "Authority": "Authority",
"Explicit retention trigger": "Explicit retention trigger", "Explicit retention trigger": "Explicit retention trigger",
@@ -151,7 +166,49 @@ const en = {
"Enter a reason for this action.": "Enter a reason for this action.", "Enter a reason for this action.": "Enter a reason for this action.",
"Enter the hold authority.": "Enter the hold authority.", "Enter the hold authority.": "Enter the hold authority.",
"Enter a volume label.": "Enter a volume label.", "Enter a volume label.": "Enter a volume label.",
"Select an available archive profile.": "Select an available archive profile." "Select an available archive profile.": "Select an available archive profile.",
"Access purpose": "Access purpose",
"Manage access": "Manage access",
"Enter the management purpose in the access-purpose filter first.": "Enter the management purpose in the access-purpose filter first.",
"Access mode": "Access mode",
"Evaluated purpose": "Evaluated purpose",
"Tenant access": "Tenant access",
"Matched grant": "Matched grant",
"Tenant-wide permission": "Tenant-wide permission",
"Purpose-bound grants": "Purpose-bound grants",
"Initial allowed purposes": "Initial allowed purposes",
"One purpose per line": "One purpose per line",
"Include the action purpose in the initial allowed purposes.": "Include the action purpose in the initial allowed purposes.",
"Restricted record access": "Restricted record access",
"Management purpose:": "Management purpose:",
"Not supplied": "Not supplied",
"Loading access grants": "Loading access grants",
"No current access grants are available.": "No current access grants are available.",
"Add access grant": "Add access grant",
"Subject type": "Subject type",
"Subject identifier": "Subject identifier",
"Actions": "Actions",
"Allowed purposes": "Allowed purposes",
"Valid until": "Valid until",
"Read": "Read",
"Write": "Write",
"Manage": "Manage",
"Account": "Account",
"Membership": "Membership",
"Group": "Group",
"Role": "Role",
"Function assignment": "Function assignment",
"Delegation": "Delegation",
"Revoke": "Revoke",
"Add grant": "Add grant",
"The restricted access grants could not be loaded.": "The restricted access grants could not be loaded.",
"The restricted access grant could not be created.": "The restricted access grant could not be created.",
"The restricted access grant could not be revoked.": "The restricted access grant could not be revoked.",
"The grant is being saved.": "The grant is being saved.",
"Complete the subject, action, purpose, and reason.": "Complete the subject, action, purpose, and reason.",
"Grants are effective-dated and match an exact subject, action, and declared purpose. Current grants also govern historical record views.": "Grants are effective-dated and match an exact subject, action, and declared purpose. Current grants also govern historical record views.",
"Active": "Active",
"Revoked": "Revoked"
} as const; } as const;
const de: Record<keyof typeof en, string> = { const de: Record<keyof typeof en, string> = {
@@ -194,6 +251,16 @@ const de: Record<keyof typeof en, string> = {
"Retention input": "Aufbewahrungsvorgabe", "Retention input": "Aufbewahrungsvorgabe",
"Not classified": "Nicht klassifiziert", "Not classified": "Nicht klassifiziert",
"Not configured": "Nicht konfiguriert", "Not configured": "Nicht konfiguriert",
"Governance context": "Governance-Kontext",
"Service": "Leistung",
"Case": "Vorgang",
"Applicant": "Antragstellende Person",
"Representative": "Vertretende Person",
"Representation": "Vertretung",
"Responsible function": "Zuständige Funktion",
"Mandate": "Mandat",
"Legal basis": "Rechtsgrundlage",
"Retention policy": "Aufbewahrungsrichtlinie",
"Contents": "Inhalt", "Contents": "Inhalt",
"No items have been filed in this temporal view.": "In dieser temporalen Ansicht sind keine Objekte veraktet.", "No items have been filed in this temporal view.": "In dieser temporalen Ansicht sind keine Objekte veraktet.",
"Chronology": "Chronologie", "Chronology": "Chronologie",
@@ -219,6 +286,9 @@ const de: Record<keyof typeof en, string> = {
"Source object ID": "ID des Quellobjekts", "Source object ID": "ID des Quellobjekts",
"Exact source revision": "Exakte Quellrevision", "Exact source revision": "Exakte Quellrevision",
"Filing reason": "Veraktungsbegründung", "Filing reason": "Veraktungsbegründung",
"Relationship": "Beziehung",
"Source recorded": "Quelle erfasst am",
"Evidence role": "Nachweisrolle",
"Not set": "Nicht gesetzt", "Not set": "Nicht gesetzt",
"Size unavailable": "Größe nicht verfügbar", "Size unavailable": "Größe nicht verfügbar",
"Your account may view records but may not create them.": "Ihr Konto darf Akten einsehen, aber nicht anlegen.", "Your account may view records but may not create them.": "Ihr Konto darf Akten einsehen, aber nicht anlegen.",
@@ -274,6 +344,8 @@ const de: Record<keyof typeof en, string> = {
"Disposition": "Aussonderung", "Disposition": "Aussonderung",
"Transfer package": "Übergabepaket", "Transfer package": "Übergabepaket",
"Simulation only; no archival custody.": "Nur Simulation; keine Archivverwahrung.", "Simulation only; no archival custody.": "Nur Simulation; keine Archivverwahrung.",
"Archive custody was transferred.": "Die Archivverwahrung wurde übertragen.",
"Archive custody was not transferred.": "Die Archivverwahrung wurde nicht übertragen.",
"Volume label": "Bandbezeichnung", "Volume label": "Bandbezeichnung",
"Authority": "Anordnende Stelle", "Authority": "Anordnende Stelle",
"Explicit retention trigger": "Expliziter Aufbewahrungsbeginn", "Explicit retention trigger": "Expliziter Aufbewahrungsbeginn",
@@ -304,7 +376,49 @@ const de: Record<keyof typeof en, string> = {
"Enter a reason for this action.": "Geben Sie eine Begründung für diese Aktion an.", "Enter a reason for this action.": "Geben Sie eine Begründung für diese Aktion an.",
"Enter the hold authority.": "Geben Sie die anordnende Stelle der Sperre an.", "Enter the hold authority.": "Geben Sie die anordnende Stelle der Sperre an.",
"Enter a volume label.": "Geben Sie eine Bandbezeichnung an.", "Enter a volume label.": "Geben Sie eine Bandbezeichnung an.",
"Select an available archive profile.": "Wählen Sie ein verfügbares Archivprofil." "Select an available archive profile.": "Wählen Sie ein verfügbares Archivprofil.",
"Access purpose": "Zugriffszweck",
"Manage access": "Zugriff verwalten",
"Enter the management purpose in the access-purpose filter first.": "Geben Sie zuerst den Verwaltungszweck im Zugriffszweckfilter an.",
"Access mode": "Zugriffsmodus",
"Evaluated purpose": "Geprüfter Zweck",
"Tenant access": "Mandantenzugriff",
"Matched grant": "Passende Freigabe",
"Tenant-wide permission": "Mandantenweite Berechtigung",
"Purpose-bound grants": "Zweckgebundene Freigaben",
"Initial allowed purposes": "Anfänglich erlaubte Zwecke",
"One purpose per line": "Ein Zweck pro Zeile",
"Include the action purpose in the initial allowed purposes.": "Nehmen Sie den Aktionszweck in die anfänglich erlaubten Zwecke auf.",
"Restricted record access": "Zugriff auf besonders geschützte Akten",
"Management purpose:": "Verwaltungszweck:",
"Not supplied": "Nicht angegeben",
"Loading access grants": "Zugriffsfreigaben werden geladen",
"No current access grants are available.": "Es sind keine aktuellen Zugriffsfreigaben vorhanden.",
"Add access grant": "Zugriffsfreigabe hinzufügen",
"Subject type": "Subjekttyp",
"Subject identifier": "Subjektkennung",
"Actions": "Aktionen",
"Allowed purposes": "Erlaubte Zwecke",
"Valid until": "Gültig bis",
"Read": "Lesen",
"Write": "Schreiben",
"Manage": "Verwalten",
"Account": "Konto",
"Membership": "Mitgliedschaft",
"Group": "Gruppe",
"Role": "Rolle",
"Function assignment": "Funktionszuordnung",
"Delegation": "Delegation",
"Revoke": "Entziehen",
"Add grant": "Freigabe hinzufügen",
"The restricted access grants could not be loaded.": "Die Freigaben für besonders geschützte Akten konnten nicht geladen werden.",
"The restricted access grant could not be created.": "Die Freigabe für die besonders geschützte Akte konnte nicht angelegt werden.",
"The restricted access grant could not be revoked.": "Die Freigabe für die besonders geschützte Akte konnte nicht entzogen werden.",
"The grant is being saved.": "Die Freigabe wird gespeichert.",
"Complete the subject, action, purpose, and reason.": "Vervollständigen Sie Subjekt, Aktion, Zweck und Begründung.",
"Grants are effective-dated and match an exact subject, action, and declared purpose. Current grants also govern historical record views.": "Freigaben sind zeitlich wirksam und gelten für ein eindeutiges Subjekt, eine Aktion und einen erklärten Zweck. Aktuelle Freigaben bestimmen auch den Zugriff auf historische Aktenansichten.",
"Active": "Aktiv",
"Revoked": "Entzogen"
}; };
export const generatedTranslations: PlatformTranslations = { en, de }; export const generatedTranslations: PlatformTranslations = { en, de };
+96 -49
View File
@@ -1,40 +1,14 @@
.records-page, .records-page {
.records-shell {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
.records-shell {
display: flex;
flex-direction: column;
background: var(--panel);
}
.records-toolbar {
display: flex;
align-items: center;
gap: 10px;
min-height: 58px;
padding: 9px 14px;
border-bottom: var(--border-line-dark);
background: var(--panel-header);
}
.records-search { .records-search {
display: flex; flex: 1 1 420px;
align-items: center;
gap: 8px;
width: min(420px, 100%);
margin-left: 8px; margin-left: 8px;
} }
.records-search input {
min-width: 130px;
height: 36px;
flex: 1;
}
.records-result-count { .records-result-count {
margin-left: auto; margin-left: auto;
color: var(--muted); color: var(--muted);
@@ -205,17 +179,6 @@
padding: 18px 20px 32px; padding: 18px 20px 32px;
} }
.records-detail-empty {
display: grid;
place-items: center;
align-content: center;
gap: 10px;
height: 100%;
color: var(--muted);
padding: 30px;
text-align: center;
}
.records-detail-header, .records-detail-header,
.records-detail-actions, .records-detail-actions,
.records-section-heading { .records-section-heading {
@@ -290,6 +253,42 @@
line-height: 1.55; line-height: 1.55;
} }
.records-context-facts,
.records-item-evidence {
display: grid;
margin: 0;
}
.records-context-facts {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--line);
}
.records-context-facts > div {
min-width: 0;
padding: 9px 10px;
background: var(--surface);
}
.records-context-facts dt,
.records-item-evidence dt {
color: var(--muted);
font-size: 12px;
}
.records-context-facts dd,
.records-item-evidence dd {
overflow-wrap: anywhere;
margin: 3px 0 0;
color: var(--text-strong);
font-size: var(--font-size-sm);
font-weight: 600;
}
.records-lifecycle-actions { .records-lifecycle-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -412,7 +411,7 @@
min-width: 24px; min-width: 24px;
height: 24px; height: 24px;
place-items: center; place-items: center;
border-radius: 50%; border-radius: var(--radius-round);
background: var(--surface-strong); background: var(--surface-strong);
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
@@ -461,6 +460,17 @@
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }
.records-item-evidence {
grid-column: 1 / -1;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
padding-top: 4px;
}
.records-item-evidence > div {
min-width: 0;
}
.records-chronology > div { .records-chronology > div {
position: relative; position: relative;
display: grid; display: grid;
@@ -487,7 +497,7 @@
align-self: start; align-self: start;
margin: 5px 0 0 3px; margin: 5px 0 0 3px;
border: 2px solid var(--accent); border: 2px solid var(--accent);
border-radius: 50%; border-radius: var(--radius-round);
background: var(--panel-soft); background: var(--panel-soft);
} }
@@ -529,12 +539,6 @@
font-weight: 700; font-weight: 700;
} }
.records-empty-note {
margin: 12px;
color: var(--muted);
font-size: var(--font-size-sm);
}
.records-dialog { .records-dialog {
width: min(760px, calc(100vw - 32px)); width: min(760px, calc(100vw - 32px));
} }
@@ -549,7 +553,44 @@
grid-column: 1 / -1; grid-column: 1 / -1;
} }
@media (max-width: 1260px) { .records-grant-list {
display: grid;
gap: 8px;
margin-block: 16px;
}
.records-grant-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 12px;
align-items: center;
padding: 12px;
border: var(--border-line);
border-radius: var(--radius-sm);
}
.records-grant-row > div,
.records-grant-actions {
display: flex;
gap: 8px;
}
.records-grant-row > div {
min-width: 0;
flex-direction: column;
}
.records-grant-actions {
flex-wrap: wrap;
}
.records-grant-actions label {
display: inline-flex;
gap: 6px;
align-items: center;
}
@media (max-width: 1280px) {
.records-workspace { .records-workspace {
grid-template-columns: minmax(190px, 220px) minmax(330px, 0.85fr) minmax(390px, 1fr); grid-template-columns: minmax(190px, 220px) minmax(330px, 0.85fr) minmax(390px, 1fr);
} }
@@ -585,7 +626,7 @@
} }
} }
@media (max-width: 640px) { @media (max-width: 680px) {
.records-workspace { .records-workspace {
display: flex; display: flex;
overflow-y: auto; overflow-y: auto;
@@ -607,6 +648,8 @@
} }
.records-facts, .records-facts,
.records-context-facts,
.records-item-evidence,
.records-lifecycle-evidence > dl, .records-lifecycle-evidence > dl,
.records-recovery-result dl, .records-recovery-result dl,
.records-access-explanation dl, .records-access-explanation dl,
@@ -617,4 +660,8 @@
.records-dialog-form > .form-field:has(textarea) { .records-dialog-form > .form-field:has(textarea) {
grid-column: auto; grid-column: auto;
} }
.records-grant-row {
grid-template-columns: 1fr;
}
} }