feat(cases): enforce purpose-bound case governance
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -324,13 +324,66 @@ class CaseLifecycleTests(unittest.TestCase):
|
||||
|
||||
outsider = Principal(account_id="account-2")
|
||||
self.assertIsNone(get_case(self.session, outsider, case_id="case-1"))
|
||||
self.assertEqual(0, list_cases(self.session, outsider)[1])
|
||||
self.assertEqual(
|
||||
0,
|
||||
list_cases(
|
||||
self.session,
|
||||
outsider,
|
||||
purpose="cases.casework",
|
||||
)[1],
|
||||
)
|
||||
self.assertEqual((), case_history(self.session, outsider, case_id="case-1"))
|
||||
self.assertEqual((), case_timeline(self.session, outsider, case_id="case-1"))
|
||||
|
||||
reader = Principal(account_id="account-3", group_ids=("reviewers",))
|
||||
self.assertIsNotNone(get_case(self.session, reader, case_id="case-1"))
|
||||
self.assertEqual(1, list_cases(self.session, reader)[1])
|
||||
self.assertIsNone(get_case(self.session, reader, case_id="case-1"))
|
||||
self.assertIsNone(
|
||||
get_case(
|
||||
self.session,
|
||||
reader,
|
||||
case_id="case-1",
|
||||
purpose="cases.unrelated",
|
||||
)
|
||||
)
|
||||
self.assertIsNotNone(
|
||||
get_case(
|
||||
self.session,
|
||||
reader,
|
||||
case_id="case-1",
|
||||
purpose="cases.casework",
|
||||
)
|
||||
)
|
||||
administrator = Principal(
|
||||
account_id="account-6",
|
||||
scopes=frozenset({"cases:case:admin"}),
|
||||
)
|
||||
self.assertIsNone(
|
||||
get_case(
|
||||
self.session,
|
||||
administrator,
|
||||
case_id="case-1",
|
||||
purpose="cases.casework",
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
1,
|
||||
list_cases(
|
||||
self.session,
|
||||
reader,
|
||||
purpose="cases.casework",
|
||||
)[1],
|
||||
)
|
||||
self.assertEqual(
|
||||
1,
|
||||
len(
|
||||
case_history(
|
||||
self.session,
|
||||
reader,
|
||||
case_id="case-1",
|
||||
purpose="cases.casework",
|
||||
)
|
||||
),
|
||||
)
|
||||
with self.assertRaises(PermissionError):
|
||||
update_case(
|
||||
self.session,
|
||||
@@ -396,9 +449,56 @@ class CaseLifecycleTests(unittest.TestCase):
|
||||
self.session.commit()
|
||||
self.assertEqual(3, shared.revision)
|
||||
self.assertIsNotNone(
|
||||
get_case(self.session, Principal(account_id="account-5"), case_id="case-1")
|
||||
get_case(
|
||||
self.session,
|
||||
Principal(account_id="account-5"),
|
||||
case_id="case-1",
|
||||
purpose="cases.casework",
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
3,
|
||||
len(
|
||||
case_history(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
purpose="cases.casework",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
revoked = update_case(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
expected_revision=3,
|
||||
changes={"access_grants": []},
|
||||
recorded_at=NOW + timedelta(minutes=3),
|
||||
change_reason="Removed direct collaboration access.",
|
||||
idempotency_key="case-share-revoke",
|
||||
purpose="cases.casework",
|
||||
)
|
||||
self.session.commit()
|
||||
self.assertEqual(4, revoked.revision)
|
||||
self.assertIsNone(
|
||||
get_case(
|
||||
self.session,
|
||||
Principal(account_id="account-5"),
|
||||
case_id="case-1",
|
||||
revision=2,
|
||||
purpose="cases.casework",
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
"cases.casework",
|
||||
case_timeline(
|
||||
self.session,
|
||||
self.principal,
|
||||
case_id="case-1",
|
||||
purpose="cases.casework",
|
||||
)[0]["purpose"],
|
||||
)
|
||||
self.assertEqual(3, len(case_history(self.session, self.principal, case_id="case-1")))
|
||||
|
||||
def test_malformed_access_grant_is_rejected(self) -> None:
|
||||
create_case(
|
||||
|
||||
@@ -255,6 +255,10 @@ class CasesDsarProviderTests(unittest.TestCase):
|
||||
self.assertEqual("cases_access_grant", account_records[0].resource_type)
|
||||
self.assertFalse(account_records[0].immutable_evidence)
|
||||
self.assertEqual("account-subject", account_records[0].data["subject_id"])
|
||||
self.assertEqual(
|
||||
["cases.casework"],
|
||||
account_records[0].data["allowed_purposes"],
|
||||
)
|
||||
|
||||
identity_records = self.provider.search_subject(
|
||||
self.session,
|
||||
@@ -308,7 +312,11 @@ class CasesDsarProviderTests(unittest.TestCase):
|
||||
self.assertTrue(
|
||||
all(
|
||||
item.resource_type
|
||||
in {"cases_operator_attribution", "cases_timeline_event"}
|
||||
in {
|
||||
"cases_access_grant",
|
||||
"cases_operator_attribution",
|
||||
"cases_timeline_event",
|
||||
}
|
||||
for item in records
|
||||
)
|
||||
)
|
||||
|
||||
@@ -67,6 +67,20 @@ class CasesInterfaceDocumentationContractTests(unittest.TestCase):
|
||||
owner_links.metadata["consequence_classes"],
|
||||
)
|
||||
|
||||
def test_information_governance_claims_are_bounded_and_documented(self) -> None:
|
||||
manifest = get_manifest()
|
||||
governance = manifest.information_governance
|
||||
self.assertEqual("enforced", governance.temporal_browsing.adoption)
|
||||
self.assertEqual("enforced", governance.purpose_aware_access.adoption)
|
||||
self.assertEqual("enforced", governance.institutional_context.adoption)
|
||||
self.assertEqual("contract_only", governance.retention.adoption)
|
||||
self.assertIn("legal holds", governance.retention.limitation or "")
|
||||
self.assertTrue(governance.current_authorization_for_historical_reads)
|
||||
topics = {topic.id: topic for topic in manifest.documentation}
|
||||
purpose = topics["cases.governance.purpose-bound-access"]
|
||||
self.assertIn("cases.field.access-purpose", purpose.metadata["help_contexts"])
|
||||
self.assertIn("cases.field.allowed-purposes", purpose.metadata["help_contexts"])
|
||||
|
||||
def test_webui_uses_shared_help_guard_and_confirmation_components(self) -> None:
|
||||
list_page = (REPO_ROOT / "webui/src/features/cases/CasesPage.tsx").read_text(
|
||||
encoding="utf-8"
|
||||
|
||||
@@ -40,9 +40,23 @@ class CasesMigrationTests(unittest.TestCase):
|
||||
for item in inspector.get_columns("case_record_revisions")
|
||||
},
|
||||
)
|
||||
self.assertIn(
|
||||
"allowed_purposes",
|
||||
{
|
||||
item["name"]
|
||||
for item in inspector.get_columns("case_access_grants")
|
||||
},
|
||||
)
|
||||
self.assertIn(
|
||||
"purpose",
|
||||
{
|
||||
item["name"]
|
||||
for item in inspector.get_columns("case_timeline_entries")
|
||||
},
|
||||
)
|
||||
with engine.connect() as connection:
|
||||
self.assertIn(
|
||||
"f6d3a8b1c4e7",
|
||||
"a7c4e2f9b1d6",
|
||||
set(MigrationContext.configure(connection).get_current_heads()),
|
||||
)
|
||||
finally:
|
||||
|
||||
@@ -75,7 +75,7 @@ class CasesRecordSourceTests(unittest.TestCase):
|
||||
def test_resolves_exact_currently_authorized_case_revision(self) -> None:
|
||||
with patch(
|
||||
"govoplan_cases.backend.record_source.can_access_case", return_value=True
|
||||
):
|
||||
) as can_access:
|
||||
result = CasesRecordSource().resolve(
|
||||
self.session,
|
||||
Principal(),
|
||||
@@ -83,9 +83,20 @@ class CasesRecordSourceTests(unittest.TestCase):
|
||||
purpose="preserve decision basis",
|
||||
)
|
||||
|
||||
can_access.assert_called_once_with(
|
||||
self.session,
|
||||
unittest.mock.ANY,
|
||||
case_id="case-1",
|
||||
permission="read",
|
||||
purpose="preserve decision basis",
|
||||
)
|
||||
self.assertEqual("2026/C-1 - Permit application", result.label)
|
||||
self.assertEqual(64, len(result.content_sha256 or ""))
|
||||
self.assertEqual("review", result.metadata["status_key"])
|
||||
self.assertEqual(
|
||||
"/cases/case-1?purpose=preserve%20decision%20basis",
|
||||
result.launch_url,
|
||||
)
|
||||
|
||||
def test_object_access_is_rechecked_and_fails_closed(self) -> None:
|
||||
with (
|
||||
|
||||
@@ -28,8 +28,12 @@ from govoplan_cases.backend.db.models import (
|
||||
CaseTimelineEntry,
|
||||
CaseTypeDefinition,
|
||||
)
|
||||
from govoplan_cases.backend.domain import CaseRecord
|
||||
from govoplan_cases.backend.search_source import CasesSearchSource, PROVIDER_ID
|
||||
from govoplan_cases.backend.domain import CaseGrant, CaseRecord
|
||||
from govoplan_cases.backend.search_source import (
|
||||
SEARCH_ACCESS_PURPOSE,
|
||||
CasesSearchSource,
|
||||
PROVIDER_ID,
|
||||
)
|
||||
from govoplan_cases.backend.service import (
|
||||
create_case,
|
||||
upsert_case_status,
|
||||
@@ -77,6 +81,15 @@ class CasesSearchSourceTests(unittest.TestCase):
|
||||
case_type_key="permit",
|
||||
status_key="review",
|
||||
title="Permit application",
|
||||
access_mode="restricted",
|
||||
access_grants=(
|
||||
CaseGrant(
|
||||
"account",
|
||||
"account-1",
|
||||
("read",),
|
||||
(SEARCH_ACCESS_PURPOSE,),
|
||||
),
|
||||
),
|
||||
context=GovernedContextEnvelope(
|
||||
tenant_id="tenant-1",
|
||||
temporal=TemporalRevision(
|
||||
@@ -112,6 +125,7 @@ class CasesSearchSourceTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(("case-1",), tuple(item.resource_id for item in page.documents))
|
||||
self.assertEqual("PERMIT-1", page.documents[0].metadata["case_number"])
|
||||
self.assertIn("purpose=cases.search", page.documents[0].url)
|
||||
reference = SearchResourceReference(
|
||||
tenant_id="tenant-1",
|
||||
module_id="cases",
|
||||
|
||||
Reference in New Issue
Block a user