Skip to content

fix(security): give the two immutability guards a call site that actually runs - #737

Open
rubenvdlinde wants to merge 4 commits into
developmentfrom
fix/wire-bewijsstuk-immutability-guard
Open

fix(security): give the two immutability guards a call site that actually runs#737
rubenvdlinde wants to merge 4 commits into
developmentfrom
fix/wire-bewijsstuk-immutability-guard

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

What was wrong

Both of procest's immutability rules were enforced by nothing.

REQ-SUB-007 — BewijsstukService::assertMutable() was implemented and unit-tested with zero production callers (hydra gate-6, orphan-auth). An authorization check that is never invoked is identical to having no check at all (OWASP A01:2021). A bewijsstuk linked to a vaststelling could be edited or deleted freely.

REQ-IC-8 — ChecklistRunImmutabilityListener was worse than orphaned, and gate-6 does not see it because its entry point is handle():

  1. it is not referenced by any registrar (ObjectListenerRegistrar / BezwaarListenerRegistrar / WorkflowListenerRegistrar), so it was never subscribed to any event and never ran; and
  2. it declared the post-persist ObjectUpdatedEvent, which OpenRegister dispatches after updateObjectEntity() has committed the row (MagicMapper.php:8990), with no surrounding transaction — so even had it been registered, throwing from there could not have undone the mutation it objected to.

It also had no test.

The fix

Both listeners now subscribe to OpenRegister's pre-persist, stoppable ObjectUpdatingEvent / ObjectDeletingEvent. stopPropagation() makes MagicMapper raise HookStoppedException before anything is written — the same mechanism LocationBagValidationListener already uses and documents in its own docblock.

This is the reachable enforcement point: the frontend writes through OpenRegister's generic objects API (ADR-022), not through a procest route — there is no bewijsstuk route to guard. Wiring the guard here does not require inventing the archival/Docudesk integration that the 2026-07-16 decision (procest#229) deliberately declined to build blind.

BewijsstukImmutabilityListener reads the stored state (getOldObject() on update, the entity itself on delete), never the incoming payload — otherwise a caller could clear immutable in the same request that mutates the document and walk straight through the guard. There is a test for exactly that bypass.

Delete is guarded as well as update: an immutability rule that only covers UPDATE is bypassable by delete-and-recreate.

Proof — every test re-run with lib/ reverted

control reverted state result
A assertMutable() has no caller (the shipped state) the 3 rejection tests fail, the 4 positive controls still pass
B guard reads the caller payload instead of the stored row the bypass test fails
C checklist listener restored to post-persist ObjectUpdatedEvent the pre-persist rejection test fails

Clean tree: tests/Unit/Listener 52/52 green. Gate-6 on this branch: 0 findings (was 1).

Spec

openspec/specs/subsidieverlening-keten/spec.md keeps status: partialverifyHash(), the nightly archief-trigger and the Docudesk PDF/A handover are still unwired. The status note is corrected to say which half of REQ-SUB-007 now runs and which half does not, so it stops asserting something that is no longer true.

…ally runs

Both of procest's immutability rules were enforced by nothing.

REQ-SUB-007 — `BewijsstukService::assertMutable()` was implemented and unit
tested with ZERO production callers (hydra gate-6, orphan-auth). An
authorization check that is never invoked is identical to having no check at
all (OWASP A01:2021). A bewijsstuk linked to a vaststelling could be edited or
deleted freely.

REQ-IC-8 — `ChecklistRunImmutabilityListener` was worse than orphaned. It was
never referenced by any registrar, so it was never subscribed to any event and
never ran; and it declared the POST-persist `ObjectUpdatedEvent`, which
OpenRegister dispatches AFTER `updateObjectEntity()` has committed the row,
with no surrounding transaction. Even had it been registered, throwing from
there could not have undone the mutation it objected to.

The fix, for both: subscribe to OpenRegister's PRE-persist, stoppable
`ObjectUpdatingEvent` / `ObjectDeletingEvent`. `stopPropagation()` makes
MagicMapper raise `HookStoppedException` before anything is written — the same
mechanism `LocationBagValidationListener` already uses and documents. This is
the reachable enforcement point because the frontend writes through
OpenRegister's generic objects API (ADR-022), not through a procest route;
there is no bewijsstuk route to guard.

`BewijsstukImmutabilityListener` reads the STORED state (`getOldObject()` on
update, the entity itself on delete), never the incoming payload — otherwise a
caller could clear `immutable` in the same request that mutates the document
and walk through the guard. There is a test for exactly that bypass.

Proof, not assertion — each test was re-run with lib/ reverted:
- revert A (assertMutable has no caller, i.e. the shipped state): the 3
  rejection tests fail, the 4 positive controls still pass.
- revert B (guard reads the caller payload instead of the stored row): the
  bypass test fails.
- revert C (checklist listener restored to post-persist ObjectUpdatedEvent):
  the pre-persist rejection test fails.
Clean tree: 52/52 green in tests/Unit/Listener.

The subsidieverlening-keten spec note is updated to say which half of
REQ-SUB-007 now runs and which half still does not; the spec stays `partial`
(verifyHash, the archief-trigger and the Docudesk PDF/A handover are still
unwired, per the 2026-07-16 decision in procest#229).
…guards

Two failures my previous commit introduced — phpstan and phpmd were both green
on development before it, so these are mine, not pre-existing.

phpmd CouplingBetweenObjects: the three new imports pushed
`ObjectListenerRegistrar` to 14 dependencies against a limit of 13. Rather
than raise the threshold, the immutability registrations move into their own
`ImmutabilityListenerRegistrar`, which is what that class's own docblock says
should happen ("Subsystem-scoped listeners live in their own registrars") and
is the same shape as the bezwaar and workflow registrars.

phpstan: `is_array($payload) === false` is always false — `jsonSerialize()`
is declared `array`, so the guard was dead code. Removed rather than
annotated.

No suppression, no threshold change, no baseline entry. Revert control A
re-run after the rework: with `assertMutable()`'s call removed the 3 rejection
tests still fail and the 4 positive controls still pass. Clean tree: 52/52 in
tests/Unit/Listener, gate-6 clean, phpcs/phpmd/phpstan clean on every changed
file.
@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Status — leaving this open, and why

Not merging on the current CI state. Recording it precisely so whoever picks this up does not have to re-derive it.

Run 31015088820 (commit 1678f597, the security commit) — 31 jobs:

  • quality / PHP Quality (phpstan) failure and quality / PHP Quality (phpmd) failure. These were mine, not pre-existing: the last pre-outage run on development (31004072438, 12:04:38Z) has 31 jobs with phpstan and phpmd both green, and only Hydra Gates + Quality Report red. Both are fixed in c1653bdf — phpmd CouplingBetweenObjects by moving the registrations into their own ImmutabilityListenerRegistrar (which is what ObjectListenerRegistrar's docblock says should happen) rather than touching the threshold, and phpstan by deleting a genuinely dead is_array() guard rather than annotating it.
  • quality / E2E Tests (Playwright) and quality / Hydra Gates cancelled by the follow-up push. A cancelled job is no verdict in either direction, so neither can be cited.

Run 31017209915 (commit c1653bdf, the fix) — created 14:50:06Z, status: pending, 0 jobs roughly an hour later. Zero jobs is not a pass and it is not a failure; it is the absence of a measurement. The workflow itself resolved (the run is named Code Quality, not the raw path, so this is not the .github d68fb727 breakage that voided runs between 13:29:41Z and 13:47:44Z) — it looks like runner-queue starvation behind the still-running E2E on the older run.

Verified locally against the pushed tree in the meantime, with the main checkout's toolchain:

  • phpmd … phpmd.xml on all five changed PHP files — clean.
  • phpstan analyse -c phpstan.neon on the same five, --error-format=raw — zero error lines.
  • phpcs --standard=phpcs.xml on both new files — clean.
  • tests/Unit/Listener — 52/52.
  • gate-6 check_orphan_auth.py over lib/Service + lib/Controller — 0 findings (was 1).
  • Revert control A re-run after the rework: with assertMutable()'s call removed the 3 rejection tests fail and the 4 positive controls still pass, so the tests still discriminate.

Local green is not CI green, which is exactly why this is not being merged on it. Please merge once run 31017209915 has actually produced its jobs and they are read individually.

One thing to expect: quality / Hydra Gates is already red on development (run 31004072438, pre-outage). If it is red here too, check whether it is red for the same reasons before attributing it to this PR — the fleet-wide false-positive analysis is in ConductionNL/.github#158 and #160.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/procest @ 13c3250

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
check-vue3-compile
test-l10n
composer ✅ 100/100
npm ✅ 550/550
PHPUnit
Newman ⏭️
Playwright
Hydra gates

Quality workflow — 2026-08-05 14:55 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/procest @ 51c8a62

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
check-vue3-compile
test-l10n
composer ✅ 100/100
npm ✅ 550/550
PHPUnit
Newman ⏭️
Playwright
Hydra gates

Quality workflow — 2026-08-05 15:18 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/procest @ 341d4ac

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
check-vue3-compile
test-l10n
composer ✅ 100/100
npm ✅ 550/550
PHPUnit
Newman ⏭️
Playwright
Hydra gates

Quality workflow — 2026-08-05 20:32 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Held, not merged — blocked by a repo-wide gate-24 wiring failure, not by anything in this PR.

Hydra Gates is red here, but the failure is "a gate did not run", not "a gate found something":

[hydra-gates] GATES THAT DID NOT RUN: 24
[hydra-gates] RESULT: ALL GATES PASSED — EXCEPT GATES 24, WHICH DID NOT RUN.

hydra-gates-require-full-coverage is set on this repo, so a gate whose subject matter exists but which fails to report is a hard failure. That is the correct behaviour — a gate that did not run is not a gate that passed.

It is not this PR's doing. Measured across three independent procest PRs tonight with completely different diffs:

PR branch scope result
#739 fix/phpmd-unusedparams-ruleset 3 changed files gate-24 DID NOT RUN
#737 fix/wire-bewijsstuk-immutability-guard 9 changed files gate-24 DID NOT RUN
#742 chore/eupl-license-normalisation-2026-08-05 32 changed files gate-24 DID NOT RUN

Three different diffs, three different sizes, identical outcome. gate-24 (integration-parity) is structurally unwired in this repo right now, so no PR can currently go green here regardless of its content.

I deliberately did not reach for development to call this pre-existing: a push to the base branch scopes 0 files and Hydra Gates passes in ~20s having inspected nothing, so a green base run here would be vacuous and proves nothing. Absence of evidence from a vacuous run is not evidence of absence.

What unblocks this: the gate-24 parity work that is in flight (fix/gate-24-integration-parity, and the sibling work in openconnector and hermiq). Once a real gate-24 lands in procest, this PR needs only a re-run — its own substantive jobs are already green (28 SUCCESS on #739, with E2E Tests (Playwright) passing after a real 1633-second run).

Not merging on a red Hydra Gates, and not adding a waiver or flipping require-full-coverage to go green — that would convert a known-broken gate into a silently absent one across the whole repo.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/procest @ 0afb1a5

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
check-vue3-compile
test-l10n
composer ✅ 100/100
npm ✅ 550/550
PHPUnit
Newman ⏭️
Playwright
Hydra gates

Quality workflow — 2026-08-05 21:38 UTC

Download the full PDF report from the workflow artifacts.

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