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:
@@ -34,17 +34,21 @@ The executable backend slice now exposes:
|
|||||||
Cases does not own institutional Service, Party, representation, identity,
|
Cases does not own institutional Service, Party, representation, identity,
|
||||||
address, Mandate, Decision, file, workflow, or task lifecycles. The `/cases`
|
address, Mandate, Decision, file, workflow, or task lifecycles. The `/cases`
|
||||||
workspace now supplies list/detail, status/title revision, history, and timeline
|
workspace now supplies list/detail, status/title revision, history, and timeline
|
||||||
surfaces. A case can remain tenant-visible or become restricted to its creator,
|
surfaces. A case can remain tenant-visible or become restricted. Restricted
|
||||||
case administrators, explicit user/group grants, and assignment-derived
|
access requires a current custodian, explicit subject, or assignment-derived
|
||||||
function, function-assignment, or organization-unit grants. The detail surface
|
grant whose permission and exact allowed purpose both match the operation.
|
||||||
uses the shared reference selector to manage those grants; list, detail,
|
`cases.casework` is the interactive default and `cases.search` is reserved for
|
||||||
history, timeline, and update paths all apply the same fail-closed ACL.
|
Search rechecks. The detail surface uses the shared reference selector to
|
||||||
|
manage grants and purposes; list, detail, history, timeline, update, evidence,
|
||||||
|
Decision, Search, and Records-source paths apply the same fail-closed decision.
|
||||||
|
Current grants also govern historical revision reads.
|
||||||
|
|
||||||
See [docs/CONCEPT.md](docs/CONCEPT.md) for the current module concept.
|
See [docs/CONCEPT.md](docs/CONCEPT.md) for the current module concept.
|
||||||
|
|
||||||
## Data-subject requests
|
## Data-subject requests
|
||||||
|
|
||||||
Cases contributes `privacy.dsar.cases`. It reports exact-tenant access grants
|
Cases contributes `privacy.dsar.cases`. It reports exact-tenant access grants,
|
||||||
|
including their bounded allowed purposes,
|
||||||
and operator attribution, plus minimized case lifecycle data when an explicit
|
and operator attribution, plus minimized case lifecycle data when an explicit
|
||||||
Cases reference is supplied and corroborated. Raw snapshots, metadata, search
|
Cases reference is supplied and corroborated. Raw snapshots, metadata, search
|
||||||
text, free-text reasons, event payloads, evidence identifiers, request digests,
|
text, free-text reasons, event payloads, evidence identifiers, request digests,
|
||||||
|
|||||||
+39
-14
@@ -88,19 +88,21 @@ The persistent backend provides:
|
|||||||
- replay-safe case timeline events carrying the institutional context and a
|
- replay-safe case timeline events carrying the institutional context and a
|
||||||
stable event/audit reference
|
stable event/audit reference
|
||||||
- tenant-level resource ACL and tenant summary providers
|
- tenant-level resource ACL and tenant summary providers
|
||||||
- tenant-wide or restricted object access with explicit, revisioned grants
|
- tenant-wide or restricted object access with purpose-bound, revisioned grant snapshots
|
||||||
- deterministic, replay-safe case launch from an exact Service binding
|
- deterministic, replay-safe case launch from an exact Service binding
|
||||||
- deterministic, replay-safe Case escalation from an authorized Ticket handoff
|
- deterministic, replay-safe Case escalation from an authorized Ticket handoff
|
||||||
|
|
||||||
The `/cases` list and `/cases/:caseId` detail workspace provide server-side
|
The `/cases` list and `/cases/:caseId` detail workspace provide server-side
|
||||||
search/status filtering, case facts, typed references, history, timeline, and
|
search/status filtering, case facts, typed references, history, timeline, and
|
||||||
OCC-guarded title/status/access revisions with an explicit change reason.
|
OCC-guarded title/status/access revisions with an explicit change reason.
|
||||||
Restricted cases are visible to their creator, case administrators, explicit
|
Restricted cases require a current custodian, explicit subject, or matching
|
||||||
user/group grants, and matching function, function-assignment, or
|
function, function-assignment, or organization-unit grant. The grant must imply
|
||||||
organization-unit assignments. The same decision filters list, detail,
|
the requested permission and contain the exact declared purpose. The same
|
||||||
history, timeline, and update operations so an inaccessible identifier does
|
decision filters list, detail, history, timeline, update, evidence, Decision,
|
||||||
not disclose case existence. API reads and writes are tenant-bound, and
|
Search, and Records-source operations so an inaccessible identifier does not
|
||||||
create/update/assign/close/share/catalog operations have separate permissions.
|
disclose case existence. Current grants govern historical revisions. API reads
|
||||||
|
and writes are tenant-bound, and create/update/assign/close/share/catalog
|
||||||
|
operations have separate permissions.
|
||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
|
|
||||||
@@ -114,9 +116,14 @@ Implemented scopes:
|
|||||||
- `cases:case:share`
|
- `cases:case:share`
|
||||||
- `cases:case:admin`
|
- `cases:case:admin`
|
||||||
|
|
||||||
Access decisions combine tenant permissions, creator/administrative authority,
|
Access decisions combine tenant permissions with current purpose-bound
|
||||||
case assignments, and explicit case shares. Restricted access is versioned
|
custodian, assignment-derived, and explicit subject grants. Creating or newly
|
||||||
with the case record so its history remains reconstructable.
|
restricting a case establishes a custodian grant for the attributable actor.
|
||||||
|
`cases.casework` is the stable interactive default; Search rechecks use only
|
||||||
|
`cases.search`; Records filing supplies its declared purpose. Assignment-derived
|
||||||
|
grants admit `cases.casework`, `cases.search`, and `cases.records`. Each accepted
|
||||||
|
timeline event stores the evaluated purpose. Restricted access snapshots remain
|
||||||
|
versioned with the case record while current grant state governs disclosure.
|
||||||
|
|
||||||
## Data Model Sketch
|
## Data Model Sketch
|
||||||
|
|
||||||
@@ -187,7 +194,7 @@ metadata snapshots. The owning module remains responsible for the real object.
|
|||||||
|
|
||||||
When Records is enabled, `records.source.cases` resolves one exact immutable
|
When Records is enabled, `records.source.cases` resolves one exact immutable
|
||||||
case revision for eAkte filing. Cases rechecks current tenant, scope, and
|
case revision for eAkte filing. Cases rechecks current tenant, scope, and
|
||||||
object-level access, then returns the case number, lifecycle state, represented
|
object-level access for the caller-declared purpose, then returns the case number, lifecycle state, represented
|
||||||
valid interval, recorded time, canonical snapshot digest, and launch link.
|
valid interval, recorded time, canonical snapshot digest, and launch link.
|
||||||
Records owns the filing decision and chronology; Cases remains authoritative
|
Records owns the filing decision and chronology; Cases remains authoritative
|
||||||
for the case and its revision history.
|
for the case and its revision history.
|
||||||
@@ -196,7 +203,25 @@ When Search is enabled, `cases.cases` indexes current Case titles, numbers,
|
|||||||
types, states, and the existing bounded search projection. It includes no
|
types, states, and the existing bounded search projection. It includes no
|
||||||
provider-owned evidence content or protected Decision reasoning. Restricted
|
provider-owned evidence content or protected Decision reasoning. Restricted
|
||||||
Case candidates carry bounded ACL tokens and every result is checked again
|
Case candidates carry bounded ACL tokens and every result is checked again
|
||||||
against the current Case object-access decision before disclosure.
|
against the current Case object-access decision using the fixed `cases.search`
|
||||||
|
purpose before disclosure. Search links preserve that purpose in the Case URL.
|
||||||
|
|
||||||
|
## Information governance adoption
|
||||||
|
|
||||||
|
Cases enforces temporal browsing for immutable case revisions while always
|
||||||
|
using current authorization for historical reads. It enforces exact-purpose
|
||||||
|
access for restricted cases and records the evaluated purpose in lifecycle
|
||||||
|
evidence. Governed context validates and persists tenant, service, party,
|
||||||
|
organization, function, acting assignment/delegation, mandate, jurisdiction,
|
||||||
|
legal-basis, evidence, Decision, and record references at their owning
|
||||||
|
boundaries.
|
||||||
|
|
||||||
|
Retention remains contract-only. Cases preserves immutable revisions and
|
||||||
|
timeline evidence and publishes DSAR retention/manual-review consequences, but
|
||||||
|
does not yet apply configurable schedules, legal holds, appraisal, or
|
||||||
|
disposition gates. Those controls must be implemented through the Records
|
||||||
|
boundary or a future explicit Cases retention policy before retention can be
|
||||||
|
claimed as enforced.
|
||||||
|
|
||||||
## WebUI
|
## WebUI
|
||||||
|
|
||||||
@@ -230,8 +255,8 @@ The focused suite covers:
|
|||||||
## Data-subject requests
|
## Data-subject requests
|
||||||
|
|
||||||
Cases publishes `privacy.dsar.cases`. Canonical account, identity, and
|
Cases publishes `privacy.dsar.cases`. Canonical account, identity, and
|
||||||
membership selectors cover Cases-owned operator attribution; account and
|
membership selectors cover Cases-owned operator attribution and matching
|
||||||
identity selectors also cover explicit case-access grants. Exact
|
account, identity, or membership access grants. Exact
|
||||||
`cases.case`, `cases.revision`, `cases.access_grant`, and `cases.timeline`
|
`cases.case`, `cases.revision`, `cases.access_grant`, and `cases.timeline`
|
||||||
references select lifecycle data. When a canonical and direct selector are
|
references select lifecycle data. When a canonical and direct selector are
|
||||||
combined, a Cases-owned relationship must corroborate them and all supplied
|
combined, a Cases-owned relationship must corroborate them and all supplied
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-cases"
|
name = "govoplan-cases"
|
||||||
version = "0.1.22"
|
version = "0.1.23"
|
||||||
description = "GovOPlaN administrative case context module."
|
description = "GovOPlaN administrative case context module."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -140,6 +140,9 @@ class CaseAccessGrant(Base, TimestampMixin):
|
|||||||
subject_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
subject_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||||
subject_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
subject_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
permissions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
permissions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
allowed_purposes: Mapped[list[str]] = mapped_column(
|
||||||
|
JSON, default=list, nullable=False
|
||||||
|
)
|
||||||
source: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
source: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||||
source_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
source_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
@@ -163,6 +166,9 @@ class CaseTimelineEntry(Base, TimestampMixin):
|
|||||||
summary: Mapped[str] = mapped_column(String(500), nullable=False)
|
summary: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||||
|
purpose: Mapped[str] = mapped_column(
|
||||||
|
String(255), nullable=False, default="cases.casework", index=True
|
||||||
|
)
|
||||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
audit_event_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
audit_event_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from govoplan_core.core.institutional import (
|
|||||||
TemporalRevision,
|
TemporalRevision,
|
||||||
resolve_mandate_candidates,
|
resolve_mandate_candidates,
|
||||||
)
|
)
|
||||||
from govoplan_cases.backend.domain import CaseRecord
|
from govoplan_cases.backend.domain import DEFAULT_CASE_ACCESS_PURPOSE, CaseRecord
|
||||||
from govoplan_cases.backend.service import (
|
from govoplan_cases.backend.service import (
|
||||||
can_access_case,
|
can_access_case,
|
||||||
get_case,
|
get_case,
|
||||||
@@ -56,6 +56,7 @@ class CaseDecisionCommand:
|
|||||||
reasoning: str
|
reasoning: str
|
||||||
change_reason: str
|
change_reason: str
|
||||||
idempotency_key: str
|
idempotency_key: str
|
||||||
|
purpose: str = DEFAULT_CASE_ACCESS_PURPOSE
|
||||||
conditions: tuple[str, ...] = ()
|
conditions: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
@@ -93,8 +94,14 @@ class CaseDecisionPath:
|
|||||||
principal,
|
principal,
|
||||||
case_id=case_id,
|
case_id=case_id,
|
||||||
revision=command.expected_case_revision,
|
revision=command.expected_case_revision,
|
||||||
|
purpose=command.purpose,
|
||||||
|
)
|
||||||
|
current = get_case(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
purpose=command.purpose,
|
||||||
)
|
)
|
||||||
current = get_case(session, principal, case_id=case_id)
|
|
||||||
if source_case is None or current is None:
|
if source_case is None or current is None:
|
||||||
raise LookupError("Case not found.")
|
raise LookupError("Case not found.")
|
||||||
if not can_access_case(
|
if not can_access_case(
|
||||||
@@ -102,6 +109,7 @@ class CaseDecisionPath:
|
|||||||
principal,
|
principal,
|
||||||
case_id=case_id,
|
case_id=case_id,
|
||||||
permission="update",
|
permission="update",
|
||||||
|
purpose=command.purpose,
|
||||||
):
|
):
|
||||||
raise PermissionError("Case update access is denied.")
|
raise PermissionError("Case update access is denied.")
|
||||||
|
|
||||||
@@ -163,6 +171,7 @@ class CaseDecisionPath:
|
|||||||
recorded_at=command.effective_at,
|
recorded_at=command.effective_at,
|
||||||
change_reason=command.change_reason,
|
change_reason=command.change_reason,
|
||||||
idempotency_key=f"formal-decision:{command.idempotency_key}",
|
idempotency_key=f"formal-decision:{command.idempotency_key}",
|
||||||
|
purpose=command.purpose,
|
||||||
)
|
)
|
||||||
return CaseDecisionResult(case=linked, decision=persisted)
|
return CaseDecisionResult(case=linked, decision=persisted)
|
||||||
|
|
||||||
@@ -172,8 +181,14 @@ class CaseDecisionPath:
|
|||||||
principal: object,
|
principal: object,
|
||||||
*,
|
*,
|
||||||
case_id: str,
|
case_id: str,
|
||||||
|
purpose: str | None = None,
|
||||||
) -> tuple[FormalDecision, ...]:
|
) -> tuple[FormalDecision, ...]:
|
||||||
case = get_case(session, principal, case_id=case_id)
|
case = get_case(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
purpose=purpose,
|
||||||
|
)
|
||||||
if case is None:
|
if case is None:
|
||||||
raise LookupError("Case not found.")
|
raise LookupError("Case not found.")
|
||||||
registry = _decision_registry(self._registry)
|
registry = _decision_registry(self._registry)
|
||||||
@@ -434,6 +449,7 @@ def _validate_command(command: CaseDecisionCommand) -> None:
|
|||||||
(command.reasoning, "Decision reasoning"),
|
(command.reasoning, "Decision reasoning"),
|
||||||
(command.change_reason, "Decision change reason"),
|
(command.change_reason, "Decision change reason"),
|
||||||
(command.idempotency_key, "Decision idempotency key"),
|
(command.idempotency_key, "Decision idempotency key"),
|
||||||
|
(command.purpose, "Case access purpose"),
|
||||||
):
|
):
|
||||||
if not value.strip():
|
if not value.strip():
|
||||||
raise CaseDecisionError(f"{label} is required.")
|
raise CaseDecisionError(f"{label} is required.")
|
||||||
|
|||||||
@@ -17,15 +17,19 @@ CASE_ACCESS_SUBJECT_KINDS = frozenset(
|
|||||||
{
|
{
|
||||||
"account",
|
"account",
|
||||||
"identity",
|
"identity",
|
||||||
|
"membership",
|
||||||
"group",
|
"group",
|
||||||
"role",
|
"role",
|
||||||
"function",
|
"function",
|
||||||
"function_assignment",
|
"function_assignment",
|
||||||
"organization_unit",
|
"organization_unit",
|
||||||
"service_account",
|
"service_account",
|
||||||
|
"delegation",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
CASE_ACCESS_PERMISSIONS = frozenset({"read", "update", "share", "admin"})
|
CASE_ACCESS_PERMISSIONS = frozenset({"read", "update", "share", "admin"})
|
||||||
|
DEFAULT_CASE_ACCESS_PURPOSE = "cases.casework"
|
||||||
|
MAX_CASE_ACCESS_PURPOSES = 100
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -33,6 +37,7 @@ class CaseGrant:
|
|||||||
subject_kind: str
|
subject_kind: str
|
||||||
subject_id: str
|
subject_id: str
|
||||||
permissions: tuple[str, ...] = ("read",)
|
permissions: tuple[str, ...] = ("read",)
|
||||||
|
allowed_purposes: tuple[str, ...] = (DEFAULT_CASE_ACCESS_PURPOSE,)
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if self.subject_kind not in CASE_ACCESS_SUBJECT_KINDS:
|
if self.subject_kind not in CASE_ACCESS_SUBJECT_KINDS:
|
||||||
@@ -43,12 +48,29 @@ class CaseGrant:
|
|||||||
if not normalized or set(normalized) - CASE_ACCESS_PERMISSIONS:
|
if not normalized or set(normalized) - CASE_ACCESS_PERMISSIONS:
|
||||||
raise InstitutionalContextError("Case access permissions are invalid.")
|
raise InstitutionalContextError("Case access permissions are invalid.")
|
||||||
object.__setattr__(self, "permissions", normalized)
|
object.__setattr__(self, "permissions", normalized)
|
||||||
|
purposes = tuple(
|
||||||
|
dict.fromkeys(
|
||||||
|
str(value).strip() for value in self.allowed_purposes if str(value).strip()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not purposes:
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Case access grants require at least one allowed purpose."
|
||||||
|
)
|
||||||
|
if len(purposes) > MAX_CASE_ACCESS_PURPOSES or any(
|
||||||
|
len(value) > 255 for value in purposes
|
||||||
|
):
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Case access grant purposes exceed the supported bounds."
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "allowed_purposes", purposes)
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, object]:
|
def to_dict(self) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"subject_kind": self.subject_kind,
|
"subject_kind": self.subject_kind,
|
||||||
"subject_id": self.subject_id,
|
"subject_id": self.subject_id,
|
||||||
"permissions": list(self.permissions),
|
"permissions": list(self.permissions),
|
||||||
|
"allowed_purposes": list(self.allowed_purposes),
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -58,10 +80,18 @@ class CaseGrant:
|
|||||||
raise InstitutionalContextError(
|
raise InstitutionalContextError(
|
||||||
"Case access permissions must be a list."
|
"Case access permissions must be a list."
|
||||||
)
|
)
|
||||||
|
raw_purposes = value.get(
|
||||||
|
"allowed_purposes", (DEFAULT_CASE_ACCESS_PURPOSE,)
|
||||||
|
)
|
||||||
|
if not isinstance(raw_purposes, (list, tuple)):
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Case access purposes must be a list."
|
||||||
|
)
|
||||||
return cls(
|
return cls(
|
||||||
subject_kind=_text(value, "subject_kind"),
|
subject_kind=_text(value, "subject_kind"),
|
||||||
subject_id=_text(value, "subject_id"),
|
subject_id=_text(value, "subject_id"),
|
||||||
permissions=tuple(str(item) for item in raw_permissions),
|
permissions=tuple(str(item) for item in raw_permissions),
|
||||||
|
allowed_purposes=tuple(str(item) for item in raw_purposes),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ def _matching_grants(
|
|||||||
for kind, value in (
|
for kind, value in (
|
||||||
("account", selectors.account_id),
|
("account", selectors.account_id),
|
||||||
("identity", selectors.identity_id),
|
("identity", selectors.identity_id),
|
||||||
|
("membership", selectors.membership_id),
|
||||||
):
|
):
|
||||||
if value:
|
if value:
|
||||||
conditions.append(
|
conditions.append(
|
||||||
@@ -431,6 +432,10 @@ def _grant_subject_matches(
|
|||||||
return bool(
|
return bool(
|
||||||
(row.subject_kind == "account" and row.subject_id == selectors.account_id)
|
(row.subject_kind == "account" and row.subject_id == selectors.account_id)
|
||||||
or (row.subject_kind == "identity" and row.subject_id == selectors.identity_id)
|
or (row.subject_kind == "identity" and row.subject_id == selectors.identity_id)
|
||||||
|
or (
|
||||||
|
row.subject_kind == "membership"
|
||||||
|
and row.subject_id == selectors.membership_id
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -531,6 +536,9 @@ def _access_grant_record(row: CaseAccessGrant) -> DsarRecordRef:
|
|||||||
"subject_kind": row.subject_kind,
|
"subject_kind": row.subject_kind,
|
||||||
"subject_id": row.subject_id,
|
"subject_id": row.subject_id,
|
||||||
"permissions": [str(value)[:40] for value in row.permissions[:20]],
|
"permissions": [str(value)[:40] for value in row.permissions[:20]],
|
||||||
|
"allowed_purposes": [
|
||||||
|
str(value)[:255] for value in row.allowed_purposes[:100]
|
||||||
|
],
|
||||||
"source": _bounded_text(row.source, 30),
|
"source": _bounded_text(row.source, 30),
|
||||||
"active": row.active,
|
"active": row.active,
|
||||||
"source_revision": row.source_revision,
|
"source_revision": row.source_revision,
|
||||||
@@ -562,6 +570,7 @@ def _timeline_record(
|
|||||||
"match_fields": list(match_fields),
|
"match_fields": list(match_fields),
|
||||||
"case_id": row.case_id,
|
"case_id": row.case_id,
|
||||||
"event_type": _bounded_text(row.event_type, 120),
|
"event_type": _bounded_text(row.event_type, 120),
|
||||||
|
"purpose": _bounded_text(row.purpose, 255),
|
||||||
"case_revision": row.case_revision,
|
"case_revision": row.case_revision,
|
||||||
"occurred_at": _iso(row.occurred_at),
|
"occurred_at": _iso(row.occurred_at),
|
||||||
"actor_id": row.actor_id if expose_actor else None,
|
"actor_id": row.actor_id if expose_actor else None,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from urllib.parse import parse_qs, quote, urlsplit
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.core.institutional import EvidenceReference
|
from govoplan_core.core.institutional import EvidenceReference
|
||||||
from govoplan_cases.backend.domain import CaseRecord
|
from govoplan_cases.backend.domain import DEFAULT_CASE_ACCESS_PURPOSE, CaseRecord
|
||||||
from govoplan_cases.backend.service import CaseStoreError, get_case, update_case
|
from govoplan_cases.backend.service import CaseStoreError, get_case, update_case
|
||||||
|
|
||||||
|
|
||||||
@@ -31,12 +31,14 @@ def link_case_evidence(
|
|||||||
recorded_at: datetime,
|
recorded_at: datetime,
|
||||||
change_reason: str,
|
change_reason: str,
|
||||||
idempotency_key: str,
|
idempotency_key: str,
|
||||||
|
purpose: str = DEFAULT_CASE_ACCESS_PURPOSE,
|
||||||
) -> CaseRecord:
|
) -> CaseRecord:
|
||||||
base = _base_revision(
|
base = _base_revision(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
case_id=case_id,
|
case_id=case_id,
|
||||||
expected_revision=expected_revision,
|
expected_revision=expected_revision,
|
||||||
|
purpose=purpose,
|
||||||
)
|
)
|
||||||
evidence = _validated_evidence(reference, tenant_id=base.reference.tenant_id)
|
evidence = _validated_evidence(reference, tenant_id=base.reference.tenant_id)
|
||||||
if any(item.evidence_id == evidence.evidence_id for item in base.evidence_refs):
|
if any(item.evidence_id == evidence.evidence_id for item in base.evidence_refs):
|
||||||
@@ -50,6 +52,7 @@ def link_case_evidence(
|
|||||||
recorded_at=recorded_at,
|
recorded_at=recorded_at,
|
||||||
change_reason=change_reason,
|
change_reason=change_reason,
|
||||||
idempotency_key=idempotency_key,
|
idempotency_key=idempotency_key,
|
||||||
|
purpose=purpose,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -63,12 +66,14 @@ def unlink_case_evidence(
|
|||||||
recorded_at: datetime,
|
recorded_at: datetime,
|
||||||
change_reason: str,
|
change_reason: str,
|
||||||
idempotency_key: str,
|
idempotency_key: str,
|
||||||
|
purpose: str = DEFAULT_CASE_ACCESS_PURPOSE,
|
||||||
) -> CaseRecord:
|
) -> CaseRecord:
|
||||||
base = _base_revision(
|
base = _base_revision(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
case_id=case_id,
|
case_id=case_id,
|
||||||
expected_revision=expected_revision,
|
expected_revision=expected_revision,
|
||||||
|
purpose=purpose,
|
||||||
)
|
)
|
||||||
evidence = next(
|
evidence = next(
|
||||||
(item for item in base.evidence_refs if item.evidence_id == evidence_id),
|
(item for item in base.evidence_refs if item.evidence_id == evidence_id),
|
||||||
@@ -89,6 +94,7 @@ def unlink_case_evidence(
|
|||||||
recorded_at=recorded_at,
|
recorded_at=recorded_at,
|
||||||
change_reason=change_reason,
|
change_reason=change_reason,
|
||||||
idempotency_key=idempotency_key,
|
idempotency_key=idempotency_key,
|
||||||
|
purpose=purpose,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -119,12 +125,14 @@ def _base_revision(
|
|||||||
*,
|
*,
|
||||||
case_id: str,
|
case_id: str,
|
||||||
expected_revision: int,
|
expected_revision: int,
|
||||||
|
purpose: str,
|
||||||
) -> CaseRecord:
|
) -> CaseRecord:
|
||||||
base = get_case(
|
base = get_case(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
case_id=case_id,
|
case_id=case_id,
|
||||||
revision=expected_revision,
|
revision=expected_revision,
|
||||||
|
purpose=purpose,
|
||||||
)
|
)
|
||||||
if base is None:
|
if base is None:
|
||||||
raise LookupError("Case revision not found.")
|
raise LookupError("Case revision not found.")
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ from govoplan_core.core.institutional import (
|
|||||||
CAPABILITY_MANDATE_RESOLVER,
|
CAPABILITY_MANDATE_RESOLVER,
|
||||||
CAPABILITY_PARTY_RESOLVER,
|
CAPABILITY_PARTY_RESOLVER,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.information_governance import (
|
||||||
|
InformationGovernanceDimension,
|
||||||
|
ModuleInformationGovernance,
|
||||||
|
)
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
CapabilityDocumentation,
|
CapabilityDocumentation,
|
||||||
DocumentationCondition,
|
DocumentationCondition,
|
||||||
@@ -72,7 +76,7 @@ from govoplan_core.db.base import Base
|
|||||||
|
|
||||||
|
|
||||||
MODULE_ID = "cases"
|
MODULE_ID = "cases"
|
||||||
MODULE_VERSION = "0.1.22"
|
MODULE_VERSION = "0.1.23"
|
||||||
READ_SCOPE = "cases:case:read"
|
READ_SCOPE = "cases:case:read"
|
||||||
CREATE_SCOPE = "cases:case:create"
|
CREATE_SCOPE = "cases:case:create"
|
||||||
UPDATE_SCOPE = "cases:case:update"
|
UPDATE_SCOPE = "cases:case:update"
|
||||||
@@ -819,6 +823,45 @@ manifest = ModuleManifest(
|
|||||||
"outcome": "The eAkte preserves an exact case snapshot reference while Cases retains authority.",
|
"outcome": "The eAkte preserves an exact case snapshot reference while Cases retains authority.",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="cases.governance.purpose-bound-access",
|
||||||
|
title="Purpose-bound access to restricted cases",
|
||||||
|
summary="Require a current subject grant, sufficient action, and one exact declared purpose before disclosing or changing a restricted case.",
|
||||||
|
body=(
|
||||||
|
"Tenant-visible cases continue to follow the Cases read permission. For a restricted case, every list, detail, history, timeline, update, share, evidence, Decision, Search, and Records-source path evaluates current grants against the acting account, membership, group, role, function, assignment, organization unit, service account, or delegation. The requested action and exact purpose must both match; missing or different purposes fail closed. cases.casework is the stable interactive default, cases.search is used only by Search authorization rechecks, and a Records filing request supplies its own declared purpose. Creating or newly restricting a case writes a purpose-bound custodian grant for the attributable actor. Assignment-derived grants permit cases.casework, cases.search, and cases.records. Explicit grants list their purposes separately and can be changed through the confirmed access dialog. Current grants always govern historical revision reads, so removed access cannot be recovered by selecting an older revision. Every accepted lifecycle event records the evaluated purpose. Retention schedules, legal holds, and disposition gates are not yet Cases-owned and remain a documented limitation."
|
||||||
|
),
|
||||||
|
layer="available",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "case_manager", "module_admin", "auditor"),
|
||||||
|
related_modules=("search", "records", "access"),
|
||||||
|
order=16,
|
||||||
|
links=(
|
||||||
|
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Cases concept",
|
||||||
|
href="govoplan-cases/docs/CONCEPT.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Zweckgebundener Zugriff auf eingeschränkte Vorgänge",
|
||||||
|
"summary": "Vor Offenlegung oder Änderung eines eingeschränkten Vorgangs eine aktuelle Subjektfreigabe, ausreichende Aktion und einen exakt angegebenen Zweck verlangen.",
|
||||||
|
"body": (
|
||||||
|
"Mandantenweit sichtbare Vorgänge folgen weiterhin der Leseberechtigung von Cases. Bei einem eingeschränkten Vorgang prüfen Listen-, Detail-, Historien-, Timeline-, Änderungs-, Freigabe-, Nachweis-, Decision-, Search- und Records-Quellpfade aktuelle Freigaben gegen das handelnde Konto, die Mitgliedschaft, Gruppe, Rolle, Funktion, Funktionszuweisung, Organisationseinheit, das Dienstkonto oder die Delegation. Angeforderte Aktion und exakter Zweck müssen übereinstimmen; fehlende oder abweichende Zwecke schließen sicher. cases.casework ist der stabile interaktive Standard, cases.search wird ausschließlich für erneute Search-Autorisierungen verwendet und eine Records-Ablage übergibt ihren eigenen angegebenen Zweck. Beim Anlegen oder erstmaligen Einschränken entsteht eine zweckgebundene Custodian-Freigabe für die zurechenbare handelnde Person. Aus Zuweisungen abgeleitete Freigaben erlauben cases.casework, cases.search und cases.records. Ausdrückliche Freigaben führen ihre Zwecke getrennt und werden im bestätigten Zugriffsdialog geändert. Aktuelle Freigaben gelten stets auch für historische Revisionsabrufe; entzogener Zugriff kann daher nicht über eine ältere Revision wiederhergestellt werden. Jedes akzeptierte Lebenszyklusereignis hält den ausgewerteten Zweck fest. Aufbewahrungspläne, Legal Holds und Aussonderungssperren gehören noch nicht Cases und bleiben als Einschränkung dokumentiert."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": [
|
||||||
|
"cases.page.catalogue",
|
||||||
|
"cases.detail.access",
|
||||||
|
"cases.field.access-purpose",
|
||||||
|
"cases.field.allowed-purposes",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="cases.institutional-context",
|
id="cases.institutional-context",
|
||||||
title="Case institutional context",
|
title="Case institutional context",
|
||||||
@@ -881,8 +924,8 @@ manifest = ModuleManifest(
|
|||||||
"appends a timeline entry with actor, time, and change reason; a terminal "
|
"appends a timeline entry with actor, time, and change reason; a terminal "
|
||||||
"status additionally requires the case-close permission. Case visibility is "
|
"status additionally requires the case-close permission. Case visibility is "
|
||||||
"tenant-wide or restricted. Restricted cases remain visible only through "
|
"tenant-wide or restricted. Restricted cases remain visible only through "
|
||||||
"administrative authority, assignment or unit context, creator authority, or "
|
"a current purpose-bound custodian, assignment/unit context, or explicit "
|
||||||
"an explicit account/group grant. Access changes append another immutable "
|
"subject grant. Permission and exact allowed purpose must both match. Access changes append another immutable "
|
||||||
"revision and require confirmation. Service, party, assignment, Decision, and "
|
"revision and require confirmation. Service, party, assignment, Decision, and "
|
||||||
"record references identify provider-owned objects; Cases preserves their "
|
"record references identify provider-owned objects; Cases preserves their "
|
||||||
"stable identifiers and versions without copying or silently changing them."
|
"stable identifiers and versions without copying or silently changing them."
|
||||||
@@ -907,8 +950,8 @@ manifest = ModuleManifest(
|
|||||||
"Änderungen an Titel und Status ergänzen eine unveränderliche Vorgangsrevision, geschützt durch erwartete Revision und "
|
"Änderungen an Titel und Status ergänzen eine unveränderliche Vorgangsrevision, geschützt durch erwartete Revision und "
|
||||||
"stabilen Idempotenzschlüssel. Jede akzeptierte Änderung ergänzt außerdem einen Timeline-Eintrag mit handelnder Person, "
|
"stabilen Idempotenzschlüssel. Jede akzeptierte Änderung ergänzt außerdem einen Timeline-Eintrag mit handelnder Person, "
|
||||||
"Zeitpunkt und Änderungsgrund; ein abschließender Status verlangt zusätzlich die Berechtigung zum Schließen. Die Sichtbarkeit "
|
"Zeitpunkt und Änderungsgrund; ein abschließender Status verlangt zusätzlich die Berechtigung zum Schließen. Die Sichtbarkeit "
|
||||||
"eines Vorgangs ist mandantenweit oder eingeschränkt. Eingeschränkte Vorgänge bleiben nur durch administrative Befugnis, "
|
"eines Vorgangs ist mandantenweit oder eingeschränkt. Eingeschränkte Vorgänge bleiben nur über eine aktuelle zweckgebundene "
|
||||||
"Zuweisungs- oder Einheitenkontext, Erstellerbefugnis oder eine ausdrückliche Konto-/Gruppenfreigabe sichtbar. "
|
"Custodian-, Zuweisungs-/Einheiten- oder ausdrückliche Subjektfreigabe sichtbar; Berechtigung und exakter erlaubter Zweck müssen übereinstimmen. "
|
||||||
"Zugriffsänderungen ergänzen eine weitere unveränderliche Revision und verlangen Bestätigung. Service-, Beteiligten-, "
|
"Zugriffsänderungen ergänzen eine weitere unveränderliche Revision und verlangen Bestätigung. Service-, Beteiligten-, "
|
||||||
"Zuweisungs-, Decision- und Aktenverweise bezeichnen Objekte anderer Provider; Cases bewahrt ihre stabilen Kennungen und "
|
"Zuweisungs-, Decision- und Aktenverweise bezeichnen Objekte anderer Provider; Cases bewahrt ihre stabilen Kennungen und "
|
||||||
"Versionen, ohne sie zu kopieren oder stillschweigend zu verändern."
|
"Versionen, ohne sie zu kopieren oder stillschweigend zu verändern."
|
||||||
@@ -933,6 +976,47 @@ manifest = ModuleManifest(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
information_governance=ModuleInformationGovernance(
|
||||||
|
temporal_browsing=InformationGovernanceDimension(
|
||||||
|
adoption="enforced",
|
||||||
|
object_types=("case", "case_revision", "case_timeline_event"),
|
||||||
|
evidence=(
|
||||||
|
"src/govoplan_cases/backend/service.py",
|
||||||
|
"tests/test_case_lifecycle.py",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
purpose_aware_access=InformationGovernanceDimension(
|
||||||
|
adoption="enforced",
|
||||||
|
object_types=("case", "case_access_grant", "case_timeline_event"),
|
||||||
|
evidence=(
|
||||||
|
"src/govoplan_cases/backend/service.py",
|
||||||
|
"src/govoplan_cases/backend/search_source.py",
|
||||||
|
"src/govoplan_cases/backend/record_source.py",
|
||||||
|
"tests/test_case_lifecycle.py",
|
||||||
|
"tests/test_search_source.py",
|
||||||
|
"tests/test_record_source.py",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
retention=InformationGovernanceDimension(
|
||||||
|
adoption="contract_only",
|
||||||
|
object_types=("case", "case_revision", "case_timeline_event"),
|
||||||
|
limitation=(
|
||||||
|
"Cases preserves immutable history and DSAR retention evidence but does not yet apply configurable schedules, legal holds, appraisal, or disposition gates."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
institutional_context=InformationGovernanceDimension(
|
||||||
|
adoption="enforced",
|
||||||
|
object_types=("case", "case_revision", "case_timeline_event"),
|
||||||
|
evidence=(
|
||||||
|
"src/govoplan_cases/backend/domain.py",
|
||||||
|
"src/govoplan_cases/backend/service.py",
|
||||||
|
"src/govoplan_cases/backend/decision_path.py",
|
||||||
|
"tests/test_case_lifecycle.py",
|
||||||
|
"tests/test_institutional_consumers.py",
|
||||||
|
"tests/test_decision_path.py",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
architecture=ModuleArchitectureDeclaration(
|
architecture=ModuleArchitectureDeclaration(
|
||||||
layer="human_work_procedure",
|
layer="human_work_procedure",
|
||||||
kind="domain",
|
kind="domain",
|
||||||
|
|||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
"""v0.1.23 purpose-bound case access.
|
||||||
|
|
||||||
|
Revision ID: a7c4e2f9b1d6
|
||||||
|
Revises: f6d3a8b1c4e7
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "a7c4e2f9b1d6"
|
||||||
|
down_revision = "f6d3a8b1c4e7"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"case_access_grants",
|
||||||
|
sa.Column(
|
||||||
|
"allowed_purposes",
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=False,
|
||||||
|
server_default='["cases.casework"]',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"case_timeline_entries",
|
||||||
|
sa.Column(
|
||||||
|
"purpose",
|
||||||
|
sa.String(length=255),
|
||||||
|
nullable=False,
|
||||||
|
server_default="cases.casework",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_case_timeline_entries_purpose"),
|
||||||
|
"case_timeline_entries",
|
||||||
|
["purpose"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_case_timeline_entries_purpose"),
|
||||||
|
table_name="case_timeline_entries",
|
||||||
|
)
|
||||||
|
op.drop_column("case_timeline_entries", "purpose")
|
||||||
|
op.drop_column("case_access_grants", "allowed_purposes")
|
||||||
@@ -49,7 +49,11 @@ class CasesRecordSource:
|
|||||||
):
|
):
|
||||||
raise RecordContractError("Current Cases read permission is required.")
|
raise RecordContractError("Current Cases read permission is required.")
|
||||||
if not can_access_case(
|
if not can_access_case(
|
||||||
session, principal, case_id=locator.resource_id, permission="read"
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=locator.resource_id,
|
||||||
|
permission="read",
|
||||||
|
purpose=purpose,
|
||||||
):
|
):
|
||||||
raise RecordContractError("The current principal cannot read this case.")
|
raise RecordContractError("The current principal cannot read this case.")
|
||||||
try:
|
try:
|
||||||
@@ -93,7 +97,10 @@ class CasesRecordSource:
|
|||||||
valid_from=row.opened_at,
|
valid_from=row.opened_at,
|
||||||
valid_to=row.closed_at,
|
valid_to=row.closed_at,
|
||||||
recorded_at=row.recorded_at,
|
recorded_at=row.recorded_at,
|
||||||
launch_url=f"/cases/{quote(row.case_id, safe='')}",
|
launch_url=(
|
||||||
|
f"/cases/{quote(row.case_id, safe='')}"
|
||||||
|
f"?purpose={quote(purpose, safe='')}"
|
||||||
|
),
|
||||||
metadata={
|
metadata={
|
||||||
"case_number": identity.case_number,
|
"case_number": identity.case_number,
|
||||||
"case_type_key": row.case_type_key,
|
"case_type_key": row.case_type_key,
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ def api_list_cases(
|
|||||||
query: str = "",
|
query: str = "",
|
||||||
status_key: list[str] | None = Query(default=None),
|
status_key: list[str] | None = Query(default=None),
|
||||||
case_type_key: list[str] | None = Query(default=None),
|
case_type_key: list[str] | None = Query(default=None),
|
||||||
|
purpose: str | None = Query(default=None, max_length=255),
|
||||||
offset: int = Query(default=0, ge=0),
|
offset: int = Query(default=0, ge=0),
|
||||||
limit: int = Query(default=100, ge=1, le=200),
|
limit: int = Query(default=100, ge=1, le=200),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
@@ -187,6 +188,7 @@ def api_list_cases(
|
|||||||
query=query,
|
query=query,
|
||||||
status_keys=status_key,
|
status_keys=status_key,
|
||||||
case_type_keys=case_type_key,
|
case_type_keys=case_type_key,
|
||||||
|
purpose=purpose,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
@@ -216,6 +218,7 @@ def api_create_case(
|
|||||||
principal,
|
principal,
|
||||||
record=record,
|
record=record,
|
||||||
idempotency_key=payload.idempotency_key,
|
idempotency_key=payload.idempotency_key,
|
||||||
|
purpose=payload.purpose,
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
except (CaseStoreError, InstitutionalContextError) as exc:
|
except (CaseStoreError, InstitutionalContextError) as exc:
|
||||||
@@ -227,6 +230,7 @@ def api_create_case(
|
|||||||
@router.get("/{case_id}/decisions", response_model=dict[str, list[dict[str, Any]]])
|
@router.get("/{case_id}/decisions", response_model=dict[str, list[dict[str, Any]]])
|
||||||
def api_case_decisions(
|
def api_case_decisions(
|
||||||
case_id: str,
|
case_id: str,
|
||||||
|
purpose: str | None = Query(default=None, max_length=255),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> dict[str, list[dict[str, Any]]]:
|
) -> dict[str, list[dict[str, Any]]]:
|
||||||
@@ -237,6 +241,7 @@ def api_case_decisions(
|
|||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
case_id=case_id,
|
case_id=case_id,
|
||||||
|
purpose=purpose,
|
||||||
)
|
)
|
||||||
except (
|
except (
|
||||||
CaseDecisionError,
|
CaseDecisionError,
|
||||||
@@ -281,6 +286,7 @@ def api_record_case_decision(
|
|||||||
conditions=tuple(payload.conditions),
|
conditions=tuple(payload.conditions),
|
||||||
change_reason=payload.change_reason,
|
change_reason=payload.change_reason,
|
||||||
idempotency_key=payload.idempotency_key,
|
idempotency_key=payload.idempotency_key,
|
||||||
|
purpose=payload.purpose,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -300,11 +306,18 @@ def api_record_case_decision(
|
|||||||
def api_get_case(
|
def api_get_case(
|
||||||
case_id: str,
|
case_id: str,
|
||||||
revision: int | None = Query(default=None, ge=1),
|
revision: int | None = Query(default=None, ge=1),
|
||||||
|
purpose: str | None = Query(default=None, max_length=255),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
_require(principal, READ_SCOPE)
|
_require(principal, READ_SCOPE)
|
||||||
item = get_case(session, principal, case_id=case_id, revision=revision)
|
item = get_case(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
revision=revision,
|
||||||
|
purpose=purpose,
|
||||||
|
)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail="Case not found")
|
raise HTTPException(status_code=404, detail="Case not found")
|
||||||
return item.to_dict()
|
return item.to_dict()
|
||||||
@@ -342,6 +355,7 @@ def api_update_case(
|
|||||||
recorded_at=payload.recorded_at,
|
recorded_at=payload.recorded_at,
|
||||||
change_reason=payload.change_reason,
|
change_reason=payload.change_reason,
|
||||||
idempotency_key=payload.idempotency_key,
|
idempotency_key=payload.idempotency_key,
|
||||||
|
purpose=payload.purpose,
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
except (
|
except (
|
||||||
@@ -373,6 +387,7 @@ def api_link_case_evidence(
|
|||||||
recorded_at=payload.recorded_at,
|
recorded_at=payload.recorded_at,
|
||||||
change_reason=payload.change_reason,
|
change_reason=payload.change_reason,
|
||||||
idempotency_key=payload.idempotency_key,
|
idempotency_key=payload.idempotency_key,
|
||||||
|
purpose=payload.purpose,
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
except (
|
except (
|
||||||
@@ -408,6 +423,7 @@ def api_unlink_case_evidence(
|
|||||||
recorded_at=payload.recorded_at,
|
recorded_at=payload.recorded_at,
|
||||||
change_reason=payload.change_reason,
|
change_reason=payload.change_reason,
|
||||||
idempotency_key=payload.idempotency_key,
|
idempotency_key=payload.idempotency_key,
|
||||||
|
purpose=payload.purpose,
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
except (
|
except (
|
||||||
@@ -432,6 +448,7 @@ def api_case_share_target_options(
|
|||||||
selected: list[str] = Query(default=[]),
|
selected: list[str] = Query(default=[]),
|
||||||
limit: int = Query(default=50, ge=1, le=200),
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
cursor: str | None = None,
|
cursor: str | None = None,
|
||||||
|
purpose: str = Query(default="cases.casework", min_length=1, max_length=255),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> ReferenceOptionListResponse:
|
) -> ReferenceOptionListResponse:
|
||||||
@@ -441,6 +458,7 @@ def api_case_share_target_options(
|
|||||||
principal,
|
principal,
|
||||||
case_id=case_id,
|
case_id=case_id,
|
||||||
permission="share",
|
permission="share",
|
||||||
|
purpose=purpose,
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=403, detail="Case share access is denied")
|
raise HTTPException(status_code=403, detail="Case share access is denied")
|
||||||
try:
|
try:
|
||||||
@@ -470,6 +488,7 @@ def api_case_share_target_options(
|
|||||||
def api_case_history(
|
def api_case_history(
|
||||||
case_id: str,
|
case_id: str,
|
||||||
limit: int = Query(default=100, ge=1, le=200),
|
limit: int = Query(default=100, ge=1, le=200),
|
||||||
|
purpose: str | None = Query(default=None, max_length=255),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> CaseHistoryResponse:
|
) -> CaseHistoryResponse:
|
||||||
@@ -482,6 +501,7 @@ def api_case_history(
|
|||||||
principal,
|
principal,
|
||||||
case_id=case_id,
|
case_id=case_id,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
purpose=purpose,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -491,6 +511,7 @@ def api_case_history(
|
|||||||
def api_case_timeline(
|
def api_case_timeline(
|
||||||
case_id: str,
|
case_id: str,
|
||||||
limit: int = Query(default=200, ge=1, le=500),
|
limit: int = Query(default=200, ge=1, le=500),
|
||||||
|
purpose: str | None = Query(default=None, max_length=255),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> CaseTimelineResponse:
|
) -> CaseTimelineResponse:
|
||||||
@@ -502,6 +523,7 @@ def api_case_timeline(
|
|||||||
principal,
|
principal,
|
||||||
case_id=case_id,
|
case_id=case_id,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
purpose=purpose,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -513,6 +535,7 @@ def _update_changes(payload: CaseUpdateRequest) -> dict[str, object]:
|
|||||||
"recorded_at",
|
"recorded_at",
|
||||||
"change_reason",
|
"change_reason",
|
||||||
"idempotency_key",
|
"idempotency_key",
|
||||||
|
"purpose",
|
||||||
}
|
}
|
||||||
raw = payload.model_dump(exclude_unset=True, exclude=excluded)
|
raw = payload.model_dump(exclude_unset=True, exclude=excluded)
|
||||||
for key in ("party_refs", "assignment_refs", "decision_refs", "record_refs"):
|
for key in ("party_refs", "assignment_refs", "decision_refs", "record_refs"):
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class CaseWriteRequest(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
record: dict[str, Any]
|
record: dict[str, Any]
|
||||||
|
purpose: str = Field(default="cases.casework", min_length=1, max_length=255)
|
||||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
@@ -43,12 +44,14 @@ class CaseGrantRequest(BaseModel):
|
|||||||
subject_kind: Literal[
|
subject_kind: Literal[
|
||||||
"account",
|
"account",
|
||||||
"identity",
|
"identity",
|
||||||
|
"membership",
|
||||||
"group",
|
"group",
|
||||||
"role",
|
"role",
|
||||||
"function",
|
"function",
|
||||||
"function_assignment",
|
"function_assignment",
|
||||||
"organization_unit",
|
"organization_unit",
|
||||||
"service_account",
|
"service_account",
|
||||||
|
"delegation",
|
||||||
]
|
]
|
||||||
subject_id: str = Field(min_length=1, max_length=255)
|
subject_id: str = Field(min_length=1, max_length=255)
|
||||||
permissions: list[Literal["read", "update", "share", "admin"]] = Field(
|
permissions: list[Literal["read", "update", "share", "admin"]] = Field(
|
||||||
@@ -56,6 +59,11 @@ class CaseGrantRequest(BaseModel):
|
|||||||
min_length=1,
|
min_length=1,
|
||||||
max_length=4,
|
max_length=4,
|
||||||
)
|
)
|
||||||
|
allowed_purposes: list[str] = Field(
|
||||||
|
default_factory=lambda: ["cases.casework"],
|
||||||
|
min_length=1,
|
||||||
|
max_length=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CaseUpdateRequest(BaseModel):
|
class CaseUpdateRequest(BaseModel):
|
||||||
@@ -65,6 +73,7 @@ class CaseUpdateRequest(BaseModel):
|
|||||||
recorded_at: datetime
|
recorded_at: datetime
|
||||||
change_reason: str = Field(min_length=1, max_length=1_000)
|
change_reason: str = Field(min_length=1, max_length=1_000)
|
||||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
purpose: str = Field(default="cases.casework", min_length=1, max_length=255)
|
||||||
status_key: str | None = Field(default=None, min_length=1, max_length=120)
|
status_key: str | None = Field(default=None, min_length=1, max_length=120)
|
||||||
title: str | None = Field(default=None, min_length=1, max_length=500)
|
title: str | None = Field(default=None, min_length=1, max_length=500)
|
||||||
access_mode: Literal["tenant", "restricted"] | None = None
|
access_mode: Literal["tenant", "restricted"] | None = None
|
||||||
@@ -100,6 +109,7 @@ class CaseEvidenceLinkRequest(BaseModel):
|
|||||||
recorded_at: datetime
|
recorded_at: datetime
|
||||||
change_reason: str = Field(min_length=1, max_length=1_000)
|
change_reason: str = Field(min_length=1, max_length=1_000)
|
||||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
purpose: str = Field(default="cases.casework", min_length=1, max_length=255)
|
||||||
reference: CaseOwnerReferenceRequest
|
reference: CaseOwnerReferenceRequest
|
||||||
|
|
||||||
|
|
||||||
@@ -110,6 +120,7 @@ class CaseEvidenceUnlinkRequest(BaseModel):
|
|||||||
recorded_at: datetime
|
recorded_at: datetime
|
||||||
change_reason: str = Field(min_length=1, max_length=1_000)
|
change_reason: str = Field(min_length=1, max_length=1_000)
|
||||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
purpose: str = Field(default="cases.casework", min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
class CaseDecisionRequest(BaseModel):
|
class CaseDecisionRequest(BaseModel):
|
||||||
@@ -123,6 +134,7 @@ class CaseDecisionRequest(BaseModel):
|
|||||||
conditions: list[str] = Field(default_factory=list, max_length=100)
|
conditions: list[str] = Field(default_factory=list, max_length=100)
|
||||||
change_reason: str = Field(min_length=1, max_length=1_000)
|
change_reason: str = Field(min_length=1, max_length=1_000)
|
||||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
purpose: str = Field(default="cases.casework", min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
class CaseListResponse(BaseModel):
|
class CaseListResponse(BaseModel):
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ PROVIDER_ID = "cases.cases"
|
|||||||
RESOURCE_TYPE = "case"
|
RESOURCE_TYPE = "case"
|
||||||
READ_SCOPE = "cases:case:read"
|
READ_SCOPE = "cases:case:read"
|
||||||
ADMIN_SCOPE = "cases:case:admin"
|
ADMIN_SCOPE = "cases:case:admin"
|
||||||
|
SEARCH_ACCESS_PURPOSE = "cases.search"
|
||||||
|
|
||||||
|
|
||||||
class CasesSearchSource:
|
class CasesSearchSource:
|
||||||
@@ -118,6 +119,7 @@ class CasesSearchSource:
|
|||||||
principal,
|
principal,
|
||||||
case_id=reference.resource_id,
|
case_id=reference.resource_id,
|
||||||
permission="read",
|
permission="read",
|
||||||
|
purpose=SEARCH_ACCESS_PURPOSE,
|
||||||
)
|
)
|
||||||
return decisions
|
return decisions
|
||||||
|
|
||||||
@@ -219,7 +221,10 @@ def _document(
|
|||||||
resource_type=RESOURCE_TYPE,
|
resource_type=RESOURCE_TYPE,
|
||||||
resource_id=row.case_id,
|
resource_id=row.case_id,
|
||||||
title=row.title,
|
title=row.title,
|
||||||
url=f"/cases/{quote(row.case_id, safe='')}",
|
url=(
|
||||||
|
f"/cases/{quote(row.case_id, safe='')}"
|
||||||
|
f"?purpose={quote(SEARCH_ACCESS_PURPOSE, safe='')}"
|
||||||
|
),
|
||||||
summary=f"{identity.case_number} - {row.status_key}",
|
summary=f"{identity.case_number} - {row.status_key}",
|
||||||
body=row.search_text[:200_000],
|
body=row.search_text[:200_000],
|
||||||
keywords=(
|
keywords=(
|
||||||
@@ -279,5 +284,6 @@ __all__ = [
|
|||||||
"CasesSearchSource",
|
"CasesSearchSource",
|
||||||
"PROVIDER_ID",
|
"PROVIDER_ID",
|
||||||
"RESOURCE_TYPE",
|
"RESOURCE_TYPE",
|
||||||
|
"SEARCH_ACCESS_PURPOSE",
|
||||||
"create_cases_search_source",
|
"create_cases_search_source",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import json
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import and_, exists, func, or_
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.core.events import (
|
from govoplan_core.core.events import (
|
||||||
@@ -32,7 +32,11 @@ from govoplan_cases.backend.db.models import (
|
|||||||
CaseTimelineEntry,
|
CaseTimelineEntry,
|
||||||
CaseTypeDefinition,
|
CaseTypeDefinition,
|
||||||
)
|
)
|
||||||
from govoplan_cases.backend.domain import CaseGrant, CaseRecord
|
from govoplan_cases.backend.domain import (
|
||||||
|
DEFAULT_CASE_ACCESS_PURPOSE,
|
||||||
|
CaseGrant,
|
||||||
|
CaseRecord,
|
||||||
|
)
|
||||||
from govoplan_cases.backend.service_intake import CaseIntakePlan
|
from govoplan_cases.backend.service_intake import CaseIntakePlan
|
||||||
|
|
||||||
|
|
||||||
@@ -222,8 +226,10 @@ def create_case(
|
|||||||
*,
|
*,
|
||||||
record: CaseRecord,
|
record: CaseRecord,
|
||||||
idempotency_key: str,
|
idempotency_key: str,
|
||||||
|
purpose: str = DEFAULT_CASE_ACCESS_PURPOSE,
|
||||||
) -> CaseRecord:
|
) -> CaseRecord:
|
||||||
tenant_id = _principal_tenant(principal)
|
tenant_id = _principal_tenant(principal)
|
||||||
|
declared_purpose = _access_purpose(purpose)
|
||||||
if record.reference.tenant_id != tenant_id:
|
if record.reference.tenant_id != tenant_id:
|
||||||
raise CaseStoreError("Case records cannot cross tenants.")
|
raise CaseStoreError("Case records cannot cross tenants.")
|
||||||
if record.revision != 1:
|
if record.revision != 1:
|
||||||
@@ -241,6 +247,7 @@ def create_case(
|
|||||||
principal,
|
principal,
|
||||||
case_id=replay.reference.object_id,
|
case_id=replay.reference.object_id,
|
||||||
permission="read",
|
permission="read",
|
||||||
|
purpose=declared_purpose,
|
||||||
):
|
):
|
||||||
raise PermissionError("Case replay access is denied.")
|
raise PermissionError("Case replay access is denied.")
|
||||||
return replay
|
return replay
|
||||||
@@ -274,6 +281,7 @@ def create_case(
|
|||||||
idempotency_key=idempotency_key,
|
idempotency_key=idempotency_key,
|
||||||
request_sha256=request_sha256,
|
request_sha256=request_sha256,
|
||||||
operation="created",
|
operation="created",
|
||||||
|
purpose=declared_purpose,
|
||||||
)
|
)
|
||||||
return persisted
|
return persisted
|
||||||
|
|
||||||
@@ -353,8 +361,10 @@ def update_case(
|
|||||||
recorded_at: datetime,
|
recorded_at: datetime,
|
||||||
change_reason: str,
|
change_reason: str,
|
||||||
idempotency_key: str,
|
idempotency_key: str,
|
||||||
|
purpose: str = DEFAULT_CASE_ACCESS_PURPOSE,
|
||||||
) -> CaseRecord:
|
) -> CaseRecord:
|
||||||
tenant_id = _principal_tenant(principal)
|
tenant_id = _principal_tenant(principal)
|
||||||
|
declared_purpose = _access_purpose(purpose)
|
||||||
current_row = _current_row(
|
current_row = _current_row(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
@@ -363,7 +373,13 @@ def update_case(
|
|||||||
)
|
)
|
||||||
if current_row is None:
|
if current_row is None:
|
||||||
raise LookupError("Case not found.")
|
raise LookupError("Case not found.")
|
||||||
if not can_access_case(session, principal, case_id=case_id, permission="update"):
|
if not can_access_case(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
permission="update",
|
||||||
|
purpose=declared_purpose,
|
||||||
|
):
|
||||||
raise PermissionError("Case update access is denied.")
|
raise PermissionError("Case update access is denied.")
|
||||||
current = _record_from_row(current_row)
|
current = _record_from_row(current_row)
|
||||||
normalized = _normalized_changes(changes)
|
normalized = _normalized_changes(changes)
|
||||||
@@ -383,6 +399,14 @@ def update_case(
|
|||||||
request_sha256=request_sha256,
|
request_sha256=request_sha256,
|
||||||
)
|
)
|
||||||
if replay is not None:
|
if replay is not None:
|
||||||
|
if not can_access_case(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
permission="read",
|
||||||
|
purpose=declared_purpose,
|
||||||
|
):
|
||||||
|
raise PermissionError("Case replay access is denied.")
|
||||||
return replay
|
return replay
|
||||||
if current.revision != expected_revision:
|
if current.revision != expected_revision:
|
||||||
raise CaseStoreError(
|
raise CaseStoreError(
|
||||||
@@ -461,6 +485,7 @@ def update_case(
|
|||||||
idempotency_key=idempotency_key,
|
idempotency_key=idempotency_key,
|
||||||
request_sha256=request_sha256,
|
request_sha256=request_sha256,
|
||||||
operation="closed" if terminal and not current.closed_at else "updated",
|
operation="closed" if terminal and not current.closed_at else "updated",
|
||||||
|
purpose=declared_purpose,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -470,6 +495,7 @@ def get_case(
|
|||||||
*,
|
*,
|
||||||
case_id: str,
|
case_id: str,
|
||||||
revision: int | None = None,
|
revision: int | None = None,
|
||||||
|
purpose: str | None = None,
|
||||||
) -> CaseRecord | None:
|
) -> CaseRecord | None:
|
||||||
tenant_id = _principal_tenant(principal)
|
tenant_id = _principal_tenant(principal)
|
||||||
query = session.query(CaseRecordRevision).filter(
|
query = session.query(CaseRecordRevision).filter(
|
||||||
@@ -486,6 +512,7 @@ def get_case(
|
|||||||
principal,
|
principal,
|
||||||
case_id=case_id,
|
case_id=case_id,
|
||||||
permission="read",
|
permission="read",
|
||||||
|
purpose=purpose,
|
||||||
):
|
):
|
||||||
return None
|
return None
|
||||||
return _record_from_row(row) if row is not None else None
|
return _record_from_row(row) if row is not None else None
|
||||||
@@ -498,6 +525,7 @@ def list_cases(
|
|||||||
query: str = "",
|
query: str = "",
|
||||||
status_keys: Sequence[str] | None = None,
|
status_keys: Sequence[str] | None = None,
|
||||||
case_type_keys: Sequence[str] | None = None,
|
case_type_keys: Sequence[str] | None = None,
|
||||||
|
purpose: str | None = None,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
) -> tuple[tuple[CaseRecord, ...], int]:
|
) -> tuple[tuple[CaseRecord, ...], int]:
|
||||||
@@ -510,7 +538,13 @@ def list_cases(
|
|||||||
CaseRecordRevision.tenant_id == tenant_id,
|
CaseRecordRevision.tenant_id == tenant_id,
|
||||||
CaseRecordRevision.superseded_at.is_(None),
|
CaseRecordRevision.superseded_at.is_(None),
|
||||||
)
|
)
|
||||||
statement = _filter_accessible_cases(statement, principal, permission="read")
|
eligible = _eligible_case_ids(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
permission="read",
|
||||||
|
purpose=purpose,
|
||||||
|
)
|
||||||
|
statement = statement.filter(CaseRecordRevision.case_id.in_(eligible))
|
||||||
if status_keys:
|
if status_keys:
|
||||||
statement = statement.filter(
|
statement = statement.filter(
|
||||||
CaseRecordRevision.status_key.in_(tuple(status_keys))
|
CaseRecordRevision.status_key.in_(tuple(status_keys))
|
||||||
@@ -543,8 +577,15 @@ def case_history(
|
|||||||
*,
|
*,
|
||||||
case_id: str,
|
case_id: str,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
|
purpose: str | None = None,
|
||||||
) -> tuple[CaseRecord, ...]:
|
) -> tuple[CaseRecord, ...]:
|
||||||
if not can_access_case(session, principal, case_id=case_id, permission="read"):
|
if not can_access_case(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
permission="read",
|
||||||
|
purpose=purpose,
|
||||||
|
):
|
||||||
return ()
|
return ()
|
||||||
if not 1 <= limit <= 200:
|
if not 1 <= limit <= 200:
|
||||||
raise CaseStoreError("Case history limit must be between 1 and 200.")
|
raise CaseStoreError("Case history limit must be between 1 and 200.")
|
||||||
@@ -567,8 +608,15 @@ def case_timeline(
|
|||||||
*,
|
*,
|
||||||
case_id: str,
|
case_id: str,
|
||||||
limit: int = 200,
|
limit: int = 200,
|
||||||
|
purpose: str | None = None,
|
||||||
) -> tuple[dict[str, object], ...]:
|
) -> tuple[dict[str, object], ...]:
|
||||||
if not can_access_case(session, principal, case_id=case_id, permission="read"):
|
if not can_access_case(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
case_id=case_id,
|
||||||
|
permission="read",
|
||||||
|
purpose=purpose,
|
||||||
|
):
|
||||||
return ()
|
return ()
|
||||||
if not 1 <= limit <= 500:
|
if not 1 <= limit <= 500:
|
||||||
raise CaseStoreError("Case timeline limit must be between 1 and 500.")
|
raise CaseStoreError("Case timeline limit must be between 1 and 500.")
|
||||||
@@ -595,10 +643,21 @@ class SqlCaseRegistry:
|
|||||||
if reference.tenant_id != _principal_tenant(principal):
|
if reference.tenant_id != _principal_tenant(principal):
|
||||||
raise InstitutionalContextError("Case lookup cannot cross tenants.")
|
raise InstitutionalContextError("Case lookup cannot cross tenants.")
|
||||||
revision = int(reference.version) if reference.version else None
|
revision = int(reference.version) if reference.version else None
|
||||||
return get_case(_session(session), principal, case_id=reference.object_id, revision=revision)
|
return get_case(
|
||||||
|
_session(session),
|
||||||
|
principal,
|
||||||
|
case_id=reference.object_id,
|
||||||
|
revision=revision,
|
||||||
|
purpose=DEFAULT_CASE_ACCESS_PURPOSE,
|
||||||
|
)
|
||||||
|
|
||||||
def list_cases(self, session: object, principal: object, *, limit: int = 100) -> Sequence[CaseRecord]:
|
def list_cases(self, session: object, principal: object, *, limit: int = 100) -> Sequence[CaseRecord]:
|
||||||
items, _total = list_cases(_session(session), principal, limit=limit)
|
items, _total = list_cases(
|
||||||
|
_session(session),
|
||||||
|
principal,
|
||||||
|
limit=limit,
|
||||||
|
purpose=DEFAULT_CASE_ACCESS_PURPOSE,
|
||||||
|
)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
@@ -608,6 +667,7 @@ def can_access_case(
|
|||||||
*,
|
*,
|
||||||
case_id: str,
|
case_id: str,
|
||||||
permission: str = "read",
|
permission: str = "read",
|
||||||
|
purpose: str | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if permission not in {"read", "update", "share", "admin"}:
|
if permission not in {"read", "update", "share", "admin"}:
|
||||||
raise CaseStoreError("Unsupported case access permission.")
|
raise CaseStoreError("Unsupported case access permission.")
|
||||||
@@ -620,87 +680,86 @@ def can_access_case(
|
|||||||
)
|
)
|
||||||
if current is None:
|
if current is None:
|
||||||
return False
|
return False
|
||||||
if _has_scope(principal, "cases:case:admin"):
|
|
||||||
return True
|
|
||||||
identity = _identity(session, tenant_id=tenant_id, case_id=case_id)
|
|
||||||
if identity is not None and identity.created_by in _principal_actor_ids(principal):
|
|
||||||
return True
|
|
||||||
if current.access_mode == "tenant":
|
if current.access_mode == "tenant":
|
||||||
return True
|
return True
|
||||||
|
if current.access_mode != "restricted":
|
||||||
|
return False
|
||||||
|
declared_purpose = str(purpose or "").strip()
|
||||||
|
if not declared_purpose:
|
||||||
|
return False
|
||||||
subjects = _principal_subjects(principal)
|
subjects = _principal_subjects(principal)
|
||||||
if not subjects:
|
if not subjects:
|
||||||
return False
|
return False
|
||||||
clauses = [
|
|
||||||
and_(
|
|
||||||
CaseAccessGrant.subject_kind == kind,
|
|
||||||
CaseAccessGrant.subject_id == subject_id,
|
|
||||||
)
|
|
||||||
for kind, subject_id in subjects
|
|
||||||
]
|
|
||||||
grants = (
|
grants = (
|
||||||
session.query(CaseAccessGrant)
|
session.query(CaseAccessGrant)
|
||||||
.filter(
|
.filter(
|
||||||
CaseAccessGrant.tenant_id == tenant_id,
|
CaseAccessGrant.tenant_id == tenant_id,
|
||||||
CaseAccessGrant.case_id == case_id,
|
CaseAccessGrant.case_id == case_id,
|
||||||
CaseAccessGrant.active.is_(True),
|
CaseAccessGrant.active.is_(True),
|
||||||
or_(*clauses),
|
|
||||||
)
|
)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return any(
|
return any(
|
||||||
_access_permissions_allow(tuple(item.permissions or ()), permission)
|
(item.subject_kind, item.subject_id) in subjects
|
||||||
|
and _access_permissions_allow(tuple(item.permissions or ()), permission)
|
||||||
|
and declared_purpose in tuple(item.allowed_purposes or ())
|
||||||
for item in grants
|
for item in grants
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _filter_accessible_cases(statement, principal: object, *, permission: str):
|
def _eligible_case_ids(
|
||||||
if _has_scope(principal, "cases:case:admin"):
|
session: Session,
|
||||||
return statement
|
principal: object,
|
||||||
conditions = [CaseRecordRevision.access_mode == "tenant"]
|
*,
|
||||||
actor_ids = _principal_actor_ids(principal)
|
permission: str,
|
||||||
if actor_ids:
|
purpose: str | None,
|
||||||
conditions.append(
|
) -> tuple[str, ...]:
|
||||||
exists().where(
|
tenant_id = _principal_tenant(principal)
|
||||||
and_(
|
current = session.query(
|
||||||
CaseIdentity.tenant_id == CaseRecordRevision.tenant_id,
|
CaseRecordRevision.case_id,
|
||||||
CaseIdentity.case_id == CaseRecordRevision.case_id,
|
CaseRecordRevision.access_mode,
|
||||||
CaseIdentity.created_by.in_(actor_ids),
|
).filter(
|
||||||
)
|
CaseRecordRevision.tenant_id == tenant_id,
|
||||||
)
|
CaseRecordRevision.superseded_at.is_(None),
|
||||||
)
|
).all()
|
||||||
|
eligible = {
|
||||||
|
case_id for case_id, access_mode in current if access_mode == "tenant"
|
||||||
|
}
|
||||||
|
declared_purpose = str(purpose or "").strip()
|
||||||
|
if not declared_purpose:
|
||||||
|
return tuple(eligible)
|
||||||
subjects = _principal_subjects(principal)
|
subjects = _principal_subjects(principal)
|
||||||
if subjects:
|
if not subjects:
|
||||||
subject_clause = or_(
|
return tuple(eligible)
|
||||||
*(
|
grants = session.query(CaseAccessGrant).filter(
|
||||||
and_(
|
CaseAccessGrant.tenant_id == tenant_id,
|
||||||
CaseAccessGrant.subject_kind == kind,
|
|
||||||
CaseAccessGrant.subject_id == subject_id,
|
|
||||||
)
|
|
||||||
for kind, subject_id in subjects
|
|
||||||
)
|
|
||||||
)
|
|
||||||
conditions.append(
|
|
||||||
exists().where(
|
|
||||||
and_(
|
|
||||||
CaseAccessGrant.tenant_id == CaseRecordRevision.tenant_id,
|
|
||||||
CaseAccessGrant.case_id == CaseRecordRevision.case_id,
|
|
||||||
CaseAccessGrant.active.is_(True),
|
CaseAccessGrant.active.is_(True),
|
||||||
subject_clause,
|
).all()
|
||||||
|
eligible.update(
|
||||||
|
grant.case_id
|
||||||
|
for grant in grants
|
||||||
|
if (grant.subject_kind, grant.subject_id) in subjects
|
||||||
|
and _access_permissions_allow(tuple(grant.permissions or ()), permission)
|
||||||
|
and declared_purpose in tuple(grant.allowed_purposes or ())
|
||||||
)
|
)
|
||||||
)
|
return tuple(eligible)
|
||||||
)
|
|
||||||
if permission != "read":
|
|
||||||
raise CaseStoreError("Case list filtering supports read access only.")
|
|
||||||
return statement.filter(or_(*conditions))
|
|
||||||
|
|
||||||
|
|
||||||
def _sync_access_grants(
|
def _sync_access_grants(
|
||||||
session: Session,
|
session: Session,
|
||||||
principal: object,
|
principal: object,
|
||||||
record: CaseRecord,
|
record: CaseRecord,
|
||||||
|
*,
|
||||||
|
purpose: str,
|
||||||
|
ensure_custodian: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
desired: dict[tuple[str, str, str], tuple[str, ...]] = {
|
desired: dict[
|
||||||
(item.subject_kind, item.subject_id, "explicit"): item.permissions
|
tuple[str, str, str], tuple[tuple[str, ...], tuple[str, ...]]
|
||||||
|
] = {
|
||||||
|
(item.subject_kind, item.subject_id, "explicit"): (
|
||||||
|
item.permissions,
|
||||||
|
item.allowed_purposes,
|
||||||
|
)
|
||||||
for item in record.access_grants
|
for item in record.access_grants
|
||||||
}
|
}
|
||||||
for reference in record.assignment_refs:
|
for reference in record.assignment_refs:
|
||||||
@@ -711,8 +770,8 @@ def _sync_access_grants(
|
|||||||
}:
|
}:
|
||||||
continue
|
continue
|
||||||
desired[(reference.kind, reference.object_id, "assignment")] = (
|
desired[(reference.kind, reference.object_id, "assignment")] = (
|
||||||
"read",
|
("read", "update"),
|
||||||
"update",
|
("cases.casework", "cases.search", "cases.records"),
|
||||||
)
|
)
|
||||||
rows = (
|
rows = (
|
||||||
session.query(CaseAccessGrant)
|
session.query(CaseAccessGrant)
|
||||||
@@ -725,11 +784,21 @@ def _sync_access_grants(
|
|||||||
existing = {
|
existing = {
|
||||||
(item.subject_kind, item.subject_id, item.source): item for item in rows
|
(item.subject_kind, item.subject_id, item.source): item for item in rows
|
||||||
}
|
}
|
||||||
|
if ensure_custodian and record.access_mode == "restricted":
|
||||||
|
subject = _custodian_subject(principal)
|
||||||
|
if subject is None:
|
||||||
|
raise CaseStoreError(
|
||||||
|
"Restricted case creation requires an attributable custodian."
|
||||||
|
)
|
||||||
|
desired[(subject[0], subject[1], "custodian")] = (
|
||||||
|
("read", "update", "share", "admin"),
|
||||||
|
(purpose,),
|
||||||
|
)
|
||||||
for key, row in existing.items():
|
for key, row in existing.items():
|
||||||
if key not in desired:
|
if key not in desired and row.source != "custodian":
|
||||||
row.active = False
|
row.active = False
|
||||||
row.source_revision = record.revision
|
row.source_revision = record.revision
|
||||||
for key, permissions in desired.items():
|
for key, (permissions, allowed_purposes) in desired.items():
|
||||||
row = existing.get(key)
|
row = existing.get(key)
|
||||||
if row is None:
|
if row is None:
|
||||||
session.add(
|
session.add(
|
||||||
@@ -739,6 +808,7 @@ def _sync_access_grants(
|
|||||||
subject_kind=key[0],
|
subject_kind=key[0],
|
||||||
subject_id=key[1],
|
subject_id=key[1],
|
||||||
permissions=list(permissions),
|
permissions=list(permissions),
|
||||||
|
allowed_purposes=list(allowed_purposes),
|
||||||
source=key[2],
|
source=key[2],
|
||||||
active=True,
|
active=True,
|
||||||
source_revision=record.revision,
|
source_revision=record.revision,
|
||||||
@@ -747,6 +817,7 @@ def _sync_access_grants(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
row.permissions = list(permissions)
|
row.permissions = list(permissions)
|
||||||
|
row.allowed_purposes = list(allowed_purposes)
|
||||||
row.active = True
|
row.active = True
|
||||||
row.source_revision = record.revision
|
row.source_revision = record.revision
|
||||||
session.flush()
|
session.flush()
|
||||||
@@ -780,6 +851,7 @@ def _principal_subjects(principal: object) -> tuple[tuple[str, str], ...]:
|
|||||||
for kind, name in (
|
for kind, name in (
|
||||||
("account", "account_id"),
|
("account", "account_id"),
|
||||||
("identity", "identity_id"),
|
("identity", "identity_id"),
|
||||||
|
("membership", "membership_id"),
|
||||||
("service_account", "service_account_id"),
|
("service_account", "service_account_id"),
|
||||||
):
|
):
|
||||||
value = str(getattr(principal, name, None) or "").strip()
|
value = str(getattr(principal, name, None) or "").strip()
|
||||||
@@ -791,15 +863,41 @@ def _principal_subjects(principal: object) -> tuple[tuple[str, str], ...]:
|
|||||||
("function", "function_ids"),
|
("function", "function_ids"),
|
||||||
("function_assignment", "function_assignment_ids"),
|
("function_assignment", "function_assignment_ids"),
|
||||||
("organization_unit", "organization_unit_ids"),
|
("organization_unit", "organization_unit_ids"),
|
||||||
|
("delegation", "delegation_ids"),
|
||||||
):
|
):
|
||||||
subjects.extend(
|
subjects.extend(
|
||||||
(kind, str(item))
|
(kind, str(item))
|
||||||
for item in (getattr(principal, name, ()) or ())
|
for item in (getattr(principal, name, ()) or ())
|
||||||
if str(item).strip()
|
if str(item).strip()
|
||||||
)
|
)
|
||||||
|
for kind, name in (
|
||||||
|
("function_assignment", "acting_assignment_id"),
|
||||||
|
("delegation", "acting_delegation_id"),
|
||||||
|
("delegation", "delegation_id"),
|
||||||
|
):
|
||||||
|
value = str(getattr(principal, name, None) or "").strip()
|
||||||
|
if value:
|
||||||
|
subjects.append((kind, value))
|
||||||
return tuple(dict.fromkeys(subjects))
|
return tuple(dict.fromkeys(subjects))
|
||||||
|
|
||||||
|
|
||||||
|
def _custodian_subject(principal: object) -> tuple[str, str] | None:
|
||||||
|
for kind, name in (
|
||||||
|
("account", "account_id"),
|
||||||
|
("identity", "identity_id"),
|
||||||
|
("membership", "membership_id"),
|
||||||
|
("service_account", "service_account_id"),
|
||||||
|
):
|
||||||
|
value = str(getattr(principal, name, None) or "").strip()
|
||||||
|
if value:
|
||||||
|
return kind, value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _access_purpose(value: object) -> str:
|
||||||
|
return _required_text(value, "Case access purpose", maximum=255)
|
||||||
|
|
||||||
|
|
||||||
def _has_scope(principal: object, scope: str) -> bool:
|
def _has_scope(principal: object, scope: str) -> bool:
|
||||||
method = getattr(principal, "has", None)
|
method = getattr(principal, "has", None)
|
||||||
if callable(method):
|
if callable(method):
|
||||||
@@ -820,6 +918,7 @@ def _record_revision(
|
|||||||
idempotency_key: str,
|
idempotency_key: str,
|
||||||
request_sha256: str,
|
request_sha256: str,
|
||||||
operation: str,
|
operation: str,
|
||||||
|
purpose: str,
|
||||||
) -> CaseRecord:
|
) -> CaseRecord:
|
||||||
clean_key = _required_text(
|
clean_key = _required_text(
|
||||||
idempotency_key,
|
idempotency_key,
|
||||||
@@ -860,6 +959,7 @@ def _record_revision(
|
|||||||
summary=f"Case {identity.case_number} {operation}.",
|
summary=f"Case {identity.case_number} {operation}.",
|
||||||
occurred_at=record.recorded_at,
|
occurred_at=record.recorded_at,
|
||||||
actor_id=_principal_actor(principal),
|
actor_id=_principal_actor(principal),
|
||||||
|
purpose=purpose,
|
||||||
idempotency_key=clean_key,
|
idempotency_key=clean_key,
|
||||||
request_sha256=request_sha256,
|
request_sha256=request_sha256,
|
||||||
audit_event_id=event_id,
|
audit_event_id=event_id,
|
||||||
@@ -870,12 +970,22 @@ def _record_revision(
|
|||||||
"revision": record.revision,
|
"revision": record.revision,
|
||||||
"access_mode": record.access_mode,
|
"access_mode": record.access_mode,
|
||||||
"access_grant_count": len(record.access_grants),
|
"access_grant_count": len(record.access_grants),
|
||||||
|
"purpose": purpose,
|
||||||
"change_reason": record.change_reason,
|
"change_reason": record.change_reason,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
session.add_all((row, timeline))
|
session.add_all((row, timeline))
|
||||||
session.flush()
|
session.flush()
|
||||||
_sync_access_grants(session, principal, record)
|
_sync_access_grants(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
record,
|
||||||
|
purpose=purpose,
|
||||||
|
ensure_custodian=(
|
||||||
|
record.access_mode == "restricted"
|
||||||
|
and (current is None or current.access_mode != "restricted")
|
||||||
|
),
|
||||||
|
)
|
||||||
emit_platform_event(
|
emit_platform_event(
|
||||||
session,
|
session,
|
||||||
PlatformEvent(
|
PlatformEvent(
|
||||||
@@ -1108,6 +1218,7 @@ def _timeline_payload(row: CaseTimelineEntry) -> dict[str, object]:
|
|||||||
"summary": row.summary,
|
"summary": row.summary,
|
||||||
"occurred_at": _datetime_text(row.occurred_at),
|
"occurred_at": _datetime_text(row.occurred_at),
|
||||||
"actor_id": row.actor_id,
|
"actor_id": row.actor_id,
|
||||||
|
"purpose": row.purpose,
|
||||||
"audit_event_id": row.audit_event_id,
|
"audit_event_id": row.audit_event_id,
|
||||||
"payload": dict(row.payload or {}),
|
"payload": dict(row.payload or {}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -324,13 +324,66 @@ class CaseLifecycleTests(unittest.TestCase):
|
|||||||
|
|
||||||
outsider = Principal(account_id="account-2")
|
outsider = Principal(account_id="account-2")
|
||||||
self.assertIsNone(get_case(self.session, outsider, case_id="case-1"))
|
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_history(self.session, outsider, case_id="case-1"))
|
||||||
self.assertEqual((), case_timeline(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",))
|
reader = Principal(account_id="account-3", group_ids=("reviewers",))
|
||||||
self.assertIsNotNone(get_case(self.session, reader, case_id="case-1"))
|
self.assertIsNone(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",
|
||||||
|
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):
|
with self.assertRaises(PermissionError):
|
||||||
update_case(
|
update_case(
|
||||||
self.session,
|
self.session,
|
||||||
@@ -396,9 +449,56 @@ class CaseLifecycleTests(unittest.TestCase):
|
|||||||
self.session.commit()
|
self.session.commit()
|
||||||
self.assertEqual(3, shared.revision)
|
self.assertEqual(3, shared.revision)
|
||||||
self.assertIsNotNone(
|
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:
|
def test_malformed_access_grant_is_rejected(self) -> None:
|
||||||
create_case(
|
create_case(
|
||||||
|
|||||||
@@ -255,6 +255,10 @@ class CasesDsarProviderTests(unittest.TestCase):
|
|||||||
self.assertEqual("cases_access_grant", account_records[0].resource_type)
|
self.assertEqual("cases_access_grant", account_records[0].resource_type)
|
||||||
self.assertFalse(account_records[0].immutable_evidence)
|
self.assertFalse(account_records[0].immutable_evidence)
|
||||||
self.assertEqual("account-subject", account_records[0].data["subject_id"])
|
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(
|
identity_records = self.provider.search_subject(
|
||||||
self.session,
|
self.session,
|
||||||
@@ -308,7 +312,11 @@ class CasesDsarProviderTests(unittest.TestCase):
|
|||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
all(
|
all(
|
||||||
item.resource_type
|
item.resource_type
|
||||||
in {"cases_operator_attribution", "cases_timeline_event"}
|
in {
|
||||||
|
"cases_access_grant",
|
||||||
|
"cases_operator_attribution",
|
||||||
|
"cases_timeline_event",
|
||||||
|
}
|
||||||
for item in records
|
for item in records
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -67,6 +67,20 @@ class CasesInterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
owner_links.metadata["consequence_classes"],
|
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:
|
def test_webui_uses_shared_help_guard_and_confirmation_components(self) -> None:
|
||||||
list_page = (REPO_ROOT / "webui/src/features/cases/CasesPage.tsx").read_text(
|
list_page = (REPO_ROOT / "webui/src/features/cases/CasesPage.tsx").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
|
|||||||
@@ -40,9 +40,23 @@ class CasesMigrationTests(unittest.TestCase):
|
|||||||
for item in inspector.get_columns("case_record_revisions")
|
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:
|
with engine.connect() as connection:
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"f6d3a8b1c4e7",
|
"a7c4e2f9b1d6",
|
||||||
set(MigrationContext.configure(connection).get_current_heads()),
|
set(MigrationContext.configure(connection).get_current_heads()),
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ class CasesRecordSourceTests(unittest.TestCase):
|
|||||||
def test_resolves_exact_currently_authorized_case_revision(self) -> None:
|
def test_resolves_exact_currently_authorized_case_revision(self) -> None:
|
||||||
with patch(
|
with patch(
|
||||||
"govoplan_cases.backend.record_source.can_access_case", return_value=True
|
"govoplan_cases.backend.record_source.can_access_case", return_value=True
|
||||||
):
|
) as can_access:
|
||||||
result = CasesRecordSource().resolve(
|
result = CasesRecordSource().resolve(
|
||||||
self.session,
|
self.session,
|
||||||
Principal(),
|
Principal(),
|
||||||
@@ -83,9 +83,20 @@ class CasesRecordSourceTests(unittest.TestCase):
|
|||||||
purpose="preserve decision basis",
|
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("2026/C-1 - Permit application", result.label)
|
||||||
self.assertEqual(64, len(result.content_sha256 or ""))
|
self.assertEqual(64, len(result.content_sha256 or ""))
|
||||||
self.assertEqual("review", result.metadata["status_key"])
|
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:
|
def test_object_access_is_rechecked_and_fails_closed(self) -> None:
|
||||||
with (
|
with (
|
||||||
|
|||||||
@@ -28,8 +28,12 @@ from govoplan_cases.backend.db.models import (
|
|||||||
CaseTimelineEntry,
|
CaseTimelineEntry,
|
||||||
CaseTypeDefinition,
|
CaseTypeDefinition,
|
||||||
)
|
)
|
||||||
from govoplan_cases.backend.domain import CaseRecord
|
from govoplan_cases.backend.domain import CaseGrant, CaseRecord
|
||||||
from govoplan_cases.backend.search_source import CasesSearchSource, PROVIDER_ID
|
from govoplan_cases.backend.search_source import (
|
||||||
|
SEARCH_ACCESS_PURPOSE,
|
||||||
|
CasesSearchSource,
|
||||||
|
PROVIDER_ID,
|
||||||
|
)
|
||||||
from govoplan_cases.backend.service import (
|
from govoplan_cases.backend.service import (
|
||||||
create_case,
|
create_case,
|
||||||
upsert_case_status,
|
upsert_case_status,
|
||||||
@@ -77,6 +81,15 @@ class CasesSearchSourceTests(unittest.TestCase):
|
|||||||
case_type_key="permit",
|
case_type_key="permit",
|
||||||
status_key="review",
|
status_key="review",
|
||||||
title="Permit application",
|
title="Permit application",
|
||||||
|
access_mode="restricted",
|
||||||
|
access_grants=(
|
||||||
|
CaseGrant(
|
||||||
|
"account",
|
||||||
|
"account-1",
|
||||||
|
("read",),
|
||||||
|
(SEARCH_ACCESS_PURPOSE,),
|
||||||
|
),
|
||||||
|
),
|
||||||
context=GovernedContextEnvelope(
|
context=GovernedContextEnvelope(
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
temporal=TemporalRevision(
|
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(("case-1",), tuple(item.resource_id for item in page.documents))
|
||||||
self.assertEqual("PERMIT-1", page.documents[0].metadata["case_number"])
|
self.assertEqual("PERMIT-1", page.documents[0].metadata["case_number"])
|
||||||
|
self.assertIn("purpose=cases.search", page.documents[0].url)
|
||||||
reference = SearchResourceReference(
|
reference = SearchResourceReference(
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
module_id="cases",
|
module_id="cases",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/cases-webui",
|
"name": "@govoplan/cases-webui",
|
||||||
"version": "0.1.22",
|
"version": "0.1.23",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
+22
-7
@@ -59,11 +59,14 @@ export type CaseRecord = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type CaseGrant = {
|
export type CaseGrant = {
|
||||||
subject_kind: "account" | "identity" | "group" | "role" | "function" | "function_assignment" | "organization_unit" | "service_account";
|
subject_kind: "account" | "identity" | "membership" | "group" | "role" | "function" | "function_assignment" | "organization_unit" | "service_account" | "delegation";
|
||||||
subject_id: string;
|
subject_id: string;
|
||||||
permissions: Array<"read" | "update" | "share" | "admin">;
|
permissions: Array<"read" | "update" | "share" | "admin">;
|
||||||
|
allowed_purposes: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_CASE_ACCESS_PURPOSE = "cases.casework";
|
||||||
|
|
||||||
export type CaseStatusDefinition = {
|
export type CaseStatusDefinition = {
|
||||||
status_key: string;
|
status_key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -101,6 +104,7 @@ export type CaseTimelineEntry = {
|
|||||||
event_type: string;
|
event_type: string;
|
||||||
case_revision: number;
|
case_revision: number;
|
||||||
summary: string;
|
summary: string;
|
||||||
|
purpose: string;
|
||||||
actor_id?: string | null;
|
actor_id?: string | null;
|
||||||
occurred_at: string;
|
occurred_at: string;
|
||||||
audit_event_id?: string | null;
|
audit_event_id?: string | null;
|
||||||
@@ -140,6 +144,7 @@ export function listCases(
|
|||||||
query?: string;
|
query?: string;
|
||||||
statuses?: string[];
|
statuses?: string[];
|
||||||
caseTypes?: string[];
|
caseTypes?: string[];
|
||||||
|
purpose?: string;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
},
|
},
|
||||||
@@ -149,6 +154,7 @@ export function listCases(
|
|||||||
query: options.query,
|
query: options.query,
|
||||||
status_key: options.statuses,
|
status_key: options.statuses,
|
||||||
case_type_key: options.caseTypes,
|
case_type_key: options.caseTypes,
|
||||||
|
purpose: options.purpose,
|
||||||
offset: options.offset,
|
offset: options.offset,
|
||||||
limit: options.limit
|
limit: options.limit
|
||||||
}), { signal });
|
}), { signal });
|
||||||
@@ -157,9 +163,10 @@ export function listCases(
|
|||||||
export function getCase(
|
export function getCase(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
caseId: string,
|
caseId: string,
|
||||||
|
purpose: string,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<CaseRecord> {
|
): Promise<CaseRecord> {
|
||||||
return apiFetch<CaseRecord>(settings, `/api/v1/cases/${encodeURIComponent(caseId)}`, { signal });
|
return apiFetch<CaseRecord>(settings, apiPath(`/api/v1/cases/${encodeURIComponent(caseId)}`, { purpose }), { signal });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listCaseCatalog(
|
export function listCaseCatalog(
|
||||||
@@ -172,27 +179,30 @@ export function listCaseCatalog(
|
|||||||
export function caseHistory(
|
export function caseHistory(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
caseId: string,
|
caseId: string,
|
||||||
|
purpose: string,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<{ revisions: CaseRecord[] }> {
|
): Promise<{ revisions: CaseRecord[] }> {
|
||||||
return apiFetch(settings, `/api/v1/cases/${encodeURIComponent(caseId)}/history`, { signal });
|
return apiFetch(settings, apiPath(`/api/v1/cases/${encodeURIComponent(caseId)}/history`, { purpose }), { signal });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function caseTimeline(
|
export function caseTimeline(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
caseId: string,
|
caseId: string,
|
||||||
|
purpose: string,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<{ entries: CaseTimelineEntry[] }> {
|
): Promise<{ entries: CaseTimelineEntry[] }> {
|
||||||
return apiFetch(settings, `/api/v1/cases/${encodeURIComponent(caseId)}/timeline`, { signal });
|
return apiFetch(settings, apiPath(`/api/v1/cases/${encodeURIComponent(caseId)}/timeline`, { purpose }), { signal });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listCaseDecisions(
|
export function listCaseDecisions(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
caseId: string,
|
caseId: string,
|
||||||
|
purpose: string,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<{ decisions: FormalDecision[] }> {
|
): Promise<{ decisions: FormalDecision[] }> {
|
||||||
return apiFetch(
|
return apiFetch(
|
||||||
settings,
|
settings,
|
||||||
`/api/v1/cases/${encodeURIComponent(caseId)}/decisions`,
|
apiPath(`/api/v1/cases/${encodeURIComponent(caseId)}/decisions`, { purpose }),
|
||||||
{ signal }
|
{ signal }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -209,6 +219,7 @@ export function recordCaseDecision(
|
|||||||
conditions: string[];
|
conditions: string[];
|
||||||
change_reason: string;
|
change_reason: string;
|
||||||
idempotency_key: string;
|
idempotency_key: string;
|
||||||
|
purpose: string;
|
||||||
}
|
}
|
||||||
): Promise<CaseDecisionResult> {
|
): Promise<CaseDecisionResult> {
|
||||||
return apiFetch(
|
return apiFetch(
|
||||||
@@ -229,6 +240,7 @@ export function updateCase(
|
|||||||
recorded_at: string;
|
recorded_at: string;
|
||||||
change_reason: string;
|
change_reason: string;
|
||||||
idempotency_key: string;
|
idempotency_key: string;
|
||||||
|
purpose: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
status_key?: string;
|
status_key?: string;
|
||||||
access_mode?: "tenant" | "restricted";
|
access_mode?: "tenant" | "restricted";
|
||||||
@@ -249,6 +261,7 @@ export function linkCaseEvidence(
|
|||||||
recorded_at: string;
|
recorded_at: string;
|
||||||
change_reason: string;
|
change_reason: string;
|
||||||
idempotency_key: string;
|
idempotency_key: string;
|
||||||
|
purpose: string;
|
||||||
reference: {
|
reference: {
|
||||||
owner_module: string;
|
owner_module: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
@@ -275,6 +288,7 @@ export function unlinkCaseEvidence(
|
|||||||
recorded_at: string;
|
recorded_at: string;
|
||||||
change_reason: string;
|
change_reason: string;
|
||||||
idempotency_key: string;
|
idempotency_key: string;
|
||||||
|
purpose: string;
|
||||||
}
|
}
|
||||||
): Promise<CaseRecord> {
|
): Promise<CaseRecord> {
|
||||||
return apiFetch<CaseRecord>(
|
return apiFetch<CaseRecord>(
|
||||||
@@ -287,11 +301,12 @@ export function unlinkCaseEvidence(
|
|||||||
export function caseShareTargetProvider(
|
export function caseShareTargetProvider(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
caseId: string,
|
caseId: string,
|
||||||
targetType: "user" | "group"
|
targetType: "user" | "group",
|
||||||
|
purpose: string
|
||||||
): ReferenceOptionProvider {
|
): ReferenceOptionProvider {
|
||||||
return apiReferenceOptionProvider(
|
return apiReferenceOptionProvider(
|
||||||
settings,
|
settings,
|
||||||
`/api/v1/cases/${encodeURIComponent(caseId)}/share-target-options`,
|
`/api/v1/cases/${encodeURIComponent(caseId)}/share-target-options`,
|
||||||
{ target_type: targetType }
|
{ target_type: targetType, purpose }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,12 +21,14 @@ import { CASES_FIELDS_DOCUMENTATION } from "./interfacePatterns";
|
|||||||
export default function CaseDecisionDialog({
|
export default function CaseDecisionDialog({
|
||||||
settings,
|
settings,
|
||||||
record,
|
record,
|
||||||
|
purpose,
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
onSaved
|
onSaved
|
||||||
}: {
|
}: {
|
||||||
settings: ApiSettings;
|
settings: ApiSettings;
|
||||||
record: CaseRecord;
|
record: CaseRecord;
|
||||||
|
purpose: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: (result: CaseDecisionResult) => void;
|
onSaved: (result: CaseDecisionResult) => void;
|
||||||
@@ -82,7 +84,8 @@ export default function CaseDecisionDialog({
|
|||||||
reasoning: reasoning.trim(),
|
reasoning: reasoning.trim(),
|
||||||
conditions: conditions.split("\n").map((item) => item.trim()).filter(Boolean),
|
conditions: conditions.split("\n").map((item) => item.trim()).filter(Boolean),
|
||||||
change_reason: changeReason.trim(),
|
change_reason: changeReason.trim(),
|
||||||
idempotency_key: idempotencyKey.current
|
idempotency_key: idempotencyKey.current,
|
||||||
|
purpose
|
||||||
});
|
});
|
||||||
setConfirmOpen(false);
|
setConfirmOpen(false);
|
||||||
onSaved(result);
|
onSaved(result);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Archive, ArrowLeft, ExternalLink, Save, Scale, Share2, Unlink } from "lucide-react";
|
import { Archive, ArrowLeft, ExternalLink, Save, Scale, Share2, Unlink } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useParams } from "react-router";
|
import { useParams, useSearchParams } from "react-router";
|
||||||
import { ActionToolbar,
|
import { ActionToolbar,
|
||||||
ActionBlockerHint,
|
ActionBlockerHint,
|
||||||
Button,
|
Button,
|
||||||
@@ -22,6 +22,7 @@ import { ActionToolbar,
|
|||||||
type QuickAccessResult
|
type QuickAccessResult
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_CASE_ACCESS_PURPOSE,
|
||||||
caseHistory,
|
caseHistory,
|
||||||
caseTimeline,
|
caseTimeline,
|
||||||
getCase,
|
getCase,
|
||||||
@@ -48,6 +49,8 @@ import {
|
|||||||
|
|
||||||
export default function CaseDetailPage({ settings, auth }: PlatformRouteContext) {
|
export default function CaseDetailPage({ settings, auth }: PlatformRouteContext) {
|
||||||
const { caseId = "" } = useParams();
|
const { caseId = "" } = useParams();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const accessPurpose = searchParams.get("purpose")?.trim() || DEFAULT_CASE_ACCESS_PURPOSE;
|
||||||
const navigate = useGuardedNavigate();
|
const navigate = useGuardedNavigate();
|
||||||
const [record, setRecord] = useState<CaseRecord | null>(null);
|
const [record, setRecord] = useState<CaseRecord | null>(null);
|
||||||
const [catalog, setCatalog] = useState<CaseCatalog>({ statuses: [], types: [] });
|
const [catalog, setCatalog] = useState<CaseCatalog>({ statuses: [], types: [] });
|
||||||
@@ -92,20 +95,20 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
|||||||
tenantId: record.reference.tenant_id,
|
tenantId: record.reference.tenant_id,
|
||||||
label: `${record.case_number} · ${record.title}`,
|
label: `${record.case_number} · ${record.title}`,
|
||||||
version: record.reference.version ?? String(record.revision),
|
version: record.reference.version ?? String(record.revision),
|
||||||
path: `/cases/${encodeURIComponent(record.reference.object_id)}`
|
path: `/cases/${encodeURIComponent(record.reference.object_id)}?purpose=${encodeURIComponent(accessPurpose)}`
|
||||||
} : null, [record]);
|
} : null, [accessPurpose, record]);
|
||||||
useRegisterActiveObject(activeCase);
|
useRegisterActiveObject(activeCase);
|
||||||
|
|
||||||
const load = useCallback((signal?: AbortSignal) => {
|
const load = useCallback((signal?: AbortSignal) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
getCase(settings, caseId, signal),
|
getCase(settings, caseId, accessPurpose, signal),
|
||||||
listCaseCatalog(settings, signal),
|
listCaseCatalog(settings, signal),
|
||||||
caseHistory(settings, caseId, signal),
|
caseHistory(settings, caseId, accessPurpose, signal),
|
||||||
caseTimeline(settings, caseId, signal),
|
caseTimeline(settings, caseId, accessPurpose, signal),
|
||||||
canReadDecisions
|
canReadDecisions
|
||||||
? listCaseDecisions(settings, caseId, signal)
|
? listCaseDecisions(settings, caseId, accessPurpose, signal)
|
||||||
: Promise.resolve({ decisions: [] as FormalDecision[] })
|
: Promise.resolve({ decisions: [] as FormalDecision[] })
|
||||||
]).
|
]).
|
||||||
then(([nextRecord, nextCatalog, nextHistory, nextTimeline, nextDecisions]) => {
|
then(([nextRecord, nextCatalog, nextHistory, nextTimeline, nextDecisions]) => {
|
||||||
@@ -119,7 +122,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
|||||||
setChangeReason("");
|
setChangeReason("");
|
||||||
}).
|
}).
|
||||||
finally(() => setLoading(false));
|
finally(() => setLoading(false));
|
||||||
}, [canReadDecisions, caseId, settings]);
|
}, [accessPurpose, canReadDecisions, caseId, settings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -179,6 +182,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
|||||||
recorded_at: new Date().toISOString(),
|
recorded_at: new Date().toISOString(),
|
||||||
change_reason: `Linked ${reference.ownerModule}.${reference.kind} through Quick Access.`,
|
change_reason: `Linked ${reference.ownerModule}.${reference.kind} through Quick Access.`,
|
||||||
idempotency_key: crypto.randomUUID(),
|
idempotency_key: crypto.randomUUID(),
|
||||||
|
purpose: accessPurpose,
|
||||||
reference: {
|
reference: {
|
||||||
owner_module: reference.ownerModule,
|
owner_module: reference.ownerModule,
|
||||||
kind: reference.kind,
|
kind: reference.kind,
|
||||||
@@ -203,7 +207,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
|||||||
QUICK_ACCESS_RESULT_EVENT,
|
QUICK_ACCESS_RESULT_EVENT,
|
||||||
receiveReference
|
receiveReference
|
||||||
);
|
);
|
||||||
}, [canUpdate, caseId, draftDirty, load, record, settings]);
|
}, [accessPurpose, canUpdate, caseId, draftDirty, load, record, settings]);
|
||||||
|
|
||||||
function discardDraft() {
|
function discardDraft() {
|
||||||
if (!record) return;
|
if (!record) return;
|
||||||
@@ -222,6 +226,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
|||||||
recorded_at: new Date().toISOString(),
|
recorded_at: new Date().toISOString(),
|
||||||
change_reason: changeReason.trim(),
|
change_reason: changeReason.trim(),
|
||||||
idempotency_key: idempotencyKey.current,
|
idempotency_key: idempotencyKey.current,
|
||||||
|
purpose: accessPurpose,
|
||||||
...(title.trim() !== record.title ? { title: title.trim() } : {}),
|
...(title.trim() !== record.title ? { title: title.trim() } : {}),
|
||||||
...(status !== record.status_key ? { status_key: status } : {})
|
...(status !== record.status_key ? { status_key: status } : {})
|
||||||
});
|
});
|
||||||
@@ -260,7 +265,8 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
|||||||
expected_revision: record.revision,
|
expected_revision: record.revision,
|
||||||
recorded_at: new Date().toISOString(),
|
recorded_at: new Date().toISOString(),
|
||||||
change_reason: `Unlinked ${evidence.owner_module} reference from the Case.`,
|
change_reason: `Unlinked ${evidence.owner_module} reference from the Case.`,
|
||||||
idempotency_key: crypto.randomUUID()
|
idempotency_key: crypto.randomUUID(),
|
||||||
|
purpose: accessPurpose
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
setRecord(saved);
|
setRecord(saved);
|
||||||
@@ -296,7 +302,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
|||||||
<main className="cases-page">
|
<main className="cases-page">
|
||||||
<div className="case-detail-shell">
|
<div className="case-detail-shell">
|
||||||
<ActionToolbar className="case-detail-toolbar">
|
<ActionToolbar className="case-detail-toolbar">
|
||||||
<button type="button" className="btn btn-ghost" onClick={() => navigate("/cases")}>
|
<button type="button" className="btn btn-ghost" onClick={() => navigate(`/cases?purpose=${encodeURIComponent(accessPurpose)}`)}>
|
||||||
<ArrowLeft size={16} aria-hidden="true" />
|
<ArrowLeft size={16} aria-hidden="true" />
|
||||||
Cases
|
Cases
|
||||||
</button>
|
</button>
|
||||||
@@ -325,6 +331,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
|||||||
icon={<Share2 size={16} />}
|
icon={<Share2 size={16} />}
|
||||||
disabledReason={!canShare ? CASES_I18N.shareReason : undefined}
|
disabledReason={!canShare ? CASES_I18N.shareReason : undefined}
|
||||||
onClick={() => setShareOpen(true)}
|
onClick={() => setShareOpen(true)}
|
||||||
|
helpContextId="cases.detail.access"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -404,6 +411,8 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
|||||||
<Fact label="Opened" value={formatDateTime(record.opened_at)} />
|
<Fact label="Opened" value={formatDateTime(record.opened_at)} />
|
||||||
<Fact label="Deadline" value={formatDateTime(record.deadline_at)} />
|
<Fact label="Deadline" value={formatDateTime(record.deadline_at)} />
|
||||||
<Fact label="Revision" value={String(record.revision)} />
|
<Fact label="Revision" value={String(record.revision)} />
|
||||||
|
<Fact label="Access purpose" value={accessPurpose} />
|
||||||
|
<Fact label="Access mode" value={humanize(record.access_mode)} />
|
||||||
<Fact label="Last change" value={record.change_reason} />
|
<Fact label="Last change" value={record.change_reason} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -444,6 +453,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
|||||||
<li key={entry.event_id}>
|
<li key={entry.event_id}>
|
||||||
<strong>{humanize(entry.event_type)}</strong>
|
<strong>{humanize(entry.event_type)}</strong>
|
||||||
<span>{entry.summary}</span>
|
<span>{entry.summary}</span>
|
||||||
|
<span>{entry.purpose}</span>
|
||||||
<time>{formatDateTime(entry.occurred_at)}</time>
|
<time>{formatDateTime(entry.occurred_at)}</time>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
@@ -470,6 +480,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
|||||||
<CaseShareDialog
|
<CaseShareDialog
|
||||||
settings={settings}
|
settings={settings}
|
||||||
record={record}
|
record={record}
|
||||||
|
purpose={accessPurpose}
|
||||||
open={shareOpen}
|
open={shareOpen}
|
||||||
onClose={() => setShareOpen(false)}
|
onClose={() => setShareOpen(false)}
|
||||||
onSaved={(saved) => {
|
onSaved={(saved) => {
|
||||||
@@ -484,6 +495,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
|
|||||||
<CaseDecisionDialog
|
<CaseDecisionDialog
|
||||||
settings={settings}
|
settings={settings}
|
||||||
record={record}
|
record={record}
|
||||||
|
purpose={accessPurpose}
|
||||||
open={decisionOpen}
|
open={decisionOpen}
|
||||||
onClose={() => setDecisionOpen(false)}
|
onClose={() => setDecisionOpen(false)}
|
||||||
onSaved={(result) => {
|
onSaved={(result) => {
|
||||||
|
|||||||
@@ -33,12 +33,14 @@ type Permission = CaseGrant["permissions"][number];
|
|||||||
export default function CaseShareDialog({
|
export default function CaseShareDialog({
|
||||||
settings,
|
settings,
|
||||||
record,
|
record,
|
||||||
|
purpose,
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
onSaved
|
onSaved
|
||||||
}: {
|
}: {
|
||||||
settings: ApiSettings;
|
settings: ApiSettings;
|
||||||
record: CaseRecord;
|
record: CaseRecord;
|
||||||
|
purpose: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: (record: CaseRecord) => void;
|
onSaved: (record: CaseRecord) => void;
|
||||||
@@ -48,6 +50,7 @@ export default function CaseShareDialog({
|
|||||||
const [targetType, setTargetType] = useState<TargetType>("user");
|
const [targetType, setTargetType] = useState<TargetType>("user");
|
||||||
const [targetId, setTargetId] = useState("");
|
const [targetId, setTargetId] = useState("");
|
||||||
const [permission, setPermission] = useState<Permission>("read");
|
const [permission, setPermission] = useState<Permission>("read");
|
||||||
|
const [allowedPurposes, setAllowedPurposes] = useState(purpose);
|
||||||
const [changeReason, setChangeReason] = useState("");
|
const [changeReason, setChangeReason] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -55,8 +58,8 @@ export default function CaseShareDialog({
|
|||||||
const idempotencyKey = useRef(crypto.randomUUID());
|
const idempotencyKey = useRef(crypto.randomUUID());
|
||||||
const { requestDiscard } = useUnsavedChanges();
|
const { requestDiscard } = useUnsavedChanges();
|
||||||
const targetProvider = useMemo(
|
const targetProvider = useMemo(
|
||||||
() => caseShareTargetProvider(settings, record.reference.object_id, targetType),
|
() => caseShareTargetProvider(settings, record.reference.object_id, targetType, purpose),
|
||||||
[record.reference.object_id, settings, targetType]
|
[purpose, record.reference.object_id, settings, targetType]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -66,11 +69,12 @@ export default function CaseShareDialog({
|
|||||||
setTargetType("user");
|
setTargetType("user");
|
||||||
setTargetId("");
|
setTargetId("");
|
||||||
setPermission("read");
|
setPermission("read");
|
||||||
|
setAllowedPurposes(purpose);
|
||||||
setChangeReason("");
|
setChangeReason("");
|
||||||
setError("");
|
setError("");
|
||||||
setConfirmOpen(false);
|
setConfirmOpen(false);
|
||||||
idempotencyKey.current = crypto.randomUUID();
|
idempotencyKey.current = crypto.randomUUID();
|
||||||
}, [open, record]);
|
}, [open, purpose, record]);
|
||||||
|
|
||||||
const changed = restricted !== (record.access_mode === "restricted")
|
const changed = restricted !== (record.access_mode === "restricted")
|
||||||
|| JSON.stringify(grants) !== JSON.stringify(record.access_grants);
|
|| JSON.stringify(grants) !== JSON.stringify(record.access_grants);
|
||||||
@@ -82,13 +86,15 @@ export default function CaseShareDialog({
|
|||||||
setTargetType("user");
|
setTargetType("user");
|
||||||
setTargetId("");
|
setTargetId("");
|
||||||
setPermission("read");
|
setPermission("read");
|
||||||
|
setAllowedPurposes(purpose);
|
||||||
setChangeReason("");
|
setChangeReason("");
|
||||||
setError("");
|
setError("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function addGrant() {
|
function addGrant() {
|
||||||
const subjectId = targetId.trim();
|
const subjectId = targetId.trim();
|
||||||
if (!subjectId) return;
|
const purposes = splitPurposes(allowedPurposes);
|
||||||
|
if (!subjectId || purposes.length === 0) return;
|
||||||
const subjectKind = targetType === "user" ? "account" : "group";
|
const subjectKind = targetType === "user" ? "account" : "group";
|
||||||
setGrants((current) => [
|
setGrants((current) => [
|
||||||
...current.filter(
|
...current.filter(
|
||||||
@@ -97,7 +103,8 @@ export default function CaseShareDialog({
|
|||||||
{
|
{
|
||||||
subject_kind: subjectKind,
|
subject_kind: subjectKind,
|
||||||
subject_id: subjectId,
|
subject_id: subjectId,
|
||||||
permissions: [permission]
|
permissions: [permission],
|
||||||
|
allowed_purposes: purposes
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
setTargetId("");
|
setTargetId("");
|
||||||
@@ -113,6 +120,7 @@ export default function CaseShareDialog({
|
|||||||
recorded_at: new Date().toISOString(),
|
recorded_at: new Date().toISOString(),
|
||||||
change_reason: changeReason.trim(),
|
change_reason: changeReason.trim(),
|
||||||
idempotency_key: idempotencyKey.current,
|
idempotency_key: idempotencyKey.current,
|
||||||
|
purpose,
|
||||||
access_mode: restricted ? "restricted" : "tenant",
|
access_mode: restricted ? "restricted" : "tenant",
|
||||||
access_grants: grants
|
access_grants: grants
|
||||||
});
|
});
|
||||||
@@ -169,6 +177,7 @@ export default function CaseShareDialog({
|
|||||||
closeDisabled={busy}
|
closeDisabled={busy}
|
||||||
portal
|
portal
|
||||||
className="case-share-dialog"
|
className="case-share-dialog"
|
||||||
|
helpContextId="cases.detail.access"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button disabled={busy} onClick={close}>Cancel</Button>
|
<Button disabled={busy} onClick={close}>Cancel</Button>
|
||||||
@@ -176,6 +185,7 @@ export default function CaseShareDialog({
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
disabledReason={saveDisabledReason}
|
disabledReason={saveDisabledReason}
|
||||||
onClick={() => setConfirmOpen(true)}
|
onClick={() => setConfirmOpen(true)}
|
||||||
|
helpContextId="cases.detail.access"
|
||||||
>
|
>
|
||||||
{busy ? "Saving" : "Save access"}
|
{busy ? "Saving" : "Save access"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -196,9 +206,9 @@ export default function CaseShareDialog({
|
|||||||
onChange={setRestricted}
|
onChange={setRestricted}
|
||||||
/>
|
/>
|
||||||
<p className="case-share-explanation">
|
<p className="case-share-explanation">
|
||||||
Tenant cases follow the Cases read permission. Restricted cases are visible only to
|
Tenant cases follow the Cases read permission. Restricted cases require a current
|
||||||
their creator, case administrators, assigned functions or units, and the explicit
|
grant whose permission and exact allowed purpose both match the attempted action.
|
||||||
grants below.
|
The creating account receives a purpose-bound custodian grant.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="case-share-add-row">
|
<div className="case-share-add-row">
|
||||||
@@ -237,11 +247,19 @@ export default function CaseShareDialog({
|
|||||||
<option value="admin">Administer</option>
|
<option value="admin">Administer</option>
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<FormField label="Allowed purposes" helpContextId="cases.field.allowed-purposes" documentation={CASES_FIELDS_DOCUMENTATION}>
|
||||||
|
<input
|
||||||
|
value={allowedPurposes}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) => setAllowedPurposes(event.target.value)}
|
||||||
|
placeholder="cases.casework, cases.search"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
<IconButton
|
<IconButton
|
||||||
label="Add access grant"
|
label="Add access grant"
|
||||||
icon={<Plus size={16} />}
|
icon={<Plus size={16} />}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabledReason={busy ? CASES_I18N.saving : !targetId.trim() ? CASES_I18N.targetRequired : undefined}
|
disabledReason={busy ? CASES_I18N.saving : !targetId.trim() ? CASES_I18N.targetRequired : splitPurposes(allowedPurposes).length === 0 ? "Declare at least one allowed purpose." : undefined}
|
||||||
onClick={addGrant}
|
onClick={addGrant}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -273,11 +291,22 @@ export default function CaseShareDialog({
|
|||||||
<option value="share">Share</option>
|
<option value="share">Share</option>
|
||||||
<option value="admin">Administer</option>
|
<option value="admin">Administer</option>
|
||||||
</select>
|
</select>
|
||||||
|
<input
|
||||||
|
aria-label={`Allowed purposes for ${grant.subject_id}`}
|
||||||
|
value={grant.allowed_purposes.join(", ")}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) => setGrants((current) => current.map((item) =>
|
||||||
|
item === grant
|
||||||
|
? { ...item, allowed_purposes: splitPurposes(event.target.value) }
|
||||||
|
: item
|
||||||
|
))}
|
||||||
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
label={`Remove access for ${grant.subject_id}`}
|
label={`Remove access for ${grant.subject_id}`}
|
||||||
icon={<Trash2 size={16} />}
|
icon={<Trash2 size={16} />}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
|
helpContextId="cases.detail.access"
|
||||||
onClick={() => setGrants((current) => current.filter((item) => item !== grant))}
|
onClick={() => setGrants((current) => current.filter((item) => item !== grant))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -317,3 +346,7 @@ export default function CaseShareDialog({
|
|||||||
function humanize(value: string): string {
|
function humanize(value: string): string {
|
||||||
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function splitPurposes(value: string): string[] {
|
||||||
|
return Array.from(new Set(value.split(/[\n,]+/).map((item) => item.trim()).filter(Boolean)));
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||||
|
import { useSearchParams } from "react-router";
|
||||||
import { Button,
|
import { Button,
|
||||||
DocumentationHelpLink,
|
DocumentationHelpLink,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
@@ -18,6 +19,7 @@ import { Button,
|
|||||||
type PlatformRouteContext
|
type PlatformRouteContext
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_CASE_ACCESS_PURPOSE,
|
||||||
listCaseCatalog,
|
listCaseCatalog,
|
||||||
listCases,
|
listCases,
|
||||||
type CaseCatalog,
|
type CaseCatalog,
|
||||||
@@ -28,8 +30,12 @@ import { CASES_DOCUMENTATION, CASES_I18N } from "./interfacePatterns";
|
|||||||
|
|
||||||
export default function CasesPage({ settings }: PlatformRouteContext) {
|
export default function CasesPage({ settings }: PlatformRouteContext) {
|
||||||
const navigate = useGuardedNavigate();
|
const navigate = useGuardedNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const initialPurpose = searchParams.get("purpose")?.trim() || DEFAULT_CASE_ACCESS_PURPOSE;
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [submittedQuery, setSubmittedQuery] = useState("");
|
const [submittedQuery, setSubmittedQuery] = useState("");
|
||||||
|
const [purpose, setPurpose] = useState(initialPurpose);
|
||||||
|
const [submittedPurpose, setSubmittedPurpose] = useState(initialPurpose);
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const [cases, setCases] = useState<CaseRecord[]>([]);
|
const [cases, setCases] = useState<CaseRecord[]>([]);
|
||||||
const [catalog, setCatalog] = useState<CaseCatalog>({ statuses: [], types: [] });
|
const [catalog, setCatalog] = useState<CaseCatalog>({ statuses: [], types: [] });
|
||||||
@@ -46,6 +52,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
|
|||||||
listCases(settings, {
|
listCases(settings, {
|
||||||
query: submittedQuery,
|
query: submittedQuery,
|
||||||
statuses: status ? [status] : undefined,
|
statuses: status ? [status] : undefined,
|
||||||
|
purpose: submittedPurpose,
|
||||||
limit: 200
|
limit: 200
|
||||||
}, controller.signal),
|
}, controller.signal),
|
||||||
listCaseCatalog(settings, controller.signal)
|
listCaseCatalog(settings, controller.signal)
|
||||||
@@ -62,7 +69,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
|
|||||||
}).
|
}).
|
||||||
finally(() => setLoading(false));
|
finally(() => setLoading(false));
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [reloadKey, settings, status, submittedQuery]);
|
}, [reloadKey, settings, status, submittedPurpose, submittedQuery]);
|
||||||
|
|
||||||
const statusLabels = useMemo(
|
const statusLabels = useMemo(
|
||||||
() => new Map(catalog.statuses.map((item) => [item.status_key, item])),
|
() => new Map(catalog.statuses.map((item) => [item.status_key, item])),
|
||||||
@@ -76,6 +83,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
|
|||||||
function submit(event: FormEvent) {
|
function submit(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSubmittedQuery(query.trim());
|
setSubmittedQuery(query.trim());
|
||||||
|
setSubmittedPurpose(purpose.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -96,6 +104,14 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
|
|||||||
aria-label="Search cases"
|
aria-label="Search cases"
|
||||||
placeholder="Search cases"
|
placeholder="Search cases"
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
value={purpose}
|
||||||
|
onChange={(event) => setPurpose(event.target.value)}
|
||||||
|
aria-label="Case access purpose"
|
||||||
|
placeholder="Case access purpose"
|
||||||
|
data-help-context-id="cases.field.access-purpose"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -135,7 +151,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
|
|||||||
<SelectionListItem
|
<SelectionListItem
|
||||||
selected={false}
|
selected={false}
|
||||||
key={item.reference.object_id}
|
key={item.reference.object_id}
|
||||||
onClick={() => navigate(`/cases/${encodeURIComponent(item.reference.object_id)}`)}>
|
onClick={() => navigate(`/cases/${encodeURIComponent(item.reference.object_id)}?purpose=${encodeURIComponent(submittedPurpose)}`)}>
|
||||||
<SelectionListItemContent title={item.title} description={`${item.case_number} · ${typeLabels.get(item.case_type_key) ?? humanize(item.case_type_key)} · ${formatDate(item.deadline_at)}`} />
|
<SelectionListItemContent title={item.title} description={`${item.case_number} · ${typeLabels.get(item.case_type_key) ?? humanize(item.case_type_key)} · ${formatDate(item.deadline_at)}`} />
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
status={statusDefinition?.terminal ? "inactive" : "active"}
|
status={statusDefinition?.terminal ? "inactive" : "active"}
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ const en = {
|
|||||||
"Permission": "Permission",
|
"Permission": "Permission",
|
||||||
"Save access": "Save access",
|
"Save access": "Save access",
|
||||||
"No explicit access grants.": "No explicit access grants.",
|
"No explicit access grants.": "No explicit access grants.",
|
||||||
|
"Case access purpose": "Case access purpose",
|
||||||
|
"Access purpose": "Access purpose",
|
||||||
|
"Access mode": "Access mode",
|
||||||
|
"Allowed purposes": "Allowed purposes",
|
||||||
|
"Declare at least one allowed purpose.": "Declare at least one allowed purpose.",
|
||||||
|
"Tenant cases follow the Cases read permission. Restricted cases require a current grant whose permission and exact allowed purpose both match the attempted action. The creating account receives a purpose-bound custodian grant.": "Tenant cases follow the Cases read permission. Restricted cases require a current grant whose permission and exact allowed purpose both match the attempted action. The creating account receives a purpose-bound custodian grant.",
|
||||||
"Record formal decision": "Record formal decision",
|
"Record formal decision": "Record formal decision",
|
||||||
"File Case in eAkte": "File Case in eAkte",
|
"File Case in eAkte": "File Case in eAkte",
|
||||||
"File Decision in eAkte": "File Decision in eAkte",
|
"File Decision in eAkte": "File Decision in eAkte",
|
||||||
@@ -184,6 +190,12 @@ const de: Record<keyof typeof en, string> = {
|
|||||||
"Permission": "Berechtigung",
|
"Permission": "Berechtigung",
|
||||||
"Save access": "Zugriff speichern",
|
"Save access": "Zugriff speichern",
|
||||||
"No explicit access grants.": "Keine ausdrücklichen Zugriffsfreigaben.",
|
"No explicit access grants.": "Keine ausdrücklichen Zugriffsfreigaben.",
|
||||||
|
"Case access purpose": "Zugriffszweck für Vorgänge",
|
||||||
|
"Access purpose": "Zugriffszweck",
|
||||||
|
"Access mode": "Zugriffsmodus",
|
||||||
|
"Allowed purposes": "Erlaubte Zwecke",
|
||||||
|
"Declare at least one allowed purpose.": "Geben Sie mindestens einen erlaubten Zweck an.",
|
||||||
|
"Tenant cases follow the Cases read permission. Restricted cases require a current grant whose permission and exact allowed purpose both match the attempted action. The creating account receives a purpose-bound custodian grant.": "Mandantenweit sichtbare Vorgänge folgen der Cases-Leseberechtigung. Eingeschränkte Vorgänge verlangen eine aktuelle Freigabe, deren Berechtigung und exakter erlaubter Zweck beide zur Aktion passen. Das anlegende Konto erhält eine zweckgebundene Custodian-Freigabe.",
|
||||||
"Record formal decision": "Formelle Entscheidung erfassen",
|
"Record formal decision": "Formelle Entscheidung erfassen",
|
||||||
"File Case in eAkte": "Vorgang in eAkte verakten",
|
"File Case in eAkte": "Vorgang in eAkte verakten",
|
||||||
"File Decision in eAkte": "Entscheidung in eAkte verakten",
|
"File Decision in eAkte": "Entscheidung in eAkte verakten",
|
||||||
|
|||||||
Reference in New Issue
Block a user