6 Commits
Author SHA1 Message Date
zemion afda3a5ac5 Release govoplan-cases v0.1.24: align published package metadata
Module Package Release / publish-packages (push) Successful in 11s
2026-09-08 01:32:27 +02:00
zemion 17415d8831 feat(cases): enforce purpose-bound case governance
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 20:05:31 +02:00
zemion 9435116299 docs: complete German structured documentation
Module Package Release / publish-packages (push) Successful in 10s
2026-08-24 01:15:32 +02:00
zemion e1cffd3e53 docs(cases): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 12s
2026-08-23 20:19:14 +02:00
zemion d395b22301 build(webui): expose tagged Cases package
Module Package Release / publish-packages (push) Successful in 12s
2026-08-22 11:48:43 +02:00
zemion d3a70f87cd feat(cases): accept replay-safe ticket escalations 2026-08-22 11:41:45 +02:00
33 changed files with 1492 additions and 166 deletions
+25 -7
View File
@@ -26,22 +26,29 @@ The executable backend slice now exposes:
assignment/evidence/Decision/record references, and replay-safe lifecycle
events; and
- `cases.service_launcher`, which opens exactly one deterministic case from an
exact published Service revision and safely replays the same Portal launch.
exact published Service revision and safely replays the same Portal launch;
and
- `tickets.case_escalation`, which creates exactly one formal Case from an
authorized Ticket handoff while preserving a governed Ticket reference.
Cases does not own institutional Service, Party, representation, identity,
address, Mandate, Decision, file, workflow, or task lifecycles. The `/cases`
workspace now supplies list/detail, status/title revision, history, and timeline
surfaces. A case can remain tenant-visible or become restricted to its creator,
case administrators, explicit user/group grants, and assignment-derived
function, function-assignment, or organization-unit grants. The detail surface
uses the shared reference selector to manage those grants; list, detail,
history, timeline, and update paths all apply the same fail-closed ACL.
surfaces. A case can remain tenant-visible or become restricted. Restricted
access requires a current custodian, explicit subject, or assignment-derived
grant whose permission and exact allowed purpose both match the operation.
`cases.casework` is the interactive default and `cases.search` is reserved for
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.
## 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
Cases reference is supplied and corroborated. Raw snapshots, metadata, search
text, free-text reasons, event payloads, evidence identifiers, request digests,
@@ -50,3 +57,14 @@ Historical case evidence is retained; current open case and active access facts
require authorized manual review through the existing lifecycle. Applicant
identity correlation remains a Parties responsibility and is never guessed
from a case's party references.
## Ticket escalation
When Tickets requests escalation, Cases rechecks tenant membership and
`cases:case:create`, resolves the exact active Case type and its initial status,
and derives a deterministic Case ID from the Ticket and idempotency key. A retry
returns the same Case. The Case keeps only the stable Ticket reference, source
revision, number, and authorized handoff note in governed context; it does not
copy Ticket comments, attachments, participants, or lifecycle state. If Cases
is absent or the configured type is unavailable, Tickets remains usable and
shows the integration consequence to the operator.
+49 -14
View File
@@ -88,18 +88,21 @@ The persistent backend provides:
- replay-safe case timeline events carrying the institutional context and a
stable event/audit reference
- 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 escalation from an authorized Ticket handoff
The `/cases` list and `/cases/:caseId` detail workspace provide server-side
search/status filtering, case facts, typed references, history, timeline, and
OCC-guarded title/status/access revisions with an explicit change reason.
Restricted cases are visible to their creator, case administrators, explicit
user/group grants, and matching function, function-assignment, or
organization-unit assignments. The same decision filters list, detail,
history, timeline, and update operations so an inaccessible identifier does
not disclose case existence. API reads and writes are tenant-bound, and
create/update/assign/close/share/catalog operations have separate permissions.
Restricted cases require a current custodian, explicit subject, or matching
function, function-assignment, or organization-unit grant. The grant must imply
the requested permission and contain the exact declared purpose. The same
decision filters list, detail, history, timeline, update, evidence, Decision,
Search, and Records-source operations so an inaccessible identifier does not
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
@@ -113,9 +116,14 @@ Implemented scopes:
- `cases:case:share`
- `cases:case:admin`
Access decisions combine tenant permissions, creator/administrative authority,
case assignments, and explicit case shares. Restricted access is versioned
with the case record so its history remains reconstructable.
Access decisions combine tenant permissions with current purpose-bound
custodian, assignment-derived, and explicit subject grants. Creating or newly
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
@@ -159,6 +167,15 @@ reference. Retrying the same launch returns the existing case; a changed request
cannot reuse the key. Portal therefore cannot create duplicate cases after an
ambiguous network response and does not gain access to Cases tables.
`tickets.case_escalation` is the optional effect boundary used by Tickets. It
rechecks `cases:case:create`, tenant identity, and the requested active Case
type, then derives a stable Case identifier from the Ticket and idempotency key.
The Case stores a governed `work_item` reference plus bounded source and handoff
metadata. It never imports Tickets or copies Ticket messages, files,
participants, assignment, or lifecycle. Replaying the same handoff returns the
existing Case, while an unavailable Cases capability leaves the operational
Ticket intact.
Cases links formal Decision records and may retain a current outcome/status
projection. It does not own decision authority, rule versions, reasoning,
correction, revocation, or remedy semantics.
@@ -177,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
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.
Records owns the filing decision and chronology; Cases remains authoritative
for the case and its revision history.
@@ -186,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
provider-owned evidence content or protected Decision reasoning. Restricted
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
@@ -220,8 +255,8 @@ The focused suite covers:
## Data-subject requests
Cases publishes `privacy.dsar.cases`. Canonical account, identity, and
membership selectors cover Cases-owned operator attribution; account and
identity selectors also cover explicit case-access grants. Exact
membership selectors cover Cases-owned operator attribution and matching
account, identity, or membership access grants. Exact
`cases.case`, `cases.revision`, `cases.access_grant`, and `cases.timeline`
references select lifecycle data. When a canonical and direct selector are
combined, a Cases-owned relationship must corroborate them and all supplied
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@govoplan/cases-webui",
"version": "0.1.24",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
},
"./styles/cases.css": "./webui/src/styles/cases.css"
},
"files": [
"webui/src",
"README.md",
"LICENSE"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.30",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
+2 -2
View File
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-cases"
version = "0.1.19"
version = "0.1.24"
description = "GovOPlaN administrative case context module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = ["govoplan-core>=0.1.18"]
dependencies = ["govoplan-core>=0.1.30"]
[tool.setuptools.packages.find]
where = ["src"]
+6
View File
@@ -140,6 +140,9 @@ class CaseAccessGrant(Base, TimestampMixin):
subject_kind: Mapped[str] = mapped_column(String(40), 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)
allowed_purposes: Mapped[list[str]] = mapped_column(
JSON, default=list, nullable=False
)
source: Mapped[str] = mapped_column(String(30), 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)
@@ -163,6 +166,9 @@ class CaseTimelineEntry(Base, TimestampMixin):
summary: Mapped[str] = mapped_column(String(500), nullable=False)
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)
purpose: Mapped[str] = mapped_column(
String(255), nullable=False, default="cases.casework", index=True
)
idempotency_key: Mapped[str] = mapped_column(String(255), 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)
+19 -3
View File
@@ -22,7 +22,7 @@ from govoplan_core.core.institutional import (
TemporalRevision,
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 (
can_access_case,
get_case,
@@ -56,6 +56,7 @@ class CaseDecisionCommand:
reasoning: str
change_reason: str
idempotency_key: str
purpose: str = DEFAULT_CASE_ACCESS_PURPOSE
conditions: tuple[str, ...] = ()
@@ -93,8 +94,14 @@ class CaseDecisionPath:
principal,
case_id=case_id,
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:
raise LookupError("Case not found.")
if not can_access_case(
@@ -102,6 +109,7 @@ class CaseDecisionPath:
principal,
case_id=case_id,
permission="update",
purpose=command.purpose,
):
raise PermissionError("Case update access is denied.")
@@ -163,6 +171,7 @@ class CaseDecisionPath:
recorded_at=command.effective_at,
change_reason=command.change_reason,
idempotency_key=f"formal-decision:{command.idempotency_key}",
purpose=command.purpose,
)
return CaseDecisionResult(case=linked, decision=persisted)
@@ -172,8 +181,14 @@ class CaseDecisionPath:
principal: object,
*,
case_id: str,
purpose: str | None = None,
) -> 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:
raise LookupError("Case not found.")
registry = _decision_registry(self._registry)
@@ -434,6 +449,7 @@ def _validate_command(command: CaseDecisionCommand) -> None:
(command.reasoning, "Decision reasoning"),
(command.change_reason, "Decision change reason"),
(command.idempotency_key, "Decision idempotency key"),
(command.purpose, "Case access purpose"),
):
if not value.strip():
raise CaseDecisionError(f"{label} is required.")
+30
View File
@@ -17,15 +17,19 @@ CASE_ACCESS_SUBJECT_KINDS = frozenset(
{
"account",
"identity",
"membership",
"group",
"role",
"function",
"function_assignment",
"organization_unit",
"service_account",
"delegation",
}
)
CASE_ACCESS_PERMISSIONS = frozenset({"read", "update", "share", "admin"})
DEFAULT_CASE_ACCESS_PURPOSE = "cases.casework"
MAX_CASE_ACCESS_PURPOSES = 100
@dataclass(frozen=True, slots=True)
@@ -33,6 +37,7 @@ class CaseGrant:
subject_kind: str
subject_id: str
permissions: tuple[str, ...] = ("read",)
allowed_purposes: tuple[str, ...] = (DEFAULT_CASE_ACCESS_PURPOSE,)
def __post_init__(self) -> None:
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:
raise InstitutionalContextError("Case access permissions are invalid.")
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]:
return {
"subject_kind": self.subject_kind,
"subject_id": self.subject_id,
"permissions": list(self.permissions),
"allowed_purposes": list(self.allowed_purposes),
}
@classmethod
@@ -58,10 +80,18 @@ class CaseGrant:
raise InstitutionalContextError(
"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(
subject_kind=_text(value, "subject_kind"),
subject_id=_text(value, "subject_id"),
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 (
("account", selectors.account_id),
("identity", selectors.identity_id),
("membership", selectors.membership_id),
):
if value:
conditions.append(
@@ -431,6 +432,10 @@ def _grant_subject_matches(
return bool(
(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 == "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_id": row.subject_id,
"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),
"active": row.active,
"source_revision": row.source_revision,
@@ -562,6 +570,7 @@ def _timeline_record(
"match_fields": list(match_fields),
"case_id": row.case_id,
"event_type": _bounded_text(row.event_type, 120),
"purpose": _bounded_text(row.purpose, 255),
"case_revision": row.case_revision,
"occurred_at": _iso(row.occurred_at),
"actor_id": row.actor_id if expose_actor else None,
+9 -1
View File
@@ -9,7 +9,7 @@ from urllib.parse import parse_qs, quote, urlsplit
from sqlalchemy.orm import Session
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
@@ -31,12 +31,14 @@ def link_case_evidence(
recorded_at: datetime,
change_reason: str,
idempotency_key: str,
purpose: str = DEFAULT_CASE_ACCESS_PURPOSE,
) -> CaseRecord:
base = _base_revision(
session,
principal,
case_id=case_id,
expected_revision=expected_revision,
purpose=purpose,
)
evidence = _validated_evidence(reference, tenant_id=base.reference.tenant_id)
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,
change_reason=change_reason,
idempotency_key=idempotency_key,
purpose=purpose,
)
@@ -63,12 +66,14 @@ def unlink_case_evidence(
recorded_at: datetime,
change_reason: str,
idempotency_key: str,
purpose: str = DEFAULT_CASE_ACCESS_PURPOSE,
) -> CaseRecord:
base = _base_revision(
session,
principal,
case_id=case_id,
expected_revision=expected_revision,
purpose=purpose,
)
evidence = next(
(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,
change_reason=change_reason,
idempotency_key=idempotency_key,
purpose=purpose,
)
@@ -119,12 +125,14 @@ def _base_revision(
*,
case_id: str,
expected_revision: int,
purpose: str,
) -> CaseRecord:
base = get_case(
session,
principal,
case_id=case_id,
revision=expected_revision,
purpose=purpose,
)
if base is None:
raise LookupError("Case revision not found.")
@@ -0,0 +1,100 @@
"""German translations for public structured documentation metadata."""
from __future__ import annotations
from typing import Any
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'cases.reference.lifecycle-access-and-evidence': {'consequence_classes': {'change_access': 'Beifügen '
'eines '
'bestätigten '
'Ereignisses '
'hinsichtlich '
'der '
'Sichtbarkeit/der '
'Überarbeitung '
'der '
'Finanzhilfe '
'und '
'des '
'Zeitplans',
'close_case': 'Anfügen '
'einer '
'Terminalrevision '
'nach '
'Close-Scope-Autorisierung',
'update_case': 'Anfügen '
'eines '
'OCC-geschützten '
'unveränderlichen '
'Revisions- '
'und '
'Zeitleistenereignisses'}},
'cases.workflow.file-exact-revision': {'outcome': 'Der eAkte behält eine genaue '
'Fall-Snapshot-Referenz, während Cases die '
'Autorität behält.',
'prerequisites': ['Sie können derzeit den Fall lesen und '
'Elemente in den Zieldatensatz eingeben.',
'Die genaue Revision des '
'unveränderlichen Falles wurde '
'ermittelt.'],
'steps': ['Öffnen Sie den Fall und identifizieren Sie die '
'Revision, die den Beweiszustand darstellt.',
'Wählen Sie den Zieldatensatz und geben Sie den '
'Zugangszweck und den Anmeldegrund an.',
'Bestätigen Sie die Einreichung; Cases überprüft '
'den aktuellen Zugriff erneut und löst die '
'genaue Überarbeitung.',
'Überprüfen Sie die Fallrevision und Snapshot '
'Digest in der Datensatzchronologie.']},
'cases.workflow.link-owner-references': {'consequence_classes': {'link_owner_reference': 'Eine '
'unveränderliche '
'Fallrevision, '
'die nur '
'die '
'genaue '
'typisierte '
'Eigentümerreferenz '
'enthält.',
'unlink_owner_reference': 'Hängt '
'eine '
'unveränderliche '
'Case-Revision '
'an '
'und '
'lässt '
'das '
'Owner-Objekt '
'unverändert.'}},
'cases.workflow.record-formal-decision': {'outcome': 'Das formale Ergebnis ist '
'Entscheidungsbesitz und exakte Version aus '
'dem Fall verknüpft.',
'prerequisites': ['Der Fall hat eine genaue '
'verantwortliche Organisation, '
'Funktion, Nachweise und rechtliche '
'Grundlage Version.',
'Eine aktive Handlungszuweisung und '
'genau ein wirksames Mandat '
'autorisieren den Entscheidungstyp.'],
'steps': ['Öffnen Sie den Fall und wählen Sie die '
'entsprechende Handlungszuweisung in der '
'Titelleiste aus.',
'Notieren Sie das operative Ergebnis, die '
'Begründung, die optionalen Bedingungen, die '
'effektive Zeit und den Änderungsgrund.',
'Bestätigen Sie die genauen Entscheidungs- '
'und Fallrevisionen in der Fallzeitleiste.',
'Wenn Records aktiviert ist, archivieren Sie '
'die genaue Entscheidungs- oder Fallrevision '
'im Zielort eAkte.']},
'cases.workflow.ticket-escalation': {'consequence_classes': {'create_case': 'Erstellt ein '
'Fall-eigenes '
'formales Verfahren '
'und gibt seine '
'stabile Referenz '
'zurück.',
'preserve_ticket': 'Konvertiert, '
'kopiert oder '
'verändert den '
'Ticketverlauf '
'nicht.'}}}
+353 -22
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
from govoplan_core.core.modules import with_documentation_structured_translations
from govoplan_cases.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
from pathlib import Path
from govoplan_core.core.module_guards import (
@@ -12,6 +15,10 @@ from govoplan_core.core.institutional import (
CAPABILITY_MANDATE_RESOLVER,
CAPABILITY_PARTY_RESOLVER,
)
from govoplan_core.core.information_governance import (
InformationGovernanceDimension,
ModuleInformationGovernance,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
@@ -31,6 +38,7 @@ from govoplan_core.core.modules import (
)
from govoplan_core.core.views import ViewSurface
from govoplan_core.core.search import SearchSourceProviderRegistration
from govoplan_core.core.tickets import CAPABILITY_TICKET_CASE_ESCALATION
from govoplan_core.core.provider_governance import (
ModuleArchitectureDeclaration,
ModuleArchitectureDocumentation,
@@ -42,7 +50,10 @@ from govoplan_cases.backend.party_context import (
)
from govoplan_cases.backend.acl import CaseAclProvider
from govoplan_cases.backend.db import models as case_models
from govoplan_cases.backend.dsar_provider import CASES_DSAR_CAPABILITY, CasesDsarProvider
from govoplan_cases.backend.dsar_provider import (
CASES_DSAR_CAPABILITY,
CasesDsarProvider,
)
from govoplan_cases.backend.service_intake import (
CAPABILITY_CASES_SERVICE_INTAKE,
CaseServiceIntake,
@@ -60,11 +71,12 @@ from govoplan_cases.backend.record_source import (
create_cases_record_source,
)
from govoplan_cases.backend.search_source import create_cases_search_source
from govoplan_cases.backend.ticket_escalation import TicketCaseEscalationProvider
from govoplan_core.db.base import Base
MODULE_ID = "cases"
MODULE_VERSION = "0.1.19"
MODULE_VERSION = "0.1.24"
READ_SCOPE = "cases:case:read"
CREATE_SCOPE = "cases:case:create"
UPDATE_SCOPE = "cases:case:update"
@@ -98,6 +110,10 @@ def _dsar_provider(context: ModuleContext) -> CasesDsarProvider:
return CasesDsarProvider()
def _ticket_escalation(_context: ModuleContext) -> TicketCaseEscalationProvider:
return TicketCaseEscalationProvider()
def _router(context: ModuleContext):
del context
from govoplan_cases.backend.router import router
@@ -163,20 +179,47 @@ manifest = ModuleManifest(
CAPABILITY_DECISION_REGISTRY,
),
permissions=(
_permission(READ_SCOPE, "View cases", "Read tenant cases and their governed history."),
_permission(CREATE_SCOPE, "Create cases", "Open a case from a configured case type or service intake."),
_permission(UPDATE_SCOPE, "Update cases", "Create a guarded immutable case revision."),
_permission(ASSIGN_SCOPE, "Assign cases", "Change stable function, assignment, or work-item references on a case."),
_permission(CLOSE_SCOPE, "Close cases", "Move a case to a configured terminal status."),
_permission(SHARE_SCOPE, "Share cases", "Restrict a case and grant object-level access to selected principals."),
_permission(ADMIN_SCOPE, "Administer cases", "Configure tenant case types and statuses."),
_permission(
READ_SCOPE, "View cases", "Read tenant cases and their governed history."
),
_permission(
CREATE_SCOPE,
"Create cases",
"Open a case from a configured case type or service intake.",
),
_permission(
UPDATE_SCOPE, "Update cases", "Create a guarded immutable case revision."
),
_permission(
ASSIGN_SCOPE,
"Assign cases",
"Change stable function, assignment, or work-item references on a case.",
),
_permission(
CLOSE_SCOPE, "Close cases", "Move a case to a configured terminal status."
),
_permission(
SHARE_SCOPE,
"Share cases",
"Restrict a case and grant object-level access to selected principals.",
),
_permission(
ADMIN_SCOPE, "Administer cases", "Configure tenant case types and statuses."
),
),
role_templates=(
RoleTemplate(
slug="case_manager",
name="Case manager",
description="Create, assign, update, and close tenant cases.",
permissions=(READ_SCOPE, CREATE_SCOPE, UPDATE_SCOPE, ASSIGN_SCOPE, CLOSE_SCOPE, SHARE_SCOPE),
permissions=(
READ_SCOPE,
CREATE_SCOPE,
UPDATE_SCOPE,
ASSIGN_SCOPE,
CLOSE_SCOPE,
SHARE_SCOPE,
),
),
RoleTemplate(
slug="case_reader",
@@ -188,7 +231,15 @@ manifest = ModuleManifest(
slug="case_administrator",
name="Case administrator",
description="Configure case types and statuses and manage cases.",
permissions=(READ_SCOPE, CREATE_SCOPE, UPDATE_SCOPE, ASSIGN_SCOPE, CLOSE_SCOPE, SHARE_SCOPE, ADMIN_SCOPE),
permissions=(
READ_SCOPE,
CREATE_SCOPE,
UPDATE_SCOPE,
ASSIGN_SCOPE,
CLOSE_SCOPE,
SHARE_SCOPE,
ADMIN_SCOPE,
),
),
),
route_factory=_router,
@@ -234,7 +285,11 @@ manifest = ModuleManifest(
label="i18n:govoplan-core.product_area.services_cases",
icon="landmark",
description="i18n:govoplan-core.product_area.services_cases_description",
surface_ids=("cases.nav.cases", "cases.route.cases", "cases.route.cases.caseid"),
surface_ids=(
"cases.nav.cases",
"cases.route.cases",
"cases.route.cases.caseid",
),
order=20,
),
),
@@ -333,13 +388,41 @@ manifest = ModuleManifest(
ModuleInterfaceProvider(name="cases.service_launcher", version="0.1.0"),
ModuleInterfaceProvider(name=CAPABILITY_RECORD_SOURCE_CASES, version="1.0.0"),
ModuleInterfaceProvider(name=CASES_DSAR_CAPABILITY, version="0.1.0"),
ModuleInterfaceProvider(
name=CAPABILITY_TICKET_CASE_ESCALATION, version="1.0.0"
),
),
requires_interfaces=(
ModuleInterfaceRequirement(name="services.definition", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="parties.procedure", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="parties.representation", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="mandates.resolution", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="decisions.formal_outcome", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(
name="services.definition",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="parties.procedure",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="parties.representation",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="mandates.resolution",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="decisions.formal_outcome",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
),
capability_factories={
CAPABILITY_CASES_SERVICE_INTAKE: _service_intake,
@@ -348,6 +431,7 @@ manifest = ModuleManifest(
CAPABILITY_CASES_SERVICE_LAUNCHER: _service_launcher,
CAPABILITY_RECORD_SOURCE_CASES: create_cases_record_source,
CASES_DSAR_CAPABILITY: _dsar_provider,
CAPABILITY_TICKET_CASE_ESCALATION: _ticket_escalation,
},
capability_documentation={
CAPABILITY_CASES_SERVICE_INTAKE: CapabilityDocumentation(
@@ -380,6 +464,11 @@ manifest = ModuleManifest(
summary="Finds minimized, tenant-scoped case access, attribution, and explicitly referenced lifecycle facts.",
contract_version="0.1.0",
),
CAPABILITY_TICKET_CASE_ESCALATION: CapabilityDocumentation(
label="Ticket-to-Case escalation",
summary="Creates one replay-safe formal Case from a Ticket handoff and returns only the stable Case reference.",
contract_version="1.0.0",
),
},
migration_spec=MigrationSpec(
module_id=MODULE_ID,
@@ -417,6 +506,47 @@ manifest = ModuleManifest(
),
tenant_summary_providers=(_tenant_summary,),
documentation=(
DocumentationTopic(
id="cases.workflow.ticket-escalation",
title="Create a formal Case from a Ticket escalation",
summary="Preserve the operational Ticket and start one replay-safe formal procedure with a stable cross-reference.",
body=(
"An authorized ticket triage actor selects an active Case type and submits a handoff note. "
"Cases rechecks the active tenant and cases:case:create permission, derives a stable Case identity from the Ticket and idempotency key, "
"uses the configured initial status, and records the exact Ticket work-item reference in the governed Case context. "
"The Ticket remains authoritative for intake, queue work, comments, and service resolution; the Case becomes authoritative for parties, evidence, decisions, deadlines, records, and retention. "
"Replaying the same request returns the existing Case. A changed request or missing/inactive Case type fails closed."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("case_manager", "ticket_manager", "operator", "auditor"),
related_modules=("tickets",),
order=11,
conditions=(
DocumentationCondition(
required_modules=("cases", "tickets"),
required_scopes=(CREATE_SCOPE, "tickets:ticket:triage"),
),
),
translations={
"de": {
"title": "Aus einer Ticket-Eskalation einen förmlichen Vorgang anlegen",
"summary": "Das operative Ticket erhalten und ein wiederholungssicheres förmliches Verfahren mit stabilem Querverweis starten.",
"body": "Eine berechtigte Triage wählt einen aktiven Vorgangstyp und übergibt einen Hinweis. Cases prüft den aktiven Mandanten und die Berechtigung cases:case:create erneut, leitet aus Ticket und Idempotenzschlüssel eine stabile Vorgangskennung ab, verwendet den konfigurierten Anfangsstatus und hält den genauen Ticket-Verweis im geregelten Vorgangskontext fest. Das Ticket bleibt für Aufnahme, Warteschlangenarbeit, Kommentare und Servicelösung maßgeblich; der Vorgang wird für Beteiligte, Nachweise, Entscheidungen, Fristen, Akten und Aufbewahrung maßgeblich. Dieselbe Anfrage liefert den vorhandenen Vorgang; geänderte Anfragen sowie fehlende oder inaktive Vorgangstypen werden abgewiesen.",
}
},
metadata={
"kind": "workflow",
"help_contexts": [
"tickets.action.escalate",
"cases.state.ticket-escalation",
],
"consequence_classes": {
"create_case": "Creates one Cases-owned formal procedure and returns its stable reference.",
"preserve_ticket": "Does not convert, copy, or mutate the Ticket history.",
},
},
),
DocumentationTopic(
id="cases.workflow.link-owner-references",
title="Link exact Files, Mail, and Campaign references to a Case",
@@ -460,6 +590,27 @@ manifest = ModuleManifest(
kind="repository",
),
),
translations={
"de": {
"title": "Exakte Files-, Mail- und Campaign-Verweise mit einem Vorgang verknüpfen",
"summary": (
"Einen vom zuständigen Modul autorisierten Verweis hinzufügen oder entfernen, ohne dessen Inhalt nach Cases zu kopieren."
),
"body": (
"Für einen aktiven Vorgang können Files, Mail und Campaigns über den versionierten Schnellzugriff-Ergebnisvertrag eine "
"exakte mandantengebundene Auswahl zurückgeben. Cases akzeptiert nur unterstützte Kombinationen aus zuständigem Modul "
"und Verweisart, den passenden Mandanten, eine festgelegte Version und die kanonische lokale Route des zuständigen Moduls. "
"Verknüpfen und Entfernen prüfen die Änderungsberechtigung für den Vorgang erneut, verwenden erwartete Revision und "
"Idempotenz und ergänzen eine unveränderliche Vorgangshistorie. Cases speichert nur typisierte Modulkennung, exakte "
"Version und Modulroute; Dateibytes, Nachrichtenkopf oder -inhalt, Empfangende, Campaign-Inhalt oder eine frühere "
"Zugriffsentscheidung werden nie kopiert. Das Öffnen wechselt stets in das zuständige Modul, das den aktuellen Zugriff "
"erneut prüft. Wird dieses Modul deaktiviert oder der Quellzugriff später entzogen, behält der Vorgang den historischen "
"Verweis, legt aber keinen Quellinhalt offen. Das Entfernen nimmt nur den Verweis aus der nächsten Vorgangsrevision und "
"löscht niemals das Quellobjekt. Ungespeicherte Vorgangsänderungen müssen vor dem Verknüpfen eines Schnellzugriff-Ergebnisses "
"gespeichert oder verworfen werden."
),
}
},
metadata={
"kind": "workflow",
"help_contexts": ["cases.detail.references"],
@@ -489,6 +640,27 @@ manifest = ModuleManifest(
kind="repository",
),
),
translations={
"de": {
"title": "Betroffenenanfragen für Vorgänge",
"summary": (
"Gesteuerte Vorgangstatsachen ausgeben, ohne die antragstellende Identität zu erraten oder undurchsichtige "
"Vorgangsdaten offenzulegen."
),
"body": (
"Der DSAR-Provider von Cases gleicht im exakten Mandanten Konten-, Identitäts- und Mitgliedschaftszuordnungen, "
"Zugriffsfreigaben für Konten oder Identitäten und ausdrückliche Cases-Verweise ab. Wird ein kanonischer Selektor mit "
"einem direkten Verweis kombiniert, muss eine Cases-eigene Beziehung beide bestätigen; andernfalls schließt die Suche "
"sicher. Direkte Vorgangs- und Revisionsverweise exportieren eine typisierte, minimierte Lebenszyklusprojektion. Rohe "
"Snapshots, Metadaten, Suchtext, Freitext-Änderungsgründe, Timeline-Nutzdaten und -Zusammenfassungen, Nachweiskennungen, "
"Anfrage-Digests, Idempotenzschlüssel, Auditkennungen und unbeteiligte Zugriffsbetroffene sind ausgeschlossen. "
"Unveränderliche Vorgangskennungen, Revisionen, Timelines und Zuordnungen werden aufbewahrt. Aktuelle offene Vorgänge und "
"aktive Zugriffsmerkmale erfordern eine berechtigte manuelle Prüfung im normalen Vorgangs-/Zugriffslebenszyklus; eine "
"automatische Löschung wird nicht angeboten. Die Verbindung zwischen antragstellender Person und Identität gehört Parties; "
"Cases leitet sie daher nicht aus Beteiligtenkennungen ab."
),
}
},
),
DocumentationTopic(
id="cases.workflow.quick-access-context",
@@ -552,9 +724,31 @@ manifest = ModuleManifest(
),
links=(
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
DocumentationLink(label="Cases concept", href="govoplan-cases/docs/CONCEPT.md", kind="repository"),
DocumentationLink(label="Decisions domain", href="govoplan-decisions/docs/DECISIONS_DOMAIN.md", kind="repository"),
DocumentationLink(
label="Cases concept",
href="govoplan-cases/docs/CONCEPT.md",
kind="repository",
),
DocumentationLink(
label="Decisions domain",
href="govoplan-decisions/docs/DECISIONS_DOMAIN.md",
kind="repository",
),
),
translations={
"de": {
"title": "Förmliche Entscheidung aus einem Vorgang festhalten",
"summary": "Handlungsbefugnis erneut prüfen und eine exakte Decision-Revision in der Vorgangshistorie bewahren.",
"body": (
"Die Aktion in der Vorgangsdetailansicht akzeptiert nur Tenor, Begründung, Bedingungen, Wirksamkeitszeitpunkt und "
"Änderungsgrund. Cases löst die exakte Vorgangsrevision auf, prüft den objektbezogenen Änderungszugriff, ermittelt über "
"Access die aktuelle Handlungszuweisung und verlangt genau ein wirksames Mandat für cases.formal_decision und den gewählten "
"Decision-Typ. Anschließend wird das Ergebnis über das optionale Decisions-Register aufgezeichnet und die exakte "
"Decision-Revision in derselben Datenbanktransaktion mit einer neuen Vorgangsrevision verknüpft. Eine Wiederholung "
"verwendet eine deterministische Decision-Kennung; abweichende Nutzdaten schließen sicher."
),
}
},
metadata={
"kind": "workflow",
"help_contexts": ["cases.action.decide", "cases.decision.editor"],
@@ -595,8 +789,24 @@ manifest = ModuleManifest(
links=(
DocumentationLink(label="Cases", href="/cases", kind="runtime"),
DocumentationLink(label="Records", href="/records", kind="runtime"),
DocumentationLink(label="Cases concept", href="govoplan-cases/docs/CONCEPT.md", kind="repository"),
DocumentationLink(
label="Cases concept",
href="govoplan-cases/docs/CONCEPT.md",
kind="repository",
),
),
translations={
"de": {
"title": "Exakte Vorgangsrevision in eine eAkte ablegen",
"summary": "Einen rekonstruierbaren Vorgangs-Snapshot in Records bewahren, ohne die Zuständigkeit von Cases zu verlagern.",
"body": (
"Ist Records aktiviert, löst Cases eine exakte unveränderliche Vorgangsrevision erst auf, nachdem aktuelle "
"Vorgangsberechtigung und objektbezogener Zugriff geprüft wurden. Der abgelegte Verweis enthält Vorgangsnummer, "
"Lebenszyklusstatus, dargestelltes Gültigkeitsintervall, Aufzeichnungszeitpunkt und einen SHA-256-Digest des kanonischen "
"Snapshots. Cases bleibt für den Vorgangslebenszyklus maßgeblich."
),
}
},
metadata={
"kind": "workflow",
"help_contexts": ["cases.detail", "records.action.file"],
@@ -613,6 +823,45 @@ manifest = ModuleManifest(
"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(
id="cases.institutional-context",
title="Case institutional context",
@@ -638,6 +887,23 @@ manifest = ModuleManifest(
kind="repository",
),
),
translations={
"de": {
"title": "Institutioneller Kontext eines Vorgangs",
"summary": (
"Cases bewahrt Service- und Beteiligtenverweise, ohne institutionelle Definitionen oder Betroffenenstammdaten zu übernehmen."
),
"body": (
"Vorgangstypen und -status sind Mandantenkonfiguration. Jede Anlage oder Änderung schreibt eine unveränderliche, durch "
"optimistische Nebenläufigkeitskontrolle geschützte Revision und ein wiederholungssicheres Timeline-Ereignis. Die "
"Service-Aufnahme hält exakte Bindungen an Service, Mandat, Zuständigkeit, Rechtsgrundlage, Formular, Workflow und Ergebnis "
"fest. Verfahrensbeteiligte stammen von einem optionalen Provider oder einer begrenzten Cases-Kompatibilitätsprojektion. "
"Zuweisungs-, Nachweis-, Decision- und Aktenverweise bleiben stabile Referenzen im Eigentum ihrer Quellmodule. Ist Search "
"aktiviert, liefert Cases eine neu aufbaubare reine Metadatenprojektion. Nachweise und geschützte Decision-Inhalte sind "
"ausgeschlossen; jeder Treffer wird vor der Offenlegung erneut gegen den aktuellen Vorgangszugriff geprüft."
),
}
},
metadata={
"help_contexts": [
"cases.list",
@@ -658,8 +924,8 @@ manifest = ModuleManifest(
"appends a timeline entry with actor, time, and change reason; a terminal "
"status additionally requires the case-close permission. Case visibility is "
"tenant-wide or restricted. Restricted cases remain visible only through "
"administrative authority, assignment or unit context, creator authority, or "
"an explicit account/group grant. Access changes append another immutable "
"a current purpose-bound custodian, assignment/unit context, or explicit "
"subject grant. Permission and exact allowed purpose must both match. Access changes append another immutable "
"revision and require confirmation. Service, party, assignment, Decision, and "
"record references identify provider-owned objects; Cases preserves their "
"stable identifiers and versions without copying or silently changing them."
@@ -674,7 +940,26 @@ manifest = ModuleManifest(
kind="repository",
),
),
translations={
"de": {
"title": "Referenz für Vorgangslebenszyklus, Zugriff und Nachweise",
"summary": (
"Revisions-, Status-, Zugriffs- und Verweisfelder gemeinsam mit ihren dauerhaften Folgen erläutern."
),
"body": (
"Ä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, "
"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 über eine aktuelle zweckgebundene "
"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-, "
"Zuweisungs-, Decision- und Aktenverweise bezeichnen Objekte anderer Provider; Cases bewahrt ihre stabilen Kennungen und "
"Versionen, ohne sie zu kopieren oder stillschweigend zu verändern."
),
}
},
metadata={
"kind": "reference",
"help_contexts": [
"cases.field.title",
"cases.field.status",
@@ -691,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(
layer="human_work_procedure",
kind="domain",
@@ -729,5 +1055,10 @@ manifest = ModuleManifest(
)
manifest = with_documentation_structured_translations(
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
)
def get_manifest() -> ModuleManifest:
return manifest
@@ -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")
+9 -2
View File
@@ -49,7 +49,11 @@ class CasesRecordSource:
):
raise RecordContractError("Current Cases read permission is required.")
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.")
try:
@@ -93,7 +97,10 @@ class CasesRecordSource:
valid_from=row.opened_at,
valid_to=row.closed_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={
"case_number": identity.case_number,
"case_type_key": row.case_type_key,
+24 -1
View File
@@ -174,6 +174,7 @@ def api_list_cases(
query: str = "",
status_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),
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
@@ -187,6 +188,7 @@ def api_list_cases(
query=query,
status_keys=status_key,
case_type_keys=case_type_key,
purpose=purpose,
offset=offset,
limit=limit,
)
@@ -216,6 +218,7 @@ def api_create_case(
principal,
record=record,
idempotency_key=payload.idempotency_key,
purpose=payload.purpose,
)
session.commit()
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]]])
def api_case_decisions(
case_id: str,
purpose: str | None = Query(default=None, max_length=255),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, list[dict[str, Any]]]:
@@ -237,6 +241,7 @@ def api_case_decisions(
session,
principal,
case_id=case_id,
purpose=purpose,
)
except (
CaseDecisionError,
@@ -281,6 +286,7 @@ def api_record_case_decision(
conditions=tuple(payload.conditions),
change_reason=payload.change_reason,
idempotency_key=payload.idempotency_key,
purpose=payload.purpose,
),
)
session.commit()
@@ -300,11 +306,18 @@ def api_record_case_decision(
def api_get_case(
case_id: str,
revision: int | None = Query(default=None, ge=1),
purpose: str | None = Query(default=None, max_length=255),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, Any]:
_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:
raise HTTPException(status_code=404, detail="Case not found")
return item.to_dict()
@@ -342,6 +355,7 @@ def api_update_case(
recorded_at=payload.recorded_at,
change_reason=payload.change_reason,
idempotency_key=payload.idempotency_key,
purpose=payload.purpose,
)
session.commit()
except (
@@ -373,6 +387,7 @@ def api_link_case_evidence(
recorded_at=payload.recorded_at,
change_reason=payload.change_reason,
idempotency_key=payload.idempotency_key,
purpose=payload.purpose,
)
session.commit()
except (
@@ -408,6 +423,7 @@ def api_unlink_case_evidence(
recorded_at=payload.recorded_at,
change_reason=payload.change_reason,
idempotency_key=payload.idempotency_key,
purpose=payload.purpose,
)
session.commit()
except (
@@ -432,6 +448,7 @@ def api_case_share_target_options(
selected: list[str] = Query(default=[]),
limit: int = Query(default=50, ge=1, le=200),
cursor: str | None = None,
purpose: str = Query(default="cases.casework", min_length=1, max_length=255),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> ReferenceOptionListResponse:
@@ -441,6 +458,7 @@ def api_case_share_target_options(
principal,
case_id=case_id,
permission="share",
purpose=purpose,
):
raise HTTPException(status_code=403, detail="Case share access is denied")
try:
@@ -470,6 +488,7 @@ def api_case_share_target_options(
def api_case_history(
case_id: str,
limit: int = Query(default=100, ge=1, le=200),
purpose: str | None = Query(default=None, max_length=255),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> CaseHistoryResponse:
@@ -482,6 +501,7 @@ def api_case_history(
principal,
case_id=case_id,
limit=limit,
purpose=purpose,
)
]
)
@@ -491,6 +511,7 @@ def api_case_history(
def api_case_timeline(
case_id: str,
limit: int = Query(default=200, ge=1, le=500),
purpose: str | None = Query(default=None, max_length=255),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> CaseTimelineResponse:
@@ -502,6 +523,7 @@ def api_case_timeline(
principal,
case_id=case_id,
limit=limit,
purpose=purpose,
)
)
)
@@ -513,6 +535,7 @@ def _update_changes(payload: CaseUpdateRequest) -> dict[str, object]:
"recorded_at",
"change_reason",
"idempotency_key",
"purpose",
}
raw = payload.model_dump(exclude_unset=True, exclude=excluded)
for key in ("party_refs", "assignment_refs", "decision_refs", "record_refs"):
+12
View File
@@ -34,6 +34,7 @@ class CaseWriteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
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)
@@ -43,12 +44,14 @@ class CaseGrantRequest(BaseModel):
subject_kind: Literal[
"account",
"identity",
"membership",
"group",
"role",
"function",
"function_assignment",
"organization_unit",
"service_account",
"delegation",
]
subject_id: str = Field(min_length=1, max_length=255)
permissions: list[Literal["read", "update", "share", "admin"]] = Field(
@@ -56,6 +59,11 @@ class CaseGrantRequest(BaseModel):
min_length=1,
max_length=4,
)
allowed_purposes: list[str] = Field(
default_factory=lambda: ["cases.casework"],
min_length=1,
max_length=100,
)
class CaseUpdateRequest(BaseModel):
@@ -65,6 +73,7 @@ class CaseUpdateRequest(BaseModel):
recorded_at: datetime
change_reason: str = Field(min_length=1, max_length=1_000)
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)
title: str | None = Field(default=None, min_length=1, max_length=500)
access_mode: Literal["tenant", "restricted"] | None = None
@@ -100,6 +109,7 @@ class CaseEvidenceLinkRequest(BaseModel):
recorded_at: datetime
change_reason: str = Field(min_length=1, max_length=1_000)
idempotency_key: str = Field(min_length=1, max_length=255)
purpose: str = Field(default="cases.casework", min_length=1, max_length=255)
reference: CaseOwnerReferenceRequest
@@ -110,6 +120,7 @@ class CaseEvidenceUnlinkRequest(BaseModel):
recorded_at: datetime
change_reason: str = Field(min_length=1, max_length=1_000)
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):
@@ -123,6 +134,7 @@ class CaseDecisionRequest(BaseModel):
conditions: list[str] = Field(default_factory=list, max_length=100)
change_reason: str = Field(min_length=1, max_length=1_000)
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):
+7 -1
View File
@@ -30,6 +30,7 @@ PROVIDER_ID = "cases.cases"
RESOURCE_TYPE = "case"
READ_SCOPE = "cases:case:read"
ADMIN_SCOPE = "cases:case:admin"
SEARCH_ACCESS_PURPOSE = "cases.search"
class CasesSearchSource:
@@ -118,6 +119,7 @@ class CasesSearchSource:
principal,
case_id=reference.resource_id,
permission="read",
purpose=SEARCH_ACCESS_PURPOSE,
)
return decisions
@@ -219,7 +221,10 @@ def _document(
resource_type=RESOURCE_TYPE,
resource_id=row.case_id,
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}",
body=row.search_text[:200_000],
keywords=(
@@ -279,5 +284,6 @@ __all__ = [
"CasesSearchSource",
"PROVIDER_ID",
"RESOURCE_TYPE",
"SEARCH_ACCESS_PURPOSE",
"create_cases_search_source",
]
+178 -67
View File
@@ -8,7 +8,7 @@ import json
from typing import Any
import uuid
from sqlalchemy import and_, exists, func, or_
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_core.core.events import (
@@ -32,7 +32,11 @@ from govoplan_cases.backend.db.models import (
CaseTimelineEntry,
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
@@ -222,8 +226,10 @@ def create_case(
*,
record: CaseRecord,
idempotency_key: str,
purpose: str = DEFAULT_CASE_ACCESS_PURPOSE,
) -> CaseRecord:
tenant_id = _principal_tenant(principal)
declared_purpose = _access_purpose(purpose)
if record.reference.tenant_id != tenant_id:
raise CaseStoreError("Case records cannot cross tenants.")
if record.revision != 1:
@@ -241,6 +247,7 @@ def create_case(
principal,
case_id=replay.reference.object_id,
permission="read",
purpose=declared_purpose,
):
raise PermissionError("Case replay access is denied.")
return replay
@@ -274,6 +281,7 @@ def create_case(
idempotency_key=idempotency_key,
request_sha256=request_sha256,
operation="created",
purpose=declared_purpose,
)
return persisted
@@ -353,8 +361,10 @@ def update_case(
recorded_at: datetime,
change_reason: str,
idempotency_key: str,
purpose: str = DEFAULT_CASE_ACCESS_PURPOSE,
) -> CaseRecord:
tenant_id = _principal_tenant(principal)
declared_purpose = _access_purpose(purpose)
current_row = _current_row(
session,
tenant_id=tenant_id,
@@ -363,7 +373,13 @@ def update_case(
)
if current_row is None:
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.")
current = _record_from_row(current_row)
normalized = _normalized_changes(changes)
@@ -383,6 +399,14 @@ def update_case(
request_sha256=request_sha256,
)
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
if current.revision != expected_revision:
raise CaseStoreError(
@@ -461,6 +485,7 @@ def update_case(
idempotency_key=idempotency_key,
request_sha256=request_sha256,
operation="closed" if terminal and not current.closed_at else "updated",
purpose=declared_purpose,
)
@@ -470,6 +495,7 @@ def get_case(
*,
case_id: str,
revision: int | None = None,
purpose: str | None = None,
) -> CaseRecord | None:
tenant_id = _principal_tenant(principal)
query = session.query(CaseRecordRevision).filter(
@@ -486,6 +512,7 @@ def get_case(
principal,
case_id=case_id,
permission="read",
purpose=purpose,
):
return None
return _record_from_row(row) if row is not None else None
@@ -498,6 +525,7 @@ def list_cases(
query: str = "",
status_keys: Sequence[str] | None = None,
case_type_keys: Sequence[str] | None = None,
purpose: str | None = None,
offset: int = 0,
limit: int = 100,
) -> tuple[tuple[CaseRecord, ...], int]:
@@ -510,7 +538,13 @@ def list_cases(
CaseRecordRevision.tenant_id == tenant_id,
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:
statement = statement.filter(
CaseRecordRevision.status_key.in_(tuple(status_keys))
@@ -543,8 +577,15 @@ def case_history(
*,
case_id: str,
limit: int = 100,
purpose: str | None = None,
) -> 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 ()
if not 1 <= limit <= 200:
raise CaseStoreError("Case history limit must be between 1 and 200.")
@@ -567,8 +608,15 @@ def case_timeline(
*,
case_id: str,
limit: int = 200,
purpose: str | None = None,
) -> 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 ()
if not 1 <= limit <= 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):
raise InstitutionalContextError("Case lookup cannot cross tenants.")
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]:
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
@@ -608,6 +667,7 @@ def can_access_case(
*,
case_id: str,
permission: str = "read",
purpose: str | None = None,
) -> bool:
if permission not in {"read", "update", "share", "admin"}:
raise CaseStoreError("Unsupported case access permission.")
@@ -620,87 +680,86 @@ def can_access_case(
)
if current is None:
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":
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)
if not subjects:
return False
clauses = [
and_(
CaseAccessGrant.subject_kind == kind,
CaseAccessGrant.subject_id == subject_id,
)
for kind, subject_id in subjects
]
grants = (
session.query(CaseAccessGrant)
.filter(
CaseAccessGrant.tenant_id == tenant_id,
CaseAccessGrant.case_id == case_id,
CaseAccessGrant.active.is_(True),
or_(*clauses),
)
.all()
)
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
)
def _filter_accessible_cases(statement, principal: object, *, permission: str):
if _has_scope(principal, "cases:case:admin"):
return statement
conditions = [CaseRecordRevision.access_mode == "tenant"]
actor_ids = _principal_actor_ids(principal)
if actor_ids:
conditions.append(
exists().where(
and_(
CaseIdentity.tenant_id == CaseRecordRevision.tenant_id,
CaseIdentity.case_id == CaseRecordRevision.case_id,
CaseIdentity.created_by.in_(actor_ids),
)
)
)
def _eligible_case_ids(
session: Session,
principal: object,
*,
permission: str,
purpose: str | None,
) -> tuple[str, ...]:
tenant_id = _principal_tenant(principal)
current = session.query(
CaseRecordRevision.case_id,
CaseRecordRevision.access_mode,
).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)
if subjects:
subject_clause = or_(
*(
and_(
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),
subject_clause,
)
)
)
if permission != "read":
raise CaseStoreError("Case list filtering supports read access only.")
return statement.filter(or_(*conditions))
if not subjects:
return tuple(eligible)
grants = session.query(CaseAccessGrant).filter(
CaseAccessGrant.tenant_id == tenant_id,
CaseAccessGrant.active.is_(True),
).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)
def _sync_access_grants(
session: Session,
principal: object,
record: CaseRecord,
*,
purpose: str,
ensure_custodian: bool,
) -> None:
desired: dict[tuple[str, str, str], tuple[str, ...]] = {
(item.subject_kind, item.subject_id, "explicit"): item.permissions
desired: dict[
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 reference in record.assignment_refs:
@@ -711,8 +770,8 @@ def _sync_access_grants(
}:
continue
desired[(reference.kind, reference.object_id, "assignment")] = (
"read",
"update",
("read", "update"),
("cases.casework", "cases.search", "cases.records"),
)
rows = (
session.query(CaseAccessGrant)
@@ -725,11 +784,21 @@ def _sync_access_grants(
existing = {
(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():
if key not in desired:
if key not in desired and row.source != "custodian":
row.active = False
row.source_revision = record.revision
for key, permissions in desired.items():
for key, (permissions, allowed_purposes) in desired.items():
row = existing.get(key)
if row is None:
session.add(
@@ -739,6 +808,7 @@ def _sync_access_grants(
subject_kind=key[0],
subject_id=key[1],
permissions=list(permissions),
allowed_purposes=list(allowed_purposes),
source=key[2],
active=True,
source_revision=record.revision,
@@ -747,6 +817,7 @@ def _sync_access_grants(
)
else:
row.permissions = list(permissions)
row.allowed_purposes = list(allowed_purposes)
row.active = True
row.source_revision = record.revision
session.flush()
@@ -780,6 +851,7 @@ def _principal_subjects(principal: object) -> tuple[tuple[str, str], ...]:
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()
@@ -791,15 +863,41 @@ def _principal_subjects(principal: object) -> tuple[tuple[str, str], ...]:
("function", "function_ids"),
("function_assignment", "function_assignment_ids"),
("organization_unit", "organization_unit_ids"),
("delegation", "delegation_ids"),
):
subjects.extend(
(kind, str(item))
for item in (getattr(principal, name, ()) or ())
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))
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:
method = getattr(principal, "has", None)
if callable(method):
@@ -820,6 +918,7 @@ def _record_revision(
idempotency_key: str,
request_sha256: str,
operation: str,
purpose: str,
) -> CaseRecord:
clean_key = _required_text(
idempotency_key,
@@ -860,6 +959,7 @@ def _record_revision(
summary=f"Case {identity.case_number} {operation}.",
occurred_at=record.recorded_at,
actor_id=_principal_actor(principal),
purpose=purpose,
idempotency_key=clean_key,
request_sha256=request_sha256,
audit_event_id=event_id,
@@ -870,12 +970,22 @@ def _record_revision(
"revision": record.revision,
"access_mode": record.access_mode,
"access_grant_count": len(record.access_grants),
"purpose": purpose,
"change_reason": record.change_reason,
},
)
session.add_all((row, timeline))
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(
session,
PlatformEvent(
@@ -1108,6 +1218,7 @@ def _timeline_payload(row: CaseTimelineEntry) -> dict[str, object]:
"summary": row.summary,
"occurred_at": _datetime_text(row.occurred_at),
"actor_id": row.actor_id,
"purpose": row.purpose,
"audit_event_id": row.audit_event_id,
"payload": dict(row.payload or {}),
}
@@ -0,0 +1,161 @@
from __future__ import annotations
from urllib.parse import quote
import uuid
from sqlalchemy.orm import Session
from govoplan_core.core.institutional import (
GovernedContextEnvelope,
InstitutionalReference,
TemporalRevision,
)
from govoplan_core.core.tickets import (
TicketCaseEscalationCommand,
TicketCaseEscalationResult,
)
from govoplan_core.security.module_permissions import scopes_grant_compatible
from govoplan_cases.backend.domain import CaseRecord
from govoplan_cases.backend.service import create_case, get_case, list_case_catalog
_CASE_NAMESPACE = uuid.uuid5(
uuid.NAMESPACE_URL,
"https://govoplan.add-ideas.de/contracts/tickets/case-escalation/v1",
)
class TicketCaseEscalationProvider:
"""Create one replay-safe formal Case while keeping Ticket history separate."""
def escalate_ticket(
self,
session: object,
principal: object,
*,
command: TicketCaseEscalationCommand,
) -> TicketCaseEscalationResult:
db = _session(session)
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
if command.tenant_id != tenant_id:
raise PermissionError("Ticket escalation cannot cross tenants.")
if not _has_scope(principal, "cases:case:create"):
raise PermissionError("Ticket escalation requires cases:case:create.")
case_id = str(
uuid.uuid5(
_CASE_NAMESPACE,
"\0".join((tenant_id, command.ticket_id, command.idempotency_key)),
)
)
existing = get_case(db, principal, case_id=case_id)
if existing is not None:
return _result(existing, replayed=True)
catalog = list_case_catalog(db, principal)
case_type = next(
(
item
for item in catalog["types"]
if item.get("type_key") == command.case_type_key and item.get("active")
),
None,
)
if case_type is None:
raise ValueError(
f"Case type {command.case_type_key!r} is not configured or active."
)
case_ref = InstitutionalReference(
kind="case",
owner_module="cases",
object_id=case_id,
tenant_id=tenant_id,
version="1",
valid_at=command.occurred_at,
)
ticket_ref = InstitutionalReference(
kind="work_item",
owner_module="tickets",
object_id=command.ticket_id,
tenant_id=tenant_id,
version=str(command.metadata.get("ticket_revision") or "1"),
valid_at=command.occurred_at,
label=command.ticket_number,
)
change_reason = f"Escalated from ticket {command.ticket_number}."
context = GovernedContextEnvelope(
tenant_id=tenant_id,
temporal=TemporalRevision(
revision="1",
valid_from=command.occurred_at,
recorded_at=command.occurred_at,
change_reason=change_reason,
),
case_ref=case_ref,
work_item_ref=ticket_ref,
)
record = CaseRecord(
reference=case_ref,
case_number=_case_number(command, case_id),
case_type_key=command.case_type_key,
status_key=str(case_type["initial_status_key"]),
title=command.title,
context=context,
opened_at=command.occurred_at,
recorded_at=command.occurred_at,
change_reason=change_reason,
metadata={
"source_module": "tickets",
"source_resource_type": "ticket",
"source_resource_id": command.ticket_id,
"source_resource_number": command.ticket_number,
"source_revision": str(command.metadata.get("ticket_revision") or "1"),
"handoff_note": command.handoff_note,
"integration_contract": "tickets.case_escalation/v1",
},
)
created = create_case(
db,
principal,
record=record,
idempotency_key=f"ticket-escalation:{command.idempotency_key}",
)
return _result(created, replayed=False)
def _result(record: CaseRecord, *, replayed: bool) -> TicketCaseEscalationResult:
return TicketCaseEscalationResult(
provider_id="cases",
case_id=record.reference.object_id,
case_number=record.case_number,
case_url=f"/cases/{quote(record.reference.object_id, safe='')}",
replayed=replayed,
metadata={
"case_revision": record.revision,
"case_type_key": record.case_type_key,
"status_key": record.status_key,
},
)
def _case_number(command: TicketCaseEscalationCommand, case_id: str) -> str:
prefix = f"CASE-{command.ticket_number}"[:230].rstrip("-")
return f"{prefix}-{case_id[:8].upper()}"
def _has_scope(principal: object, scope: str) -> bool:
method = getattr(principal, "has", None)
if callable(method):
return bool(method(scope))
return scopes_grant_compatible(
frozenset(getattr(principal, "scopes", ()) or ()),
scope,
)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Ticket Case escalation requires a SQLAlchemy session.")
return value
__all__ = ["TicketCaseEscalationProvider"]
+105 -5
View File
@@ -324,13 +324,66 @@ class CaseLifecycleTests(unittest.TestCase):
outsider = Principal(account_id="account-2")
self.assertIsNone(get_case(self.session, outsider, case_id="case-1"))
self.assertEqual(0, list_cases(self.session, outsider)[1])
self.assertEqual(
0,
list_cases(
self.session,
outsider,
purpose="cases.casework",
)[1],
)
self.assertEqual((), case_history(self.session, outsider, case_id="case-1"))
self.assertEqual((), case_timeline(self.session, outsider, case_id="case-1"))
reader = Principal(account_id="account-3", group_ids=("reviewers",))
self.assertIsNotNone(get_case(self.session, reader, case_id="case-1"))
self.assertEqual(1, list_cases(self.session, reader)[1])
self.assertIsNone(get_case(self.session, reader, case_id="case-1"))
self.assertIsNone(
get_case(
self.session,
reader,
case_id="case-1",
purpose="cases.unrelated",
)
)
self.assertIsNotNone(
get_case(
self.session,
reader,
case_id="case-1",
purpose="cases.casework",
)
)
administrator = Principal(
account_id="account-6",
scopes=frozenset({"cases:case:admin"}),
)
self.assertIsNone(
get_case(
self.session,
administrator,
case_id="case-1",
purpose="cases.casework",
)
)
self.assertEqual(
1,
list_cases(
self.session,
reader,
purpose="cases.casework",
)[1],
)
self.assertEqual(
1,
len(
case_history(
self.session,
reader,
case_id="case-1",
purpose="cases.casework",
)
),
)
with self.assertRaises(PermissionError):
update_case(
self.session,
@@ -396,9 +449,56 @@ class CaseLifecycleTests(unittest.TestCase):
self.session.commit()
self.assertEqual(3, shared.revision)
self.assertIsNotNone(
get_case(self.session, Principal(account_id="account-5"), case_id="case-1")
get_case(
self.session,
Principal(account_id="account-5"),
case_id="case-1",
purpose="cases.casework",
)
)
self.assertEqual(
3,
len(
case_history(
self.session,
self.principal,
case_id="case-1",
purpose="cases.casework",
)
),
)
revoked = update_case(
self.session,
self.principal,
case_id="case-1",
expected_revision=3,
changes={"access_grants": []},
recorded_at=NOW + timedelta(minutes=3),
change_reason="Removed direct collaboration access.",
idempotency_key="case-share-revoke",
purpose="cases.casework",
)
self.session.commit()
self.assertEqual(4, revoked.revision)
self.assertIsNone(
get_case(
self.session,
Principal(account_id="account-5"),
case_id="case-1",
revision=2,
purpose="cases.casework",
)
)
self.assertEqual(
"cases.casework",
case_timeline(
self.session,
self.principal,
case_id="case-1",
purpose="cases.casework",
)[0]["purpose"],
)
self.assertEqual(3, len(case_history(self.session, self.principal, case_id="case-1")))
def test_malformed_access_grant_is_rejected(self) -> None:
create_case(
+9 -1
View File
@@ -255,6 +255,10 @@ class CasesDsarProviderTests(unittest.TestCase):
self.assertEqual("cases_access_grant", account_records[0].resource_type)
self.assertFalse(account_records[0].immutable_evidence)
self.assertEqual("account-subject", account_records[0].data["subject_id"])
self.assertEqual(
["cases.casework"],
account_records[0].data["allowed_purposes"],
)
identity_records = self.provider.search_subject(
self.session,
@@ -308,7 +312,11 @@ class CasesDsarProviderTests(unittest.TestCase):
self.assertTrue(
all(
item.resource_type
in {"cases_operator_attribution", "cases_timeline_event"}
in {
"cases_access_grant",
"cases_operator_attribution",
"cases_timeline_event",
}
for item in records
)
)
+26 -3
View File
@@ -10,6 +10,14 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
class CasesInterfaceDocumentationContractTests(unittest.TestCase):
def test_all_static_topics_have_complete_german_content(self) -> None:
for topic in get_manifest().documentation:
german = (topic.translations or {}).get("de", {})
self.assertEqual({"title", "summary", "body"}, set(german), topic.id)
self.assertTrue(
all(str(value).strip() for value in german.values()), topic.id
)
def test_backend_surfaces_and_hierarchy_remain_declared(self) -> None:
frontend = get_manifest().frontend
self.assertIsNotNone(frontend)
@@ -53,15 +61,30 @@ class CasesInterfaceDocumentationContractTests(unittest.TestCase):
self.assertIn("update_case", reference.metadata["consequence_classes"])
self.assertIn("close_case", reference.metadata["consequence_classes"])
self.assertIn("change_access", reference.metadata["consequence_classes"])
self.assertEqual("reference", reference.metadata["kind"])
self.assertIn(
"link_owner_reference",
owner_links.metadata["consequence_classes"],
)
def test_information_governance_claims_are_bounded_and_documented(self) -> None:
manifest = get_manifest()
governance = manifest.information_governance
self.assertEqual("enforced", governance.temporal_browsing.adoption)
self.assertEqual("enforced", governance.purpose_aware_access.adoption)
self.assertEqual("enforced", governance.institutional_context.adoption)
self.assertEqual("contract_only", governance.retention.adoption)
self.assertIn("legal holds", governance.retention.limitation or "")
self.assertTrue(governance.current_authorization_for_historical_reads)
topics = {topic.id: topic for topic in manifest.documentation}
purpose = topics["cases.governance.purpose-bound-access"]
self.assertIn("cases.field.access-purpose", purpose.metadata["help_contexts"])
self.assertIn("cases.field.allowed-purposes", purpose.metadata["help_contexts"])
def test_webui_uses_shared_help_guard_and_confirmation_components(self) -> None:
list_page = (
REPO_ROOT / "webui/src/features/cases/CasesPage.tsx"
).read_text(encoding="utf-8")
list_page = (REPO_ROOT / "webui/src/features/cases/CasesPage.tsx").read_text(
encoding="utf-8"
)
detail_page = (
REPO_ROOT / "webui/src/features/cases/CaseDetailPage.tsx"
).read_text(encoding="utf-8")
+15 -1
View File
@@ -40,9 +40,23 @@ class CasesMigrationTests(unittest.TestCase):
for item in inspector.get_columns("case_record_revisions")
},
)
self.assertIn(
"allowed_purposes",
{
item["name"]
for item in inspector.get_columns("case_access_grants")
},
)
self.assertIn(
"purpose",
{
item["name"]
for item in inspector.get_columns("case_timeline_entries")
},
)
with engine.connect() as connection:
self.assertIn(
"f6d3a8b1c4e7",
"a7c4e2f9b1d6",
set(MigrationContext.configure(connection).get_current_heads()),
)
finally:
+12 -1
View File
@@ -75,7 +75,7 @@ class CasesRecordSourceTests(unittest.TestCase):
def test_resolves_exact_currently_authorized_case_revision(self) -> None:
with patch(
"govoplan_cases.backend.record_source.can_access_case", return_value=True
):
) as can_access:
result = CasesRecordSource().resolve(
self.session,
Principal(),
@@ -83,9 +83,20 @@ class CasesRecordSourceTests(unittest.TestCase):
purpose="preserve decision basis",
)
can_access.assert_called_once_with(
self.session,
unittest.mock.ANY,
case_id="case-1",
permission="read",
purpose="preserve decision basis",
)
self.assertEqual("2026/C-1 - Permit application", result.label)
self.assertEqual(64, len(result.content_sha256 or ""))
self.assertEqual("review", result.metadata["status_key"])
self.assertEqual(
"/cases/case-1?purpose=preserve%20decision%20basis",
result.launch_url,
)
def test_object_access_is_rechecked_and_fails_closed(self) -> None:
with (
+16 -2
View File
@@ -28,8 +28,12 @@ from govoplan_cases.backend.db.models import (
CaseTimelineEntry,
CaseTypeDefinition,
)
from govoplan_cases.backend.domain import CaseRecord
from govoplan_cases.backend.search_source import CasesSearchSource, PROVIDER_ID
from govoplan_cases.backend.domain import CaseGrant, CaseRecord
from govoplan_cases.backend.search_source import (
SEARCH_ACCESS_PURPOSE,
CasesSearchSource,
PROVIDER_ID,
)
from govoplan_cases.backend.service import (
create_case,
upsert_case_status,
@@ -77,6 +81,15 @@ class CasesSearchSourceTests(unittest.TestCase):
case_type_key="permit",
status_key="review",
title="Permit application",
access_mode="restricted",
access_grants=(
CaseGrant(
"account",
"account-1",
("read",),
(SEARCH_ACCESS_PURPOSE,),
),
),
context=GovernedContextEnvelope(
tenant_id="tenant-1",
temporal=TemporalRevision(
@@ -112,6 +125,7 @@ class CasesSearchSourceTests(unittest.TestCase):
)
self.assertEqual(("case-1",), tuple(item.resource_id for item in page.documents))
self.assertEqual("PERMIT-1", page.documents[0].metadata["case_number"])
self.assertIn("purpose=cases.search", page.documents[0].url)
reference = SearchResourceReference(
tenant_id="tenant-1",
module_id="cases",
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_core.core.tickets import TicketCaseEscalationCommand
from govoplan_core.db.base import Base
from govoplan_cases.backend.db import models as _case_models
from govoplan_cases.backend.service import upsert_case_status, upsert_case_type
from govoplan_cases.backend.ticket_escalation import TicketCaseEscalationProvider
NOW = datetime(2026, 8, 22, 10, 0, tzinfo=UTC)
class _Principal:
def __init__(self, tenant_id: str = "tenant-1", *, can_create: bool = True) -> None:
self.tenant_id = tenant_id
self.account_id = "case-manager-1"
self.identity_id = None
self.membership_id = "membership-1"
self.group_ids = frozenset()
self.role_ids = frozenset()
self.function_assignment_ids = frozenset()
self.acting_assignment_id = None
self.scopes = frozenset({"cases:case:create", "cases:case:read"} if can_create else {"cases:case:read"})
self.user = SimpleNamespace(id=self.account_id)
def has(self, scope: str) -> bool:
return scope in self.scopes
class TicketEscalationTests(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
self.Session = sessionmaker(bind=engine, expire_on_commit=False)
self.principal = _Principal()
def test_escalation_creates_one_linked_case_and_replays(self) -> None:
with self.Session() as session:
upsert_case_status(
session,
self.principal,
status_key="open",
label="Open",
)
upsert_case_type(
session,
self.principal,
type_key="service-request",
label="Service request",
initial_status_key="open",
)
command = TicketCaseEscalationCommand(
tenant_id="tenant-1",
ticket_id="ticket-1",
ticket_number="TKT-2026-0001",
title="Broken streetlight",
case_type_key="service-request",
occurred_at=NOW,
idempotency_key="escalation-1",
handoff_note="Formal procedure required.",
metadata={"ticket_revision": 3},
)
provider = TicketCaseEscalationProvider()
created = provider.escalate_ticket(session, self.principal, command=command)
session.commit()
replay = provider.escalate_ticket(session, self.principal, command=command)
self.assertFalse(created.replayed)
self.assertTrue(replay.replayed)
self.assertEqual(created.case_id, replay.case_id)
self.assertEqual("service-request", created.metadata["case_type_key"])
self.assertTrue(created.case_url.startswith("/cases/"))
self.assertEqual(1, session.query(_case_models.CaseIdentity).count())
current = session.query(_case_models.CaseRecordRevision).filter(
_case_models.CaseRecordRevision.case_id == created.case_id,
_case_models.CaseRecordRevision.superseded_at.is_(None),
).one()
self.assertEqual("tickets", current.snapshot["context"]["work_item_ref"]["owner_module"])
self.assertEqual("ticket-1", current.snapshot["metadata"]["source_resource_id"])
def test_escalation_fails_closed_for_cross_tenant_or_missing_permission(self) -> None:
command = TicketCaseEscalationCommand(
tenant_id="tenant-1",
ticket_id="ticket-1",
ticket_number="TKT-1",
title="Request",
case_type_key="service-request",
occurred_at=NOW,
idempotency_key="escalation-2",
)
provider = TicketCaseEscalationProvider()
with self.Session() as session:
with self.assertRaises(PermissionError):
provider.escalate_ticket(session, _Principal("tenant-2"), command=command)
with self.assertRaises(PermissionError):
provider.escalate_ticket(session, _Principal(can_create=False), command=command)
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/cases-webui",
"version": "0.1.19",
"version": "0.1.24",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -14,7 +14,7 @@
"./styles/cases.css": "./src/styles/cases.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"@govoplan/core-webui": "^0.1.30",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
+22 -7
View File
@@ -59,11 +59,14 @@ export type CaseRecord = {
};
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;
permissions: Array<"read" | "update" | "share" | "admin">;
allowed_purposes: string[];
};
export const DEFAULT_CASE_ACCESS_PURPOSE = "cases.casework";
export type CaseStatusDefinition = {
status_key: string;
label: string;
@@ -101,6 +104,7 @@ export type CaseTimelineEntry = {
event_type: string;
case_revision: number;
summary: string;
purpose: string;
actor_id?: string | null;
occurred_at: string;
audit_event_id?: string | null;
@@ -140,6 +144,7 @@ export function listCases(
query?: string;
statuses?: string[];
caseTypes?: string[];
purpose?: string;
offset?: number;
limit?: number;
},
@@ -149,6 +154,7 @@ export function listCases(
query: options.query,
status_key: options.statuses,
case_type_key: options.caseTypes,
purpose: options.purpose,
offset: options.offset,
limit: options.limit
}), { signal });
@@ -157,9 +163,10 @@ export function listCases(
export function getCase(
settings: ApiSettings,
caseId: string,
purpose: string,
signal?: AbortSignal
): 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(
@@ -172,27 +179,30 @@ export function listCaseCatalog(
export function caseHistory(
settings: ApiSettings,
caseId: string,
purpose: string,
signal?: AbortSignal
): 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(
settings: ApiSettings,
caseId: string,
purpose: string,
signal?: AbortSignal
): 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(
settings: ApiSettings,
caseId: string,
purpose: string,
signal?: AbortSignal
): Promise<{ decisions: FormalDecision[] }> {
return apiFetch(
settings,
`/api/v1/cases/${encodeURIComponent(caseId)}/decisions`,
apiPath(`/api/v1/cases/${encodeURIComponent(caseId)}/decisions`, { purpose }),
{ signal }
);
}
@@ -209,6 +219,7 @@ export function recordCaseDecision(
conditions: string[];
change_reason: string;
idempotency_key: string;
purpose: string;
}
): Promise<CaseDecisionResult> {
return apiFetch(
@@ -229,6 +240,7 @@ export function updateCase(
recorded_at: string;
change_reason: string;
idempotency_key: string;
purpose: string;
title?: string;
status_key?: string;
access_mode?: "tenant" | "restricted";
@@ -249,6 +261,7 @@ export function linkCaseEvidence(
recorded_at: string;
change_reason: string;
idempotency_key: string;
purpose: string;
reference: {
owner_module: string;
kind: string;
@@ -275,6 +288,7 @@ export function unlinkCaseEvidence(
recorded_at: string;
change_reason: string;
idempotency_key: string;
purpose: string;
}
): Promise<CaseRecord> {
return apiFetch<CaseRecord>(
@@ -287,11 +301,12 @@ export function unlinkCaseEvidence(
export function caseShareTargetProvider(
settings: ApiSettings,
caseId: string,
targetType: "user" | "group"
targetType: "user" | "group",
purpose: string
): ReferenceOptionProvider {
return apiReferenceOptionProvider(
settings,
`/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({
settings,
record,
purpose,
open,
onClose,
onSaved
}: {
settings: ApiSettings;
record: CaseRecord;
purpose: string;
open: boolean;
onClose: () => void;
onSaved: (result: CaseDecisionResult) => void;
@@ -82,7 +84,8 @@ export default function CaseDecisionDialog({
reasoning: reasoning.trim(),
conditions: conditions.split("\n").map((item) => item.trim()).filter(Boolean),
change_reason: changeReason.trim(),
idempotency_key: idempotencyKey.current
idempotency_key: idempotencyKey.current,
purpose
});
setConfirmOpen(false);
onSaved(result);
+23 -11
View File
@@ -1,6 +1,6 @@
import { Archive, ArrowLeft, ExternalLink, Save, Scale, Share2, Unlink } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router";
import { useParams, useSearchParams } from "react-router";
import { ActionToolbar,
ActionBlockerHint,
Button,
@@ -22,6 +22,7 @@ import { ActionToolbar,
type QuickAccessResult
} from "@govoplan/core-webui";
import {
DEFAULT_CASE_ACCESS_PURPOSE,
caseHistory,
caseTimeline,
getCase,
@@ -48,6 +49,8 @@ import {
export default function CaseDetailPage({ settings, auth }: PlatformRouteContext) {
const { caseId = "" } = useParams();
const [searchParams] = useSearchParams();
const accessPurpose = searchParams.get("purpose")?.trim() || DEFAULT_CASE_ACCESS_PURPOSE;
const navigate = useGuardedNavigate();
const [record, setRecord] = useState<CaseRecord | null>(null);
const [catalog, setCatalog] = useState<CaseCatalog>({ statuses: [], types: [] });
@@ -92,20 +95,20 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
tenantId: record.reference.tenant_id,
label: `${record.case_number} · ${record.title}`,
version: record.reference.version ?? String(record.revision),
path: `/cases/${encodeURIComponent(record.reference.object_id)}`
} : null, [record]);
path: `/cases/${encodeURIComponent(record.reference.object_id)}?purpose=${encodeURIComponent(accessPurpose)}`
} : null, [accessPurpose, record]);
useRegisterActiveObject(activeCase);
const load = useCallback((signal?: AbortSignal) => {
setLoading(true);
setError("");
return Promise.all([
getCase(settings, caseId, signal),
getCase(settings, caseId, accessPurpose, signal),
listCaseCatalog(settings, signal),
caseHistory(settings, caseId, signal),
caseTimeline(settings, caseId, signal),
caseHistory(settings, caseId, accessPurpose, signal),
caseTimeline(settings, caseId, accessPurpose, signal),
canReadDecisions
? listCaseDecisions(settings, caseId, signal)
? listCaseDecisions(settings, caseId, accessPurpose, signal)
: Promise.resolve({ decisions: [] as FormalDecision[] })
]).
then(([nextRecord, nextCatalog, nextHistory, nextTimeline, nextDecisions]) => {
@@ -119,7 +122,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
setChangeReason("");
}).
finally(() => setLoading(false));
}, [canReadDecisions, caseId, settings]);
}, [accessPurpose, canReadDecisions, caseId, settings]);
useEffect(() => {
const controller = new AbortController();
@@ -179,6 +182,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
recorded_at: new Date().toISOString(),
change_reason: `Linked ${reference.ownerModule}.${reference.kind} through Quick Access.`,
idempotency_key: crypto.randomUUID(),
purpose: accessPurpose,
reference: {
owner_module: reference.ownerModule,
kind: reference.kind,
@@ -203,7 +207,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
QUICK_ACCESS_RESULT_EVENT,
receiveReference
);
}, [canUpdate, caseId, draftDirty, load, record, settings]);
}, [accessPurpose, canUpdate, caseId, draftDirty, load, record, settings]);
function discardDraft() {
if (!record) return;
@@ -222,6 +226,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
recorded_at: new Date().toISOString(),
change_reason: changeReason.trim(),
idempotency_key: idempotencyKey.current,
purpose: accessPurpose,
...(title.trim() !== record.title ? { title: title.trim() } : {}),
...(status !== record.status_key ? { status_key: status } : {})
});
@@ -260,7 +265,8 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
expected_revision: record.revision,
recorded_at: new Date().toISOString(),
change_reason: `Unlinked ${evidence.owner_module} reference from the Case.`,
idempotency_key: crypto.randomUUID()
idempotency_key: crypto.randomUUID(),
purpose: accessPurpose
}
);
setRecord(saved);
@@ -296,7 +302,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
<main className="cases-page">
<div className="case-detail-shell">
<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" />
Cases
</button>
@@ -325,6 +331,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
icon={<Share2 size={16} />}
disabledReason={!canShare ? CASES_I18N.shareReason : undefined}
onClick={() => setShareOpen(true)}
helpContextId="cases.detail.access"
/>
</div>
) : null}
@@ -404,6 +411,8 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
<Fact label="Opened" value={formatDateTime(record.opened_at)} />
<Fact label="Deadline" value={formatDateTime(record.deadline_at)} />
<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} />
</div>
@@ -444,6 +453,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
<li key={entry.event_id}>
<strong>{humanize(entry.event_type)}</strong>
<span>{entry.summary}</span>
<span>{entry.purpose}</span>
<time>{formatDateTime(entry.occurred_at)}</time>
</li>
)}
@@ -470,6 +480,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
<CaseShareDialog
settings={settings}
record={record}
purpose={accessPurpose}
open={shareOpen}
onClose={() => setShareOpen(false)}
onSaved={(saved) => {
@@ -484,6 +495,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
<CaseDecisionDialog
settings={settings}
record={record}
purpose={accessPurpose}
open={decisionOpen}
onClose={() => setDecisionOpen(false)}
onSaved={(result) => {
+42 -9
View File
@@ -33,12 +33,14 @@ type Permission = CaseGrant["permissions"][number];
export default function CaseShareDialog({
settings,
record,
purpose,
open,
onClose,
onSaved
}: {
settings: ApiSettings;
record: CaseRecord;
purpose: string;
open: boolean;
onClose: () => void;
onSaved: (record: CaseRecord) => void;
@@ -48,6 +50,7 @@ export default function CaseShareDialog({
const [targetType, setTargetType] = useState<TargetType>("user");
const [targetId, setTargetId] = useState("");
const [permission, setPermission] = useState<Permission>("read");
const [allowedPurposes, setAllowedPurposes] = useState(purpose);
const [changeReason, setChangeReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@@ -55,8 +58,8 @@ export default function CaseShareDialog({
const idempotencyKey = useRef(crypto.randomUUID());
const { requestDiscard } = useUnsavedChanges();
const targetProvider = useMemo(
() => caseShareTargetProvider(settings, record.reference.object_id, targetType),
[record.reference.object_id, settings, targetType]
() => caseShareTargetProvider(settings, record.reference.object_id, targetType, purpose),
[purpose, record.reference.object_id, settings, targetType]
);
useEffect(() => {
@@ -66,11 +69,12 @@ export default function CaseShareDialog({
setTargetType("user");
setTargetId("");
setPermission("read");
setAllowedPurposes(purpose);
setChangeReason("");
setError("");
setConfirmOpen(false);
idempotencyKey.current = crypto.randomUUID();
}, [open, record]);
}, [open, purpose, record]);
const changed = restricted !== (record.access_mode === "restricted")
|| JSON.stringify(grants) !== JSON.stringify(record.access_grants);
@@ -82,13 +86,15 @@ export default function CaseShareDialog({
setTargetType("user");
setTargetId("");
setPermission("read");
setAllowedPurposes(purpose);
setChangeReason("");
setError("");
}
function addGrant() {
const subjectId = targetId.trim();
if (!subjectId) return;
const purposes = splitPurposes(allowedPurposes);
if (!subjectId || purposes.length === 0) return;
const subjectKind = targetType === "user" ? "account" : "group";
setGrants((current) => [
...current.filter(
@@ -97,7 +103,8 @@ export default function CaseShareDialog({
{
subject_kind: subjectKind,
subject_id: subjectId,
permissions: [permission]
permissions: [permission],
allowed_purposes: purposes
}
]);
setTargetId("");
@@ -113,6 +120,7 @@ export default function CaseShareDialog({
recorded_at: new Date().toISOString(),
change_reason: changeReason.trim(),
idempotency_key: idempotencyKey.current,
purpose,
access_mode: restricted ? "restricted" : "tenant",
access_grants: grants
});
@@ -169,6 +177,7 @@ export default function CaseShareDialog({
closeDisabled={busy}
portal
className="case-share-dialog"
helpContextId="cases.detail.access"
footer={
<>
<Button disabled={busy} onClick={close}>Cancel</Button>
@@ -176,6 +185,7 @@ export default function CaseShareDialog({
variant="primary"
disabledReason={saveDisabledReason}
onClick={() => setConfirmOpen(true)}
helpContextId="cases.detail.access"
>
{busy ? "Saving" : "Save access"}
</Button>
@@ -196,9 +206,9 @@ export default function CaseShareDialog({
onChange={setRestricted}
/>
<p className="case-share-explanation">
Tenant cases follow the Cases read permission. Restricted cases are visible only to
their creator, case administrators, assigned functions or units, and the explicit
grants below.
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.
</p>
<div className="case-share-add-row">
@@ -237,11 +247,19 @@ export default function CaseShareDialog({
<option value="admin">Administer</option>
</select>
</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
label="Add access grant"
icon={<Plus size={16} />}
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}
/>
</div>
@@ -273,11 +291,22 @@ export default function CaseShareDialog({
<option value="share">Share</option>
<option value="admin">Administer</option>
</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
label={`Remove access for ${grant.subject_id}`}
icon={<Trash2 size={16} />}
variant="danger"
disabled={busy}
helpContextId="cases.detail.access"
onClick={() => setGrants((current) => current.filter((item) => item !== grant))}
/>
</div>
@@ -317,3 +346,7 @@ export default function CaseShareDialog({
function humanize(value: string): string {
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)));
}
+18 -2
View File
@@ -1,5 +1,6 @@
import { Search } from "lucide-react";
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { useSearchParams } from "react-router";
import { Button,
DocumentationHelpLink,
DismissibleAlert,
@@ -18,6 +19,7 @@ import { Button,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
DEFAULT_CASE_ACCESS_PURPOSE,
listCaseCatalog,
listCases,
type CaseCatalog,
@@ -28,8 +30,12 @@ import { CASES_DOCUMENTATION, CASES_I18N } from "./interfacePatterns";
export default function CasesPage({ settings }: PlatformRouteContext) {
const navigate = useGuardedNavigate();
const [searchParams] = useSearchParams();
const initialPurpose = searchParams.get("purpose")?.trim() || DEFAULT_CASE_ACCESS_PURPOSE;
const [query, setQuery] = useState("");
const [submittedQuery, setSubmittedQuery] = useState("");
const [purpose, setPurpose] = useState(initialPurpose);
const [submittedPurpose, setSubmittedPurpose] = useState(initialPurpose);
const [status, setStatus] = useState("");
const [cases, setCases] = useState<CaseRecord[]>([]);
const [catalog, setCatalog] = useState<CaseCatalog>({ statuses: [], types: [] });
@@ -46,6 +52,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
listCases(settings, {
query: submittedQuery,
statuses: status ? [status] : undefined,
purpose: submittedPurpose,
limit: 200
}, controller.signal),
listCaseCatalog(settings, controller.signal)
@@ -62,7 +69,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
}).
finally(() => setLoading(false));
return () => controller.abort();
}, [reloadKey, settings, status, submittedQuery]);
}, [reloadKey, settings, status, submittedPurpose, submittedQuery]);
const statusLabels = useMemo(
() => new Map(catalog.statuses.map((item) => [item.status_key, item])),
@@ -76,6 +83,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
function submit(event: FormEvent) {
event.preventDefault();
setSubmittedQuery(query.trim());
setSubmittedPurpose(purpose.trim());
}
return (
@@ -96,6 +104,14 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
aria-label="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
type="submit"
variant="primary"
@@ -135,7 +151,7 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
<SelectionListItem
selected={false}
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)}`} />
<StatusBadge
status={statusDefinition?.terminal ? "inactive" : "active"}
+12
View File
@@ -81,6 +81,12 @@ const en = {
"Permission": "Permission",
"Save access": "Save access",
"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",
"File Case in eAkte": "File Case in eAkte",
"File Decision in eAkte": "File Decision in eAkte",
@@ -184,6 +190,12 @@ const de: Record<keyof typeof en, string> = {
"Permission": "Berechtigung",
"Save access": "Zugriff speichern",
"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",
"File Case in eAkte": "Vorgang in eAkte verakten",
"File Decision in eAkte": "Entscheidung in eAkte verakten",
+1 -1
View File
@@ -10,7 +10,7 @@ const CaseDetailPage = lazy(() => import("./features/cases/CaseDetailPage"));
export const casesModule: PlatformWebModule = {
id: "cases",
label: "i18n:govoplan-cases.cases",
version: "0.1.8",
version: "0.1.21",
optionalDependencies: [
"access",
"addresses",