Skip to content

fix(access): the members of a case's assigned group read it, up to vertrouwelijk (REVIEW: access, personal data) - #5136

Open
rubenvdlinde wants to merge 4 commits into
developmentfrom
fix/case-assigned-group-reads-its-cases
Open

rubenvdlinde wants to merge 4 commits into
developmentfrom
fix/case-assigned-group-reads-its-cases

Conversation

@rubenvdlinde

@rubenvdlinde rubenvdlinde commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

What this does

Ruben decided (3 November) that the members of a case's assigned group may read that case. It is read only: no edit, no move, no status change. It applies only within the case's own active organisation, and only up to and including the confidentiality level vertrouwelijk.

How it is expressed

The case schema's authorization.matrix is keyed on assignedGroup, with rowsFrom: dossiq.case-type-rights. OpenRegister asks dossiq for rows through MatrixRowsRequestedEvent, and CaseTypeRightsRowsListener answers.

The new AssignedGroupReadRows adds one row per group of the signed-in user:

{"group": "<G>", "values": ["<G>"], "where": {"confidentiality": {"$in": ["openbaar", "beperkt_openbaar", "intern", "zaakvertrouwelijk", "vertrouwelijk"]}}, "actions": ["read"]}

A member of G reads the cases assigned to G. The levels come from CaseTypeRightsMatrixRows::LEVELS, the ZGW ordering already in dossiq.

  • A case marked confidentieel, geheim or zeer geheim reads only for its behandelaar or through explicit rights. So does a case with a missing or unknown level: the row names the levels it covers, so anything else does not match.
  • Why one row per group, and not one row for everybody: OpenRegister drops a provider row that grants authenticated, public or admin (MatrixRowsFromApps::RESERVED_GROUPS).
  • Rows are built from the session user's groups. A decision made for another user in the same request still needs membership of each row's own group, so it can only grant less, never more.
  • The listener now takes its two row sources as one bundle (CaseRightsRowSources), so its constructor stays at four parameters.

Finding: on development, case-type rights grant nothing

CaseTypeRightsRowsListener called addRows() without an appId. OpenRegister keeps only the rows answered by the app that owns the provider id (dossiq), so every case-type rights row was dropped on development.

The effect was that the case-type rights matrix granted nothing at all. That fails closed, but no access was granted through rights rows anywhere. The real-OpenRegister harness CaseTypeRightsEnforcedAccessTest fails on development because of this; it is green with this PR.

The listener now passes appId: Application::APP_ID for both kinds of row. This change is a widening of access compared with what development does today, back to what case-type rights were meant to grant. Review it with that in mind.

Why REVIEW

It widens read access to cases, which is personal data, and it restores the case-type rights matrix.

Evidence

Real OpenRegister harness. CaseTypeRightsEnforcedAccessTest runs OpenRegister's own PermissionHandler (single object) and MagicSearchHandler/MagicRbacHandler (SQL list), over the shipped case schema. All 11 tests are green. Before the change, the new cases fail and the existing ones fail too, because of the appId bug.

The new cases cover:

  1. A group member reads the case, on both paths.
  2. A group member gets no update, delete, create, readFiles, addFiles or deleteFiles. A move is an update, so that is refused too.
  3. A case in another organisation is not listed for a member of the same group. This is proven on the list path; PermissionHandler does not scope by organisation.
  4. A user taken out of the group loses the case.
  5. A non-member reads nothing.
  6. The boundary: vertrouwelijk is readable. confidentieel, no level, and an unknown level are not.

Existing expectations that change:

  • anna (team-vergunningen) now also reads c-5, through her team. She still does not read c-4, which is geheim.
  • dirk (team-toezicht) also reads c-3 and c-7.

Caveats:

  • In CI the harness runs only when an openregister checkout sits beside the app. CI clones it there, so it should run.
  • I did not check :8080 (anouk on case 710d44a4): this branch is not deployed there. That check comes after the merge.

Other checks:

  • AssignedGroupReadRowsTest covers the row shape, reserved and blank groups, and no signed-in user.
  • Mutants, capped, with a no-op control: 9 over the rows and the listener.
    • Killed: the cap removed, an off-by-one in the cap, the cap widened to every level, extra verbs, the reserved check removed, the group rows removed, and the appId removed.
    • values: ['$self'] instead of [G] survives the harness, because it is equivalent for reads, and is killed by the unit test.
  • phpcs, phpmd, phpstan and psalm are clean on the touched lib files.
  • Coverage guard: passes with 0 risky tests.
  • Gate 122 ratchet: 0 findings.
  • Diff check: php -l, phpcs, phpstan and phpunit are green. The gate step reports gates 23 and 102 on files this PR does not touch, as on development. Gate 119's stale baseline also fails on development.
  • Full unit suite, run once through heavy.sh: OK, with 10,450 tests, 3 warnings and 112 skipped.

Rework after RW78

  • Cross-organisation read: open, waits on OpenRegister.
    • OpenRegister drops its organisation filter for a caller who qualifies for a conditional rule on a field other than _organisation (MagicRbacHandler::hasConditionalRulesBypassingMultitenancy). The projected handling team rules are such rules, so for a team member the assigned-group rows also read across organisations, on the list and on a single read.
    • The harness now loads the case schema with team rules, and three tests fail on development OpenRegister until ORX's OpenRegister fix lands: femke's read, the other-organisation list, and dirk's case set. They are the regression check for that fix, and the PR stays held until then.
    • An _organisation condition on the rows themselves is not possible today. OpenRegister refuses a matrix row whose where names a non-property (matrix.where-unknown-field) and drops the row whole. The case schema has no organisation property to name instead.
  • delete never comes from a rights row. delete is out of the case edit verbs, so a rights row that names it grants the rest of its row and not case deletion (REQ-ACC-10, "delete stays with administrators"). No shipped row names it; this makes the rule hold in code, not by luck. A new test checks that a row naming delete gives update and no delete.
  • Spec. REQ-ACC-10 is amended to Ruben's decision:
    • the assigned group reads only, up to vertrouwelijk, in the case's own organisation;
    • the rights matrix is honoured;
    • a rights row never grants delete.
    • Two scenarios are added.

Assisted-by: ClaudeCode:claude-opus-5-5. This PR was prepared with AI assistance; a person reviews it before merge.

Ruben decided that the members of a case's assigned group may read that
case, and only read it. OpenRegister keys the case matrix on
assignedGroup and asks dossiq for rows; each group of the signed-in user
now becomes one read row for the cases assigned to that group. The
active organisation still scopes which cases a reader reaches.

The rows dossiq hands OpenRegister now name dossiq as the app answering.
OpenRegister keeps only rows from the app that owns the provider id, so
every case type rights row was being dropped.

Assisted-by: ClaudeCode:claude-opus-5-5
A case marked confidentieel, geheim or zeer geheim, or with a missing or
unknown level, needs its behandelaar or explicit rights (Ruben, 3 Nov).

Assisted-by: ClaudeCode:claude-opus-5-5
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/dossiq @ 898e419

Check PHP Vue Security License Tests
lint ✅
phpcs ✅
phpmd ✅
psalm ✅
phpstan ✅
phpmetrics ✅
eslint ❌
stylelint ✅
build ✅
check-manifest ✅
check-vue3-compile ✅
test-l10n ✅
format ❌
check-schema-l10n ✅
check-l10n-js ✅
check-debt-ratchet ❌
check-types-e2e ✅
composer ✅ ✅ 109/109
npm ✅ ✅ 657/657
app:check-code ⏭️
info.xml ✅
REUSE ❌
lockfile sync ✅
PHPUnit ✅ 10436/10436 · 14 skipped
Newman ⏭️
Playwright ⏭️ deferred: E2E runs locally and on the promotion path only. This pull request targets development, so the suite is asked once per promotion into beta and main rather than once per push per open pull request. Run it locally with npx playwright test, or from the Actions tab on a branch with no open pull request into development.
Hydra gates ❌

Quality workflow — 2026-11-03 07:28 UTC

Download the full PDF report from the workflow artifacts.

…hat the assigned group reads, and the harness carries the team rules

- A rights row that names delete grants the rest of its row: deleting a
  case stays with administrators (REQ-ACC-10).
- REQ-ACC-10 now states Ruben's decision of 3 November: the assigned
  group reads its cases, read only, up to vertrouwelijk, in the case's
  own organisation, and the rights matrix is honoured.
- The access harness now loads the case schema with the handling team
  rules the projector writes, which switch off OpenRegister's
  organisation filter for a caller they qualify. Its cross-organisation
  assertions fail until OpenRegister keeps that filter.

Assisted-by: ClaudeCode:claude-opus-5-5
Assisted-by: ClaudeCode:claude-opus-5-5
@rubenvdlinde
rubenvdlinde requested a review from Rem-Dam as a code owner November 3, 2026 07:56
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/dossiq @ ac59797

Check PHP Vue Security License Tests
lint ✅
phpcs ✅
phpmd ✅
psalm ✅
phpstan ✅
phpmetrics ✅
eslint ❌
stylelint ✅
build ✅
check-manifest ✅
check-vue3-compile ✅
test-l10n ✅
format ❌
check-schema-l10n ✅
check-l10n-js ✅
check-debt-ratchet ❌
check-types-e2e ✅
composer ✅ ✅ 109/109
npm ✅ ✅ 657/657
app:check-code ⏭️
info.xml ✅
REUSE ❌
lockfile sync ✅
PHPUnit ❌ 10456/10457 · 16 skipped
Newman ⏭️
Playwright ⏭️ deferred: E2E runs locally and on the promotion path only. This pull request targets development, so the suite is asked once per promotion into beta and main rather than once per push per open pull request. Run it locally with npx playwright test, or from the Actions tab on a branch with no open pull request into development.
Hydra gates ❌

Quality workflow — 2026-11-03 08:45 UTC

Download the full PDF report from the workflow artifacts.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant