diff --git a/docs/EAKTE_ARCHITECTURE.md b/docs/EAKTE_ARCHITECTURE.md index edb4c6f..f179aab 100644 --- a/docs/EAKTE_ARCHITECTURE.md +++ b/docs/EAKTE_ARCHITECTURE.md @@ -46,11 +46,13 @@ attachment, case/representation, decision, delivery, and correction evidence; exercise closure, retention, hold, approval, and simulated transfer; and prove search and temporal reconstruction after database backup/restore. The remaining boundary is intentionally visible rather than implied: Records #7 -requires selection and target testing of an archive/xdomea endpoint and -conformance profile. Restricted per-record access grants also remain a -dedicated access-policy slice. Destruction is represented only as an approved -pending state; no content deletion or real archive effect is currently -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 @@ -137,9 +139,11 @@ correction path. filing independently. - The current security context always controls browsing, including historical views. -- Purpose-aware access may be narrower than ordinary read permission and can - require case assignment, represented function, mandate, legal basis, or - reason-for-access capture. +- Restricted access is narrower than ordinary module read permission. It + requires a current account, membership, group, role, function-assignment, or + 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, dataset materialization, external DMS object, physical item, or paper scan; it is not limited to Files. diff --git a/docs/RECORDS_DOMAIN_BOUNDARY.md b/docs/RECORDS_DOMAIN_BOUNDARY.md index bba8f6f..b5a6465 100644 --- a/docs/RECORDS_DOMAIN_BOUNDARY.md +++ b/docs/RECORDS_DOMAIN_BOUNDARY.md @@ -52,9 +52,12 @@ The native kernel currently provides: - 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 -archive adapter and any destructive effect remain deliberately unimplemented; +A target-tested archive adapter and any destructive effect remain deliberately unimplemented; approved destruction is only a pending lifecycle state. ## Data-subject requests @@ -67,9 +70,10 @@ 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, and transfer lifecycle metadata. It excludes source +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, digests, replay keys, launch URLs, approval identifiers, archive +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 @@ -78,8 +82,8 @@ hold, disposition, or transfer must use the governed eAkte lifecycle. ## Next Boundary Slice -Complete restricted access and one target-tested archive provider without -moving source-module ownership into Records. The executable reference journey +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. diff --git a/package.json b/package.json index a5008d3..dcee134 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/records", - "version": "0.1.22", + "version": "0.1.23", "private": true, "description": "GovOPlaN eAkte and institutional records module.", "type": "module", diff --git a/pyproject.toml b/pyproject.toml index d8d95bd..d61dda2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-records" -version = "0.1.22" +version = "0.1.23" description = "GovOPlaN eAkte and institutional records module." readme = "README.md" requires-python = ">=3.12" diff --git a/src/govoplan_records/backend/db/models.py b/src/govoplan_records/backend/db/models.py index 298c3e5..da71209 100644 --- a/src/govoplan_records/backend/db/models.py +++ b/src/govoplan_records/backend/db/models.py @@ -261,6 +261,70 @@ class RecordRevision(Base, TimestampMixin): 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): __tablename__ = "record_volume_revisions" __table_args__ = ( @@ -588,6 +652,7 @@ class RecordTransferPackageRevision(Base, TimestampMixin): __all__ = [ + "RecordAccessGrantRevision", "RecordChronologyEntry", "RecordClassRevision", "RecordFilePlanRevision", diff --git a/src/govoplan_records/backend/dsar_provider.py b/src/govoplan_records/backend/dsar_provider.py index d8a4679..10585f6 100644 --- a/src/govoplan_records/backend/dsar_provider.py +++ b/src/govoplan_records/backend/dsar_provider.py @@ -15,6 +15,7 @@ from govoplan_core.core.dsar import ( dsar_capability_name, ) from govoplan_records.backend.db.models import ( + RecordAccessGrantRevision, RecordChronologyEntry, RecordClassRevision, RecordDispositionRevision, @@ -398,6 +399,17 @@ def _record_actor_matches( is not None ): return True + if ( + session.query(RecordAccessGrantRevision.id) + .filter( + RecordAccessGrantRevision.tenant_id == tenant_id, + RecordAccessGrantRevision.record_id == record_id, + RecordAccessGrantRevision.subject_id.in_(actor_ids), + ) + .first() + is not None + ): + return True return False @@ -794,6 +806,12 @@ def _append_operator_attribution( "changed_record_transfer", "recorded_at", ), + ( + RecordAccessGrantRevision, + "changed_by", + "changed_record_access_grant", + "recorded_at", + ), ( RecordFilePlanRevision, "changed_by", @@ -856,6 +874,69 @@ def _append_operator_attribution( ) ) + subject_pairs = tuple( + pair + for pair in ( + ("account", selectors.account_id), + ("membership", selectors.membership_id), + ) + if pair[1] + ) + if not subject_pairs: + return + subject_query = session.query(RecordAccessGrantRevision).filter( + RecordAccessGrantRevision.tenant_id == tenant_id, + or_( + *( + (RecordAccessGrantRevision.subject_type == subject_type) + & (RecordAccessGrantRevision.subject_id == subject_id) + for subject_type, subject_id in subject_pairs + ) + ), + ) + if record_ids: + subject_query = subject_query.filter( + RecordAccessGrantRevision.record_id.in_(record_ids) + ) + subject_rows = ( + subject_query.order_by( + RecordAccessGrantRevision.recorded_at, + RecordAccessGrantRevision.id, + ) + .limit(_MAX_RECORDS + 1) + .all() + ) + if len(subject_rows) > _MAX_RECORDS: + raise ValueError("Records DSAR result limit exceeded; narrow the selectors.") + for row in subject_rows: + append( # type: ignore[operator] + _record( + "record_access_grant_revision", + row.id, + "restricted_record_access_evidence", + "Restricted record access grant", + { + "record_id": row.record_id, + "grant_id": row.grant_id, + "revision": row.revision, + "status": row.status, + "subject_type": row.subject_type, + "actions": list(row.actions), + "allowed_purposes": list(row.allowed_purposes), + "valid_from": _iso(row.valid_from), + "valid_to": _iso(row.valid_to), + "recorded_at": _iso(row.recorded_at), + "superseded_at": _iso(row.superseded_at), + }, + observed_at=row.recorded_at, + immutable=True, + retention_reason=( + "Restricted-record grant history is retained as access-control " + "and accountability evidence." + ), + ) + ) + def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None: references = subject.external_references diff --git a/src/govoplan_records/backend/manifest.py b/src/govoplan_records/backend/manifest.py index 2a65688..aef7951 100644 --- a/src/govoplan_records/backend/manifest.py +++ b/src/govoplan_records/backend/manifest.py @@ -53,7 +53,7 @@ from govoplan_records.backend.archive import ( MODULE_ID = "records" MODULE_NAME = "Records" -MODULE_VERSION = "0.1.22" +MODULE_VERSION = "0.1.23" READ_SCOPE = "records:workspace:read" WRITE_SCOPE = "records:workspace:write" ADMIN_SCOPE = "records:workspace:admin" @@ -146,11 +146,21 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]: ) .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 { "records": records, "open_records": open_records, "active_holds": active_holds, "pending_dispositions": pending_dispositions, + "active_restricted_grants": active_restricted_grants, } @@ -199,8 +209,8 @@ DOCUMENTATION = ( 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 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, and transfer lifecycle metadata. Source content, snapshots, search text, opaque contexts and payloads, 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." + "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"), @@ -238,10 +248,10 @@ DOCUMENTATION = ( "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, bedeuten aber niemals, dass die " + "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 und Übergabe. Quellinhalt, Schnappschüsse, Suchtext, undurchsichtige Kontexte und Nutzdaten, " + "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." @@ -259,7 +269,8 @@ DOCUMENTATION = ( "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 " "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." + "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", documentation_types=("admin", "user"), @@ -284,7 +295,9 @@ DOCUMENTATION = ( "Quellmodule bleiben Eigentümer ihrer Inhalte. Bei der Veraktung wird erst nach aktueller " "Zugriffsprüfung durch das Quellmodul eine exakte Quellrevision festgehalten. Die temporale " "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." ), } }, @@ -309,6 +322,51 @@ 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( id="records.filing", title="Exact record filing", @@ -617,6 +675,7 @@ manifest = ModuleManifest( record_models.RecordHoldRevision, record_models.RecordItem, record_models.RecordVolumeRevision, + record_models.RecordAccessGrantRevision, record_models.RecordRevision, record_models.RecordIdentity, record_models.RecordClassRevision, @@ -632,6 +691,7 @@ manifest = ModuleManifest( persistent_table_uninstall_guard( record_models.RecordIdentity, record_models.RecordRevision, + record_models.RecordAccessGrantRevision, record_models.RecordItem, record_models.RecordChronologyEntry, record_models.RecordHoldRevision, @@ -668,12 +728,13 @@ manifest = ModuleManifest( ), ), purpose_aware_access=InformationGovernanceDimension( - adoption="partial", - object_types=("record", "record_item"), - evidence=("src/govoplan_records/backend/service.py",), - limitation=( - "Purposes are mandatory and preserved for record operations, but Policy-backed " - "object-level purpose constraints and restricted-record grants are not implemented yet." + adoption="enforced", + object_types=("record", "record_item", "record_access_grant"), + evidence=( + "src/govoplan_records/backend/service.py", + "src/govoplan_records/backend/search_source.py", + "tests/test_records.py", + "tests/test_search_source.py", ), ), retention=InformationGovernanceDimension( @@ -712,7 +773,6 @@ manifest = ModuleManifest( documentation_ref="docs/EAKTE_ARCHITECTURE.md", test_ref="tests/test_records.py", known_limits=( - "Restricted object grants are tracked separately.", "Archive packaging is native, but real target conformance and destructive effects remain external work.", ), supported_authority_modes=( diff --git a/src/govoplan_records/backend/migrations/versions/8a6c4e2f1b3d_record_access_grants.py b/src/govoplan_records/backend/migrations/versions/8a6c4e2f1b3d_record_access_grants.py new file mode 100644 index 0000000..b98f6da --- /dev/null +++ b/src/govoplan_records/backend/migrations/versions/8a6c4e2f1b3d_record_access_grants.py @@ -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") diff --git a/src/govoplan_records/backend/router.py b/src/govoplan_records/backend/router.py index 391afd2..b103441 100644 --- a/src/govoplan_records/backend/router.py +++ b/src/govoplan_records/backend/router.py @@ -15,6 +15,9 @@ from govoplan_records.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCO from govoplan_records.backend.schemas import ( FilePlanNodeWriteRequest, RecordAppraisalRequest, + RecordAccessGrantCreateRequest, + RecordAccessGrantListResponse, + RecordAccessGrantRevokeRequest, RecordArchiveProviderResponse, RecordCatalogResponse, 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), class_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), limit: int = Query(default=100, ge=1, le=200), session: Session = Depends(get_session), @@ -150,6 +154,7 @@ def create_router(registry: object | None = None) -> APIRouter: state=record_state, class_id=class_id, file_plan_node_id=file_plan_node_id, + purpose=purpose, offset=offset, limit=limit, ) @@ -181,6 +186,7 @@ def create_router(registry: object | None = None) -> APIRouter: def api_get_record( record_id: str, revision: int | None = Query(default=None, ge=1), + purpose: str | None = Query(default=None, max_length=255), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> RecordDetailResponse: @@ -188,7 +194,11 @@ def create_router(registry: object | None = None) -> APIRouter: try: return RecordDetailResponse( **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: @@ -197,17 +207,97 @@ def create_router(registry: object | None = None) -> APIRouter: @router.get("/{record_id}/recovery", response_model=RecordRecoveryStatusResponse) def api_record_recovery_status( record_id: str, + purpose: str = Query(min_length=1, max_length=255), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> RecordRecoveryStatusResponse: _require(principal, ADMIN_SCOPE) try: 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: 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]) def api_update_record( record_id: str, @@ -216,6 +306,8 @@ def create_router(registry: object | None = None) -> APIRouter: principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: _require(principal, WRITE_SCOPE) + if payload.access_mode is not None: + _require(principal, ADMIN_SCOPE) return _write( session, lambda: records.update_record( diff --git a/src/govoplan_records/backend/schemas.py b/src/govoplan_records/backend/schemas.py index b105eec..7e66c46 100644 --- a/src/govoplan_records/backend/schemas.py +++ b/src/govoplan_records/backend/schemas.py @@ -10,6 +10,16 @@ class StrictModel(BaseModel): 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): node_id: str = Field(min_length=1, max_length=255) code: str = Field(min_length=1, max_length=120) @@ -74,6 +84,7 @@ class RecordCreateRequest(StrictModel): "linked_reference", ] = "native_authoritative" 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) classification: str | None = Field(default=None, max_length=120) responsible_unit_id: str | None = Field(default=None, max_length=255) @@ -90,6 +101,7 @@ class RecordCreateRequest(StrictModel): def validate_interval(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") + _validate_purpose_values(self.initial_purposes, "initial_purposes") return self @@ -101,6 +113,7 @@ class RecordUpdateRequest(StrictModel): file_plan_node_id: str | None = Field(default=None, min_length=1, max_length=255) state: Literal["planned", "open"] | 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) classification: str | None = Field(default=None, max_length=120) responsible_unit_id: str | None = Field(default=None, max_length=255) @@ -116,6 +129,7 @@ class RecordUpdateRequest(StrictModel): def validate_interval(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") + _validate_purpose_values(self.initial_purposes, "initial_purposes") return self @@ -241,6 +255,51 @@ class RecordTransferDispatchRequest(StrictModel): 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): records: list[dict[str, Any]] total: int @@ -287,6 +346,9 @@ class RecordRecoveryStatusResponse(StrictModel): __all__ = [ "FilePlanNodeWriteRequest", "RecordAppraisalRequest", + "RecordAccessGrantCreateRequest", + "RecordAccessGrantListResponse", + "RecordAccessGrantRevokeRequest", "RecordArchiveProviderResponse", "RecordCatalogResponse", "RecordClassWriteRequest", diff --git a/src/govoplan_records/backend/search_source.py b/src/govoplan_records/backend/search_source.py index d81cae7..f78e5ae 100644 --- a/src/govoplan_records/backend/search_source.py +++ b/src/govoplan_records/backend/search_source.py @@ -16,6 +16,7 @@ from govoplan_core.core.search import ( SearchResourceType, ) from govoplan_records.backend.db.models import RecordIdentity, RecordRevision +from govoplan_records.backend.service import _eligible_record_ids PROVIDER_ID = "records.objects" @@ -97,15 +98,13 @@ class RecordsSearchSource: ] resource_ids = {request.reference.resource_id for request in eligible} available_ids = ( - set( - db.scalars( - select(RecordRevision.record_id).where( - RecordRevision.tenant_id == principal.tenant_id, - RecordRevision.record_id.in_(resource_ids), - RecordRevision.superseded_at.is_(None), - ) - ).all() - ) + _eligible_record_ids( + db, + principal, + tenant_id=principal.tenant_id, + action="read", + purpose="records.search", + ).intersection(resource_ids) if resource_ids else set() ) @@ -127,7 +126,9 @@ def _document(row: RecordRevision, identity: RecordIdentity) -> SearchDocument: resource_type=RESOURCE_TYPE, resource_id=row.record_id, 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], body=row.search_text[:200_000], keywords=tuple( diff --git a/src/govoplan_records/backend/service.py b/src/govoplan_records/backend/service.py index 2824b82..f0bd30c 100644 --- a/src/govoplan_records/backend/service.py +++ b/src/govoplan_records/backend/service.py @@ -39,6 +39,7 @@ from govoplan_core.core.recovery import ( from govoplan_core.core.temporal import current_temporal_data_context from govoplan_core.db.temporal import apply_temporal_revision_filter from govoplan_records.backend.db.models import ( + RecordAccessGrantRevision, RecordChronologyEntry, RecordClassRevision, RecordDispositionRevision, @@ -69,6 +70,17 @@ class RecordSourceUnavailableError(RecordStoreError): pass +_GRANT_SUBJECT_TYPES = { + "account", + "membership", + "group", + "role", + "function_assignment", + "delegation", +} +_GRANT_ACTIONS = {"read", "write", "manage"} + + class SqlRecordRegistry: def __init__(self, registry: object | None = None) -> None: self.registry = registry @@ -266,11 +278,13 @@ class SqlRecordRegistry: request_hash=request_hash, ) if replay is not None: + replay_purposes = _text_list(payload.get("initial_purposes")) return self.get_record( session, principal, record_id=replay.record_id, revision=replay.record_revision, + purpose=replay_purposes[0] if replay_purposes else None, )["record"] class_id = _text(payload, "class_id") node_id = _text(payload, "file_plan_node_id") @@ -283,9 +297,22 @@ class SqlRecordRegistry: ) recorded_at = _timestamp(payload.get("recorded_at"), "recorded_at") record_id = _optional_text(payload.get("record_id")) or str(uuid.uuid4()) - if _text(payload, "access_mode", default="tenant") != "tenant": + access_mode = _text(payload, "access_mode", default="tenant") + if record_class.access_mode == "restricted" and access_mode != "restricted": raise RecordStoreError( - "Restricted records require the object-grant slice and cannot be created yet." + "The selected record class requires restricted-record access." + ) + initial_purposes = _text_list(payload.get("initial_purposes")) + if access_mode == "restricted" and not initial_purposes: + raise RecordStoreError( + "Restricted records require at least one initial allowed purpose." + ) + if ( + access_mode == "restricted" + and _text(payload, "purpose") not in initial_purposes + ): + raise RecordStoreError( + "The creation purpose must be included in the initial allowed purposes." ) duplicate_number = ( session.query(RecordIdentity.id) @@ -319,7 +346,7 @@ class SqlRecordRegistry: source_authority_mode=_text( payload, "source_authority_mode", default="native_authoritative" ), - access_mode=_text(payload, "access_mode", default="tenant"), + access_mode=access_mode, purpose=_text(payload, "purpose"), classification=_optional_text(payload.get("classification")), responsible_unit_id=_optional_text(payload.get("responsible_unit_id")), @@ -351,6 +378,15 @@ class SqlRecordRegistry: institutional_context=row.institutional_context, payload={"change_reason": _text(payload, "change_reason")}, ) + if access_mode == "restricted": + _create_initial_access_grant( + session, + principal, + row=row, + purposes=initial_purposes, + recorded_at=recorded_at, + source_idempotency_key=_text(payload, "idempotency_key"), + ) session.flush() return _record_dict(row, identity) @@ -363,6 +399,24 @@ class SqlRecordRegistry: payload: Mapping[str, object], ) -> dict[str, Any]: tenant_id = _tenant(principal) + access_record = _required_current_record( + session, tenant_id=tenant_id, record_id=record_id, lock=True + ) + operation_purpose = _text(payload, "purpose") + requested_access_mode = _optional_text(payload.get("access_mode")) + access_action = ( + "manage" + if requested_access_mode is not None + and requested_access_mode != access_record.access_mode + else "write" + ) + _require_record_access( + session, + principal, + record=access_record, + action=access_action, + purpose=operation_purpose, + ) request_hash = _request_hash({"record_id": record_id, **payload}) replay = _replay_event( session, @@ -376,10 +430,9 @@ class SqlRecordRegistry: principal, record_id=record_id, revision=replay.record_revision, + purpose=operation_purpose, )["record"] - current = _current_record(session, tenant_id, record_id, lock=True) - if current is None: - raise RecordNotFoundError("Record not found.") + current = access_record expected = int(payload.get("expected_revision") or 0) if current.revision != expected: raise RecordConflictError( @@ -399,9 +452,18 @@ class SqlRecordRegistry: "Lifecycle state can only change through a governed lifecycle action." ) values = _revision_values(current, payload) - if values["access_mode"] != "tenant": + initial_purposes = _text_list(payload.get("initial_purposes")) + enabling_restricted = ( + current.access_mode != "restricted" + and values["access_mode"] == "restricted" + ) + if enabling_restricted and not initial_purposes: raise RecordStoreError( - "Restricted records require the object-grant slice and cannot be enabled yet." + "Enabling restricted access requires at least one initial allowed purpose." + ) + if enabling_restricted and operation_purpose not in initial_purposes: + raise RecordStoreError( + "The change purpose must be included in the initial allowed purposes." ) record_class = _current_class(session, tenant_id, values["class_id"]) if ( @@ -451,6 +513,15 @@ class SqlRecordRegistry: "previous_revision": current.revision, }, ) + if enabling_restricted: + _create_initial_access_grant( + session, + principal, + row=row, + purposes=initial_purposes, + recorded_at=recorded_at, + source_idempotency_key=_text(payload, "idempotency_key"), + ) session.flush() return _record_dict(row, identity) @@ -463,6 +534,7 @@ class SqlRecordRegistry: state: str | None = None, class_id: str | None = None, file_plan_node_id: str | None = None, + purpose: str | None = None, offset: int = 0, limit: int = 100, ) -> tuple[list[dict[str, Any]], int]: @@ -472,6 +544,16 @@ class SqlRecordRegistry: .join(RecordIdentity, RecordIdentity.id == RecordRevision.identity_id) .filter(RecordRevision.tenant_id == tenant_id) ) + eligible_record_ids = _eligible_record_ids( + session, + principal, + tenant_id=tenant_id, + action="read", + purpose=_optional_text(purpose), + ) + if not eligible_record_ids: + return [], 0 + statement = statement.filter(RecordRevision.record_id.in_(eligible_record_ids)) statement = apply_temporal_revision_filter(statement, RecordRevision) if query and query.strip(): pattern = f"%{query.strip().lower()}%" @@ -502,6 +584,7 @@ class SqlRecordRegistry: *, record_id: str, revision: int | None = None, + purpose: str | None = None, ) -> dict[str, Any]: tenant_id = _tenant(principal) query = session.query(RecordRevision).filter( @@ -517,6 +600,16 @@ class SqlRecordRegistry: raise RecordNotFoundError( "Record not found in the selected temporal context." ) + current = _current_record(session, tenant_id, record_id) + if current is None: + raise RecordNotFoundError("Record not found.") + grant = _require_record_access( + session, + principal, + record=current, + action="read", + purpose=_optional_text(purpose), + ) identity = session.get(RecordIdentity, row.identity_id) if identity is None: raise RecordStoreError("Record identity is missing.") @@ -544,18 +637,244 @@ class SqlRecordRegistry: ], "access_explanation": { "decision": "allowed", - "reason": "Current tenant and Records permission were evaluated for this read.", - "purpose": row.purpose, + "reason": ( + "Current tenant permission and a current purpose-bound object grant were evaluated for this read." + if grant is not None + else "Current tenant and Records permission were evaluated for this read." + ), + "purpose": _optional_text(purpose) or row.purpose, "current_authorization": True, "access_mode": row.access_mode, - "limitations": ( - ["Object-level restricted-record grants are not yet implemented."] - if row.access_mode == "restricted" - else [] - ), + "grant_id": grant.grant_id if grant is not None else None, + "grant_subject_type": grant.subject_type if grant is not None else None, + "limitations": [], }, } + def list_access_grants( + self, + session: Session, + principal: object, + *, + record_id: str, + purpose: str, + ) -> list[dict[str, Any]]: + tenant_id = _tenant(principal) + record = _required_current_record( + session, tenant_id=tenant_id, record_id=record_id + ) + _require_record_access( + session, + principal, + record=record, + action="manage", + purpose=purpose, + ) + return [ + _access_grant_dict(item) + for item in _record_access_grants( + session, tenant_id=tenant_id, record_id=record_id + ) + ] + + def create_access_grant( + self, + session: Session, + principal: object, + *, + record_id: str, + payload: Mapping[str, object], + ) -> dict[str, Any]: + tenant_id = _tenant(principal) + record = _required_current_record( + session, tenant_id=tenant_id, record_id=record_id, lock=True + ) + management_purpose = _text(payload, "purpose") + _require_record_access( + session, + principal, + record=record, + action="manage", + purpose=management_purpose, + ) + request_hash = _request_hash({"record_id": record_id, **payload}) + replay = _replay_by_key( + session, + RecordAccessGrantRevision, + tenant_id=tenant_id, + idempotency_key=_text(payload, "idempotency_key"), + request_hash=request_hash, + ) + if replay is not None: + return _access_grant_dict(replay) + subject_type = _text(payload, "subject_type") + if subject_type not in _GRANT_SUBJECT_TYPES: + raise RecordStoreError("Unsupported restricted-record grant subject type.") + actions = _text_list(payload.get("actions")) + if not actions or not set(actions).issubset(_GRANT_ACTIONS): + raise RecordStoreError( + "Restricted-record grants require supported actions." + ) + allowed_purposes = _text_list(payload.get("allowed_purposes")) + if not allowed_purposes: + raise RecordStoreError( + "Restricted-record grants require at least one allowed purpose." + ) + recorded_at = _timestamp(payload.get("recorded_at"), "recorded_at") + valid_from = ( + _optional_timestamp(payload.get("valid_from"), "valid_from") or recorded_at + ) + valid_to = _optional_timestamp(payload.get("valid_to"), "valid_to") + _validate_interval(valid_from, valid_to) + row = RecordAccessGrantRevision( + tenant_id=tenant_id, + grant_id=_optional_text(payload.get("grant_id")) or str(uuid.uuid4()), + record_id=record_id, + revision=1, + status="active", + subject_type=subject_type, + subject_id=_text(payload, "subject_id"), + actions=actions, + allowed_purposes=allowed_purposes, + reason=_text(payload, "reason"), + valid_from=valid_from, + valid_to=valid_to, + recorded_at=recorded_at, + institutional_context=_mapping(payload.get("institutional_context")), + changed_by=_actor(principal), + idempotency_key=_text(payload, "idempotency_key"), + request_sha256=request_hash, + ) + session.add(row) + _append_event( + session, + principal, + row=record, + event_type="record.access_grant_created", + summary=f"Restricted access granted to {subject_type}", + occurred_at=recorded_at, + purpose=management_purpose, + idempotency_key=f"{row.idempotency_key}:chronology", + request_hash=request_hash, + institutional_context=row.institutional_context, + payload={ + "grant_id": row.grant_id, + "subject_type": row.subject_type, + "subject_id": row.subject_id, + "actions": list(row.actions), + "allowed_purposes": list(row.allowed_purposes), + "valid_from": _datetime_text(row.valid_from), + "valid_to": _datetime_text(row.valid_to), + }, + ) + session.flush() + return _access_grant_dict(row) + + def revoke_access_grant( + self, + session: Session, + principal: object, + *, + record_id: str, + grant_id: str, + payload: Mapping[str, object], + ) -> dict[str, Any]: + tenant_id = _tenant(principal) + record = _required_current_record( + session, tenant_id=tenant_id, record_id=record_id, lock=True + ) + management_purpose = _text(payload, "purpose") + _require_record_access( + session, + principal, + record=record, + action="manage", + purpose=management_purpose, + ) + request_hash = _request_hash( + {"record_id": record_id, "grant_id": grant_id, **payload} + ) + replay = _replay_by_key( + session, + RecordAccessGrantRevision, + tenant_id=tenant_id, + idempotency_key=_text(payload, "idempotency_key"), + request_hash=request_hash, + ) + if replay is not None: + return _access_grant_dict(replay) + current = _current_access_grant( + session, tenant_id=tenant_id, grant_id=grant_id, lock=True + ) + if current is None or current.record_id != record_id: + raise RecordNotFoundError("Restricted-record grant not found.") + _validate_expected( + current.revision, + payload.get("expected_revision"), + label="Restricted-record grant", + ) + if current.status != "active": + raise RecordStoreError( + "Only an active restricted-record grant can be revoked." + ) + if ( + record.access_mode == "restricted" + and "manage" in current.actions + and not _other_active_manage_grant_exists( + session, + tenant_id=tenant_id, + record_id=record_id, + excluded_grant_id=grant_id, + ) + ): + raise RecordConflictError( + "Restricted access requires at least one active management grant." + ) + recorded_at = _timestamp(payload.get("recorded_at"), "recorded_at") + _validate_later_revision(current.recorded_at, recorded_at) + current.superseded_at = recorded_at + row = RecordAccessGrantRevision( + tenant_id=tenant_id, + grant_id=grant_id, + record_id=record_id, + revision=current.revision + 1, + previous_revision_id=current.id, + status="revoked", + subject_type=current.subject_type, + subject_id=current.subject_id, + actions=list(current.actions), + allowed_purposes=list(current.allowed_purposes), + reason=_text(payload, "reason"), + valid_from=current.valid_from, + valid_to=current.valid_to, + recorded_at=recorded_at, + institutional_context=dict(current.institutional_context), + changed_by=_actor(principal), + idempotency_key=_text(payload, "idempotency_key"), + request_sha256=request_hash, + ) + session.add(row) + _append_event( + session, + principal, + row=record, + event_type="record.access_grant_revoked", + summary=f"Restricted access revoked for {row.subject_type}", + occurred_at=recorded_at, + purpose=management_purpose, + idempotency_key=f"{row.idempotency_key}:chronology", + request_hash=request_hash, + institutional_context=row.institutional_context, + payload={ + "grant_id": row.grant_id, + "subject_type": row.subject_type, + "subject_id": row.subject_id, + "reason": row.reason, + }, + ) + session.flush() + return _access_grant_dict(row) + def close_record( self, session: Session, @@ -565,6 +884,17 @@ class SqlRecordRegistry: payload: Mapping[str, object], ) -> dict[str, Any]: tenant_id = _tenant(principal) + current = _required_current_record( + session, tenant_id=tenant_id, record_id=record_id, lock=True + ) + operation_purpose = _text(payload, "purpose") + _require_record_access( + session, + principal, + record=current, + action="write", + purpose=operation_purpose, + ) request_hash = _request_hash({"record_id": record_id, **payload}) replay = _replay_event( session, @@ -578,10 +908,8 @@ class SqlRecordRegistry: principal, record_id=record_id, revision=replay.record_revision, + purpose=operation_purpose, )["record"] - current = _required_current_record( - session, tenant_id=tenant_id, record_id=record_id, lock=True - ) _validate_expected( current.revision, payload.get("expected_revision"), @@ -663,6 +991,17 @@ class SqlRecordRegistry: payload: Mapping[str, object], ) -> dict[str, Any]: tenant_id = _tenant(principal) + current = _required_current_record( + session, tenant_id=tenant_id, record_id=record_id, lock=True + ) + operation_purpose = _text(payload, "purpose") + _require_record_access( + session, + principal, + record=current, + action="write", + purpose=operation_purpose, + ) request_hash = _request_hash({"record_id": record_id, **payload}) replay = _replay_event( session, @@ -676,10 +1015,8 @@ class SqlRecordRegistry: principal, record_id=record_id, revision=replay.record_revision, + purpose=operation_purpose, )["record"] - current = _required_current_record( - session, tenant_id=tenant_id, record_id=record_id, lock=True - ) _validate_expected( current.revision, payload.get("expected_revision"), @@ -736,6 +1073,17 @@ class SqlRecordRegistry: payload: Mapping[str, object], ) -> dict[str, Any]: tenant_id = _tenant(principal) + current = _required_current_record( + session, tenant_id=tenant_id, record_id=record_id, lock=True + ) + operation_purpose = _text(payload, "purpose") + _require_record_access( + session, + principal, + record=current, + action="write", + purpose=operation_purpose, + ) request_hash = _request_hash({"record_id": record_id, **payload}) replay = _replay_event( session, @@ -749,10 +1097,8 @@ class SqlRecordRegistry: principal, record_id=record_id, revision=replay.record_revision, + purpose=operation_purpose, )["record"] - current = _required_current_record( - session, tenant_id=tenant_id, record_id=record_id, lock=True - ) _validate_expected( current.revision, payload.get("expected_revision"), @@ -819,6 +1165,16 @@ class SqlRecordRegistry: payload: Mapping[str, object], ) -> dict[str, Any]: tenant_id = _tenant(principal) + record = _required_current_record( + session, tenant_id=tenant_id, record_id=record_id, lock=True + ) + _require_record_access( + session, + principal, + record=record, + action="write", + purpose=_text(payload, "purpose"), + ) request_hash = _request_hash({"record_id": record_id, **payload}) replay = _replay_by_key( session, @@ -829,9 +1185,6 @@ class SqlRecordRegistry: ) if replay is not None: return _hold_dict(replay) - record = _required_current_record( - session, tenant_id=tenant_id, record_id=record_id, lock=True - ) _validate_expected( record.revision, payload.get("expected_record_revision"), @@ -901,6 +1254,16 @@ class SqlRecordRegistry: payload: Mapping[str, object], ) -> dict[str, Any]: tenant_id = _tenant(principal) + record = _required_current_record( + session, tenant_id=tenant_id, record_id=record_id, lock=True + ) + _require_record_access( + session, + principal, + record=record, + action="write", + purpose=_text(payload, "purpose"), + ) request_hash = _request_hash( {"record_id": record_id, "hold_id": hold_id, **payload} ) @@ -913,9 +1276,6 @@ class SqlRecordRegistry: ) if replay is not None: return _hold_dict(replay) - record = _required_current_record( - session, tenant_id=tenant_id, record_id=record_id, lock=True - ) current = _current_hold(session, tenant_id, hold_id, lock=True) if current is None or current.record_id != record_id: raise RecordNotFoundError("Record hold not found.") @@ -982,6 +1342,16 @@ class SqlRecordRegistry: payload: Mapping[str, object], ) -> dict[str, Any]: tenant_id = _tenant(principal) + record = _required_current_record( + session, tenant_id=tenant_id, record_id=record_id, lock=True + ) + _require_record_access( + session, + principal, + record=record, + action="write", + purpose=_text(payload, "purpose"), + ) request_hash = _request_hash({"record_id": record_id, **payload}) replay = _replay_by_key( session, @@ -992,9 +1362,6 @@ class SqlRecordRegistry: ) if replay is not None: return _disposition_dict(replay) - record = _required_current_record( - session, tenant_id=tenant_id, record_id=record_id, lock=True - ) _validate_expected( record.revision, payload.get("expected_record_revision"), @@ -1131,6 +1498,16 @@ class SqlRecordRegistry: payload: Mapping[str, object], ) -> dict[str, Any]: tenant_id = _tenant(principal) + record = _required_current_record( + session, tenant_id=tenant_id, record_id=record_id, lock=True + ) + _require_record_access( + session, + principal, + record=record, + action="write", + purpose=_text(payload, "purpose"), + ) request_hash = _request_hash( {"record_id": record_id, "disposition_id": disposition_id, **payload} ) @@ -1142,9 +1519,6 @@ class SqlRecordRegistry: request_hash=request_hash, ) if replay is not None: - record = _required_current_record( - session, tenant_id=tenant_id, record_id=record_id - ) identity = session.get(RecordIdentity, record.identity_id) if identity is None: raise RecordStoreError("Record identity is missing.") @@ -1152,9 +1526,6 @@ class SqlRecordRegistry: "disposition": _disposition_dict(replay), "record": _record_dict(record, identity), } - record = _required_current_record( - session, tenant_id=tenant_id, record_id=record_id, lock=True - ) current = _current_disposition(session, tenant_id, disposition_id, lock=True) if current is None or current.record_id != record_id: raise RecordNotFoundError("Record disposition not found.") @@ -1276,6 +1647,16 @@ class SqlRecordRegistry: payload: Mapping[str, object], ) -> dict[str, Any]: tenant_id = _tenant(principal) + record = _required_current_record( + session, tenant_id=tenant_id, record_id=record_id, lock=True + ) + _require_record_access( + session, + principal, + record=record, + action="write", + purpose=_text(payload, "purpose"), + ) request_hash = _request_hash( {"record_id": record_id, "disposition_id": disposition_id, **payload} ) @@ -1288,9 +1669,6 @@ class SqlRecordRegistry: ) if replay is not None: return _disposition_dict(replay) - record = _required_current_record( - session, tenant_id=tenant_id, record_id=record_id, lock=True - ) current = _current_disposition(session, tenant_id, disposition_id, lock=True) if current is None or current.record_id != record_id: raise RecordNotFoundError("Record disposition not found.") @@ -1384,11 +1762,15 @@ class SqlRecordRegistry: principal: object, *, record_id: str, + purpose: str | None = None, ) -> dict[str, Any]: tenant_id = _tenant(principal) record = _required_current_record( session, tenant_id=tenant_id, record_id=record_id ) + _require_record_access( + session, principal, record=record, action="read", purpose=purpose + ) identity = session.get(RecordIdentity, record.identity_id) if identity is None: raise RecordStoreError("Record identity is missing.") @@ -1530,6 +1912,16 @@ class SqlRecordRegistry: payload: Mapping[str, object], ) -> dict[str, Any]: tenant_id = _tenant(principal) + record = _required_current_record( + session, tenant_id=tenant_id, record_id=record_id, lock=True + ) + _require_record_access( + session, + principal, + record=record, + action="write", + purpose=_text(payload, "purpose"), + ) request_hash = _request_hash({"record_id": record_id, **payload}) replay = _replay_by_key( session, @@ -1540,9 +1932,6 @@ class SqlRecordRegistry: ) if replay is not None: return _transfer_package_dict(replay) - record = _required_current_record( - session, tenant_id=tenant_id, record_id=record_id, lock=True - ) _validate_expected( record.revision, payload.get("expected_record_revision"), @@ -1645,6 +2034,16 @@ class SqlRecordRegistry: payload: Mapping[str, object], ) -> dict[str, Any]: tenant_id = _tenant(principal) + record = _required_current_record( + session, tenant_id=tenant_id, record_id=record_id, lock=True + ) + _require_record_access( + session, + principal, + record=record, + action="write", + purpose=_text(payload, "purpose"), + ) request_hash = _request_hash( {"record_id": record_id, "package_id": package_id, **payload} ) @@ -1657,9 +2056,6 @@ class SqlRecordRegistry: ) if replay is not None: return _transfer_package_dict(replay) - record = _required_current_record( - session, tenant_id=tenant_id, record_id=record_id, lock=True - ) current = _current_transfer_package(session, tenant_id, package_id, lock=True) if current is None or current.record_id != record_id: raise RecordNotFoundError("Record transfer package not found.") @@ -1792,6 +2188,16 @@ class SqlRecordRegistry: payload: Mapping[str, object], ) -> dict[str, Any]: tenant_id = _tenant(principal) + record = _required_current_record( + session, tenant_id=tenant_id, record_id=record_id, lock=True + ) + _require_record_access( + session, + principal, + record=record, + action="write", + purpose=_text(payload, "purpose"), + ) request_hash = _request_hash({"record_id": record_id, **payload}) replay = _replay_event( session, @@ -1805,9 +2211,6 @@ class SqlRecordRegistry: if row is None: raise RecordStoreError("Replayed record volume is missing.") return _volume_dict(row) - record = _current_record(session, tenant_id, record_id, lock=True) - if record is None: - raise RecordNotFoundError("Record not found.") sequence = ( int( session.query(func.max(RecordVolumeRevision.sequence)) @@ -1866,6 +2269,19 @@ class SqlRecordRegistry: tenant_id = _tenant(principal) if request.tenant_id != tenant_id: raise RecordStoreError("Record filing cannot cross tenants.") + record = _required_current_record( + session, + tenant_id=tenant_id, + record_id=request.record_id, + lock=True, + ) + _require_record_access( + session, + principal, + record=record, + action="write", + purpose=request.purpose, + ) request_hash = _request_hash(_filing_request_mapping(request)) replay = ( session.query(RecordItem) @@ -1878,9 +2294,6 @@ class SqlRecordRegistry: if replay is not None: _verify_replay(replay.request_sha256, request_hash) return _filing_result(replay, replayed=True) - record = _current_record(session, tenant_id, request.record_id, lock=True) - if record is None: - raise RecordNotFoundError("Record not found.") if record.state != "open": raise RecordStoreError("Only open records accept new items.") record_class = _current_class(session, tenant_id, record.class_id) @@ -2290,6 +2703,283 @@ def _record_transfer_packages( ) +def _record_access_grants( + session: Session, + *, + tenant_id: str, + record_id: str, +) -> Sequence[RecordAccessGrantRevision]: + return ( + session.query(RecordAccessGrantRevision) + .filter( + RecordAccessGrantRevision.tenant_id == tenant_id, + RecordAccessGrantRevision.record_id == record_id, + RecordAccessGrantRevision.superseded_at.is_(None), + ) + .order_by( + RecordAccessGrantRevision.status, + RecordAccessGrantRevision.subject_type, + RecordAccessGrantRevision.subject_id, + ) + .all() + ) + + +def _current_access_grant( + session: Session, + *, + tenant_id: str, + grant_id: str, + lock: bool = False, +) -> RecordAccessGrantRevision | None: + query = session.query(RecordAccessGrantRevision).filter( + RecordAccessGrantRevision.tenant_id == tenant_id, + RecordAccessGrantRevision.grant_id == grant_id, + RecordAccessGrantRevision.superseded_at.is_(None), + ) + if lock: + query = query.with_for_update() + return query.one_or_none() + + +def _active_access_grants( + session: Session, + *, + tenant_id: str, + record_id: str | None = None, + at: datetime | None = None, +) -> Sequence[RecordAccessGrantRevision]: + instant = _aware(at or datetime.now(UTC)) + query = session.query(RecordAccessGrantRevision).filter( + RecordAccessGrantRevision.tenant_id == tenant_id, + RecordAccessGrantRevision.status == "active", + RecordAccessGrantRevision.superseded_at.is_(None), + RecordAccessGrantRevision.valid_from <= instant, + or_( + RecordAccessGrantRevision.valid_to.is_(None), + RecordAccessGrantRevision.valid_to > instant, + ), + ) + if record_id is not None: + query = query.filter(RecordAccessGrantRevision.record_id == record_id) + return query.order_by(RecordAccessGrantRevision.recorded_at).all() + + +def _principal_subjects(principal: object) -> set[tuple[str, str]]: + subjects: set[tuple[str, str]] = set() + + def add_one(subject_type: str, field_name: str) -> None: + value = _principal_value(principal, field_name) + if value: + subjects.add((subject_type, value)) + + def add_many(subject_type: str, field_name: str) -> None: + values = getattr(principal, field_name, ()) or () + if isinstance(values, str): + values = (values,) + for value in values: + text = str(value or "").strip() + if text: + subjects.add((subject_type, text)) + + add_one("account", "account_id") + add_one("membership", "membership_id") + add_many("group", "group_ids") + add_many("role", "role_ids") + add_many("function_assignment", "function_assignment_ids") + add_one("function_assignment", "acting_assignment_id") + add_one("function_assignment", "assignment_id") + add_many("delegation", "delegation_ids") + add_one("delegation", "acting_delegation_id") + add_one("delegation", "delegation_id") + return subjects + + +def _grant_allows( + grant: RecordAccessGrantRevision, + *, + subjects: set[tuple[str, str]], + action: str, + purpose: str, +) -> bool: + action_implications = { + "read": {"read", "write", "manage"}, + "write": {"write", "manage"}, + "manage": {"manage"}, + } + return ( + (grant.subject_type, grant.subject_id) in subjects + and bool(set(grant.actions).intersection(action_implications[action])) + and purpose in grant.allowed_purposes + ) + + +def _require_record_access( + session: Session, + principal: object, + *, + record: RecordRevision, + action: str, + purpose: str | None, +) -> RecordAccessGrantRevision | None: + if record.access_mode == "tenant": + return None + if record.access_mode != "restricted": + raise RecordStoreError("The record uses an unsupported access mode.") + declared_purpose = str(purpose or "").strip() + if not declared_purpose: + raise RecordNotFoundError("Record not found in the current access context.") + subjects = _principal_subjects(principal) + for grant in _active_access_grants( + session, + tenant_id=record.tenant_id, + record_id=record.record_id, + ): + if _grant_allows( + grant, + subjects=subjects, + action=action, + purpose=declared_purpose, + ): + return grant + raise RecordNotFoundError("Record not found in the current access context.") + + +def _eligible_record_ids( + session: Session, + principal: object, + *, + tenant_id: str, + action: str, + purpose: str | None, +) -> set[str]: + current = ( + session.query(RecordRevision.record_id, RecordRevision.access_mode) + .filter( + RecordRevision.tenant_id == tenant_id, + RecordRevision.superseded_at.is_(None), + ) + .all() + ) + eligible = { + record_id for record_id, access_mode in current if access_mode == "tenant" + } + declared_purpose = str(purpose or "").strip() + if not declared_purpose: + return eligible + subjects = _principal_subjects(principal) + for grant in _active_access_grants(session, tenant_id=tenant_id): + if _grant_allows( + grant, + subjects=subjects, + action=action, + purpose=declared_purpose, + ): + eligible.add(grant.record_id) + return eligible + + +def _other_active_manage_grant_exists( + session: Session, + *, + tenant_id: str, + record_id: str, + excluded_grant_id: str, +) -> bool: + return any( + grant.grant_id != excluded_grant_id and "manage" in grant.actions + for grant in _active_access_grants( + session, tenant_id=tenant_id, record_id=record_id + ) + ) + + +def _create_initial_access_grant( + session: Session, + principal: object, + *, + row: RecordRevision, + purposes: Sequence[str], + recorded_at: datetime, + source_idempotency_key: str, +) -> RecordAccessGrantRevision: + subjects = _principal_subjects(principal) + initial_subject = next( + ( + (subject_type, subject_id) + for subject_type in ( + "account", + "membership", + "function_assignment", + "delegation", + "group", + "role", + ) + for candidate_type, subject_id in sorted(subjects) + if candidate_type == subject_type + ), + None, + ) + if initial_subject is None: + raise RecordStoreError( + "Restricted records require an identity-bound initial custodian." + ) + subject_type, subject_id = initial_subject + values = list(dict.fromkeys(item.strip() for item in purposes if item.strip())) + if not values: + raise RecordStoreError( + "Restricted records require at least one initial allowed purpose." + ) + grant_payload = { + "record_id": row.record_id, + "subject_type": subject_type, + "subject_id": subject_id, + "actions": ["read", "write", "manage"], + "allowed_purposes": values, + "valid_from": recorded_at, + } + request_hash = _request_hash(grant_payload) + grant = RecordAccessGrantRevision( + tenant_id=row.tenant_id, + grant_id=str(uuid.uuid4()), + record_id=row.record_id, + revision=1, + status="active", + subject_type=subject_type, + subject_id=subject_id, + actions=["read", "write", "manage"], + allowed_purposes=values, + reason="Initial restricted-record custody", + valid_from=recorded_at, + recorded_at=recorded_at, + institutional_context=dict(row.institutional_context), + changed_by=_actor(principal), + idempotency_key=f"{source_idempotency_key}:initial-access", + request_sha256=request_hash, + ) + session.add(grant) + _append_event( + session, + principal, + row=row, + event_type="record.access_grant_created", + summary="Initial restricted access granted to the record custodian", + occurred_at=recorded_at, + purpose=values[0], + idempotency_key=f"{source_idempotency_key}:initial-access:chronology", + request_hash=request_hash, + institutional_context=row.institutional_context, + payload={ + "grant_id": grant.grant_id, + "subject_type": grant.subject_type, + "subject_id": grant.subject_id, + "actions": list(grant.actions), + "allowed_purposes": list(grant.allowed_purposes), + }, + ) + return grant + + def _active_holds( session: Session, *, @@ -2783,6 +3473,25 @@ def _record_dict(row: RecordRevision, identity: RecordIdentity) -> dict[str, Any } +def _access_grant_dict(row: RecordAccessGrantRevision) -> dict[str, Any]: + return { + "grant_id": row.grant_id, + "record_id": row.record_id, + "revision": row.revision, + "status": row.status, + "subject_type": row.subject_type, + "subject_id": row.subject_id, + "actions": list(row.actions), + "allowed_purposes": list(row.allowed_purposes), + "reason": row.reason, + "valid_from": _datetime_text(row.valid_from), + "valid_to": _datetime_text(row.valid_to), + "recorded_at": _datetime_text(row.recorded_at), + "institutional_context": dict(row.institutional_context), + "changed_by": row.changed_by, + } + + def _volume_dict(row: RecordVolumeRevision) -> dict[str, Any]: return { "volume_id": row.volume_id, diff --git a/tests/test_dsar_provider.py b/tests/test_dsar_provider.py index e5a77bb..d695192 100644 --- a/tests/test_dsar_provider.py +++ b/tests/test_dsar_provider.py @@ -19,6 +19,7 @@ from govoplan_core.privacy.dsar_workflow import ( search_data_subject_request, ) from govoplan_records.backend.db.models import ( + RecordAccessGrantRevision, RecordChronologyEntry, RecordClassRevision, RecordDispositionRevision, @@ -478,6 +479,44 @@ class RecordsDsarProviderTests(unittest.TestCase): 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, diff --git a/tests/test_manifest.py b/tests/test_manifest.py index bf57796..2cd80db 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -38,7 +38,7 @@ class ManifestTests(unittest.TestCase): "enforced", manifest.information_governance.temporal_browsing.adoption ) self.assertEqual( - "partial", manifest.information_governance.purpose_aware_access.adoption + "enforced", manifest.information_governance.purpose_aware_access.adoption ) diff --git a/tests/test_migrations.py b/tests/test_migrations.py index d5b04ab..677fc23 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -28,6 +28,7 @@ class RecordsMigrationTests(unittest.TestCase): self.assertTrue( { "record_chronology_entries", + "record_access_grant_revisions", "record_class_revisions", "record_file_plan_revisions", "record_hold_revisions", @@ -45,7 +46,7 @@ class RecordsMigrationTests(unittest.TestCase): ) with engine.connect() as connection: self.assertIn( - "7f5b3d9a2c1e", + "8a6c4e2f1b3d", set(MigrationContext.configure(connection).get_current_heads()), ) finally: diff --git a/tests/test_records.py b/tests/test_records.py index 04ea5d7..96fcc9b 100644 --- a/tests/test_records.py +++ b/tests/test_records.py @@ -27,6 +27,7 @@ from govoplan_core.core.temporal import ( reset_temporal_data_context, ) from govoplan_records.backend.db.models import ( + RecordAccessGrantRevision, RecordChronologyEntry, RecordClassRevision, RecordDispositionRevision, @@ -39,7 +40,11 @@ from govoplan_records.backend.db.models import ( RecordVolumeRevision, ) 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) @@ -51,6 +56,11 @@ class Principal: account_id: str = "account-1" acting_assignment_id: str = "assignment-1" 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: @@ -105,9 +115,7 @@ class ReferenceJourneySourceProvider: return RecordSourceReference( locator=locator, label=str(reference.get("label") or locator.resource_id), - authority_mode=str( - reference.get("authority_mode") or "linked_reference" - ), + 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), @@ -199,7 +207,9 @@ class ReferenceJourneyRegistry(Registry): 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 self.source_providers_by_module.get( + name.removeprefix("records.source.") + ) return super().tenant_capability(name, session, tenant_id=tenant_id) @@ -262,6 +272,7 @@ class RecordsTests(unittest.TestCase): RecordClassRevision.__table__, RecordIdentity.__table__, RecordRevision.__table__, + RecordAccessGrantRevision.__table__, RecordVolumeRevision.__table__, RecordItem.__table__, RecordChronologyEntry.__table__, @@ -402,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: self._create_record() first_payload = { @@ -882,13 +1016,14 @@ class RecordsTests(unittest.TestCase): "evidence_role": metadata["evidence_role"], }, metadata={"fixture_source_id": source["id"]}, - idempotency_key=( - f"reference-file-{source['id']}{idempotency_suffix}" - ), + idempotency_key=(f"reference-file-{source['id']}{idempotency_suffix}"), ) record = create_journey_record( - fixture["record"], context, idempotency_suffix="assisted", channel="assisted" + fixture["record"], + context, + idempotency_suffix="assisted", + channel="assisted", ) self.assertEqual(fixture["record"]["title"], record["title"]) @@ -972,6 +1107,7 @@ class RecordsTests(unittest.TestCase): self.principal, record_id=str(digital_record["record_id"]), ) + def equivalence_fields(item): return ( item["source"]["source_module"], @@ -979,6 +1115,7 @@ class RecordsTests(unittest.TestCase): 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"]], @@ -1130,7 +1267,9 @@ class RecordsTests(unittest.TestCase): ) self.session.commit() - with tempfile.TemporaryDirectory(prefix="govoplan-records-restore-") as temp_dir: + 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: diff --git a/tests/test_search_source.py b/tests/test_search_source.py index d611eda..f4b7a56 100644 --- a/tests/test_search_source.py +++ b/tests/test_search_source.py @@ -14,7 +14,11 @@ from govoplan_core.core.search import ( SearchBackfillRequest, 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 ( ADMIN_SCOPE, PROVIDER_ID, @@ -32,6 +36,7 @@ class RecordsSearchSourceTests(unittest.TestCase): self.engine = create_engine("sqlite+pysqlite:///:memory:") RecordIdentity.__table__.create(self.engine) RecordRevision.__table__.create(self.engine) + RecordAccessGrantRevision.__table__.create(self.engine) self.session = Session(self.engine) identity = RecordIdentity( id="identity-1", @@ -160,6 +165,77 @@ class RecordsSearchSourceTests(unittest.TestCase): )[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: return ApiPrincipal( diff --git a/webui/package.json b/webui/package.json index 4028b54..1259e0d 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/records-webui", - "version": "0.1.22", + "version": "0.1.23", "private": true, "type": "module", "main": "src/index.ts", diff --git a/webui/src/api/records.ts b/webui/src/api/records.ts index 4734dc1..389b016 100644 --- a/webui/src/api/records.ts +++ b/webui/src/api/records.ts @@ -165,6 +165,21 @@ export type RecordTransferPackage = { 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; +}; + export type RecordDetail = { record: RecordEntry; volumes: RecordVolume[]; @@ -179,6 +194,8 @@ export type RecordDetail = { purpose: string; current_authorization: boolean; access_mode: string; + grant_id?: string | null; + grant_subject_type?: string | null; limitations: string[]; }; }; @@ -209,6 +226,7 @@ export function listRecords( state?: string; classId?: string; filePlanNodeId?: string; + purpose?: string; offset?: number; limit?: number; }, @@ -219,13 +237,19 @@ export function listRecords( state: options.state, class_id: options.classId, file_plan_node_id: options.filePlanNodeId, + purpose: options.purpose, offset: options.offset, limit: options.limit ?? 50 }), { signal }); } -export function getRecord(settings: ApiSettings, recordId: string, signal?: AbortSignal): Promise { - return apiFetch(settings, `/api/v1/records/${encodeURIComponent(recordId)}`, { signal }); +export function getRecord( + settings: ApiSettings, + recordId: string, + purpose?: string, + signal?: AbortSignal +): Promise { + return apiFetch(settings, apiPath(`/api/v1/records/${encodeURIComponent(recordId)}`, { purpose }), { signal }); } export function getRecordCatalog(settings: ApiSettings, signal?: AbortSignal): Promise { @@ -362,9 +386,36 @@ export function dispatchRecordTransfer( export function getRecordRecoveryStatus( settings: ApiSettings, recordId: string, + purpose: string, signal?: AbortSignal ): Promise> { - 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 +): Promise { + return recordMutation(settings, recordId, "access-grants", payload); +} + +export function revokeRecordAccessGrant( + settings: ApiSettings, + recordId: string, + grantId: string, + payload: Record +): Promise { + return recordMutation(settings, recordId, `access-grants/${encodeURIComponent(grantId)}/revoke`, payload); } function recordMutation( diff --git a/webui/src/features/records/RecordAccessGrantsDialog.tsx b/webui/src/features/records/RecordAccessGrantsDialog.tsx new file mode 100644 index 0000000..3587dba --- /dev/null +++ b/webui/src/features/records/RecordAccessGrantsDialog.tsx @@ -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([]); + const [subjectType, setSubjectType] = useState("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 ( + Close} + > +

+ Grants are effective-dated and match an exact subject, action, and declared purpose. + Current grants also govern historical record views. +

+

Management purpose: {managementPurpose || "Not supplied"}

+ {error && {error}} + {loading ? : grants.length === 0 ? ( + + ) : ( +
+ {grants.map((grant) => ( +
+
+ {humanize(grant.subject_type)} · {grant.subject_id} + {grant.actions.map(humanize).join(", ")} · {grant.allowed_purposes.join(", ")} + {grant.reason} +
+ + {grant.status === "active" && ( + + )} +
+ ))} +
+ )} + + +

Add access grant

+ + + + setSubjectId(event.target.value)} required /> + +
+ + + +
+
+