feat(records): enforce purpose-bound restricted access
Module Package Release / publish-packages (push) Successful in 13s
Module Package Release / publish-packages (push) Successful in 13s
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=(
|
||||
|
||||
@@ -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")
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user