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:
+149
-10
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user