fix(security): give the two immutability guards a call site that actually runs - #737
fix(security): give the two immutability guards a call site that actually runs#737rubenvdlinde wants to merge 4 commits into
Conversation
…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.
Status — leaving this open, and whyNot merging on the current CI state. Recording it precisely so whoever picks this up does not have to re-derive it. Run 31015088820 (commit
Run 31017209915 (commit Verified locally against the pushed tree in the meantime, with the main checkout's toolchain:
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 Report — ConductionNL/procest @
|
| 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.
Quality Report — ConductionNL/procest @
|
| 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.
Quality Report — ConductionNL/procest @
|
| 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.
|
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":
It is not this PR's doing. Measured across three independent procest PRs tonight with completely different diffs:
Three different diffs, three different sizes, identical outcome. gate-24 ( I deliberately did not reach for What unblocks this: the gate-24 parity work that is in flight ( Not merging on a red Hydra Gates, and not adding a waiver or flipping |
Quality Report — ConductionNL/procest @
|
| 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.
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 —
ChecklistRunImmutabilityListenerwas worse than orphaned, and gate-6 does not see it because its entry point ishandle():ObjectListenerRegistrar/BezwaarListenerRegistrar/WorkflowListenerRegistrar), so it was never subscribed to any event and never ran; andObjectUpdatedEvent, which OpenRegister dispatches afterupdateObjectEntity()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()makesMagicMapperraiseHookStoppedExceptionbefore anything is written — the same mechanismLocationBagValidationListeneralready 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.
BewijsstukImmutabilityListenerreads the stored state (getOldObject()on update, the entity itself on delete), never the incoming payload — otherwise a caller could clearimmutablein 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/revertedassertMutable()has no caller (the shipped state)ObjectUpdatedEventClean tree:
tests/Unit/Listener52/52 green. Gate-6 on this branch: 0 findings (was 1).Spec
openspec/specs/subsidieverlening-keten/spec.mdkeepsstatus: partial—verifyHash(), 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.