Skip to content

fix(security): authorise citizen lookups and the AI decision trail (gate-7 IDORs, tranche 1) - #805

Merged
rubenvdlinde merged 6 commits into
developmentfrom
fix/gate7-idor-remediation
Aug 11, 2026
Merged

fix(security): authorise citizen lookups and the AI decision trail (gate-7 IDORs, tranche 1)#805
rubenvdlinde merged 6 commits into
developmentfrom
fix/gate7-idor-remediation

Conversation

@rubenvdlinde

@rubenvdlinde rubenvdlinde commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

🔧 DEPLOYMENT STEP — THIS PR DENIES UNTIL A GROUP EXISTS

CitizenLookupGuard gates the KCC citizen-lookup endpoints on membership of
kcc, klantcontact, beheerders or admin.

beheerders and admin are attested — both are already used as group
names by ProcessMiningController and ParaferingAuditExportController.
kcc and klantcontact are assumptions: verified with grep -w at
cb63acad3, neither appears anywhere in this codebase as a group name
(kcc is only a feature name, spec slug and CSS class; klantcontact only a
ZGW domain term and a spec slug). They were chosen by me, not derived.

Before/at merge: create a kcc group and add the KCC handlers to it, or
edit the constant to match the group your instance already uses. Until then
KCC staff get HTTP 403 and admins are unaffected.

This is a fail-closed error by design: a wrong group name is a functional
regression an operator fixes in a minute; the alternative was a live HTTP 200
returning a citizen's phone number and call summaries to any authenticated
account.

What

Closes the first tranche of the 33 unguarded #[NoAdminRequired] endpoints
found by the gate-7 fleet re-audit (procest#801) — the ones that touch citizen
personal data
.

Live reproduction, two accounts, status codes printed (rig: NC 34.0.0,
procest 0.3.9, openregister 0.2.17-unstable.37). Seeded a case and a
contactmoment for citizen BSN-999999999 as pro-victim; probed as
pro-attacker, a second non-admin account with no relationship to any of it:

request before after
GET /api/kcc/voorblad?burgerId=… unauthenticated 401 401
GET /api/kcc/voorblad?burgerId=… as an unrelated account 200 + phone number + call summary 403
same, as a member of group kcc 200 200
GET /api/contactmomenten?burgerId=… as an unrelated account 200 403
POST /api/kcc/quick-actions/klacht-registreren as an unrelated account 200 403
GET /api/ai/audit with no caseId 200, unscoped 400
GET /api/ai/audit?caseId=<victim's case> as an unrelated account 200 403

All existing verification of these endpoints was source reading only. Details
are in fleet-board/findings/procest.md; no further exploit specifics here.

Why gate-7 never saw it

.github#365 — the checker accepts Http::STATUS_UNAUTHORIZED / 401 as an
authorisation guard, and 49 of procest's 91 controller files open with the
house-style if ($user === null) { … 401 } preamble. That is authentication.
gate-7 reports 0 in all eighteen fleet apps for the same reason.

.github#372 — the layer below does not catch it either:
PermissionHandler::hasGroupPermission() returns true for an empty
authorization block and enforce_default_closed defaults false. procest's
procest_register.json declares 85 schemas, zero with an authorization
block
, so the controller guard is not the outer of two layers — it is the only
layer.

How

  • CitizenLookupGuard (new) — role gate for endpoints that resolve a raw
    burgerId. These have no per-object owner (a KCC handler legitimately answers
    a call from a citizen they have never handled), so the control is a role, not
    a per-case relationship, and CaseAccessGuard does not apply. It follows the
    idiom already used for the other broad-scope reads in this app
    (ParaferingAuditExportController, AiAuditExportController,
    ProcessMiningController): a fixed group set plus an admin fallback. Fails
    closed in the opposite direction to the bug CaseAccessGuard was written
    for — an absent group grants nothing.
  • CaseAccessGuard::hasCaseReadAccess() (new) — per-case READ predicate
    (admin / assignee / member of assignees). Deliberately not delegated to
    Sharing\CaseAccessPolicy::canUserAccessCase(), which returns true when
    OpenRegister is absent, when the schema is unconfigured, and when the lookup
    throws. Three fail-open branches, and its docblock claims an admin check the
    class has no IGroupManager to perform. Nine call sites rest on it — filed
    separately, not changed here.
  • AiController::auditIndex()caseId was optional and the filter was
    built with array_filter(), so omitting it returned every AI decision record
    on the instance.

Also fixed: a fatal that made every endpoint a 500 on the multi-tenant path

TenantMiddleware.php:132 called $this->request->setParameter(…).
setParameter() exists on neither OCP\IRequest nor
OC\AppFramework\Http\Request
, so every request from a user who had a
tenant died as an unhandled Error → HTTP 500 with an HTML page.

The branch above returns early for users with no tenant, and single-tenant
installs — CI, every e2e rig, every local install — take that branch. There is
no unit test for this middleware at all. Nothing anywhere reads the three keys
it set, so they are removed rather than re-homed; the tenant-lifecycle
enforcement above is untouched.

It also masked the IDORs while they were being measured: the first probe
round returned 500 for four of six requests, which reads exactly like "the
endpoint is protected".

Tests

  • 7 new ContactMomentController cases, 9 new CaseAccessGuard cases, 2 new
    auditIndex cases; every refusal case asserts the collaborator that would
    read or write the data is never reached, and each guard has a positive arm
    so it cannot be satisfied by refusing everyone.
  • Negative control: inverting the guard predicate in
    ContactMomentController turns all 7 red (7 failures / 7 assertions),
    including both positive arms. Reverted.
  • Full suite 1795 tests OK; phpcs 0 errors over lib/; psalm, phpstan,
    phpmd clean.
  • composer.lock: phpcsutils 1.2.2 → 1.2.3 (CVE-2026-65954). vendor/ here is
    root-owned so the bump could not be exercised locally; CI installs fresh.

Not in this PR

The remaining case-scoped endpoints from procest#801 (CaseRelation, Deelzaak,
Berichtenbox, Appointment, Zaakdossier, StatusTransition, EmailTemplate,
BezwaarHearing, Lhs, Advice) follow in the next commits on this branch.

Refs ConductionNL/.github#365, ConductionNL/.github#372, procest#801


Tranche 2 — the case-scoped endpoints (commit 2)

Twenty more endpoints now call CaseAccessGuard. The ownership field is not
invented
: case.assignee / case.assignees is the model the guard already
encodes and WOOAssessmentController already enforces.

Live reproduction, both arms, hard-flushed opcache

Owner = pro-victim (= case.assignee). Attacker = pro-attacker2, in the
same group as the owner
and differing from it in exactly one attribute: it is
not the assignee.

endpoint BEFORE owner/attacker AFTER owner/attacker
GET /api/cases/{id}/relations 200 / 200 200 / 403
GET /api/deelzaken/{id}/children 200 / 200 200 / 403
GET /api/deelzaken/{id}/parent 404 / 404 404 / 403
GET /api/case/{id}/available-transitions 200 / 200 200 / 403
GET /api/case/{id}/transition-history 200 / 200 200 / 403
GET /api/dossier/{id}/export 500 / 500 500 / 403
GET /api/appointments?caseId= 200 / 200 200 / 403
GET /api/berichtenbox/messages?caseId= 200 / 200 200 / 403
POST /api/deelzaken/validate 409 / 409 409 / 403
POST /api/cases/{id}/relations 409 / 409 409 / 403
POST /api/deelzaken/{id}/unlink 200 / 200 200 / 403

The owner column is byte-identical across both arms — none of these became
an endpoint that denies everyone.

⚠️ GET /api/dossier/{caseId}/export answers 500 to its own owner in both
arms
. Pre-existing, untouched here, now unreachable by anyone else. Flagged,
not fixed.

Two measurement traps that nearly produced a wrong table

  1. opcache.revalidate_freq=60. After git checkout <ref> -- lib/, each
    file revalidates on its own 60-second clock, so one request can execute one
    controller at the new revision and another at the old one. Three runs of the
    same 11 endpoints gave three different, internally consistent tables. Only
    docker restart between arms is reliable — opcache_reset() via
    docker exec php -r resets nothing, because opcache.enable_cli is Off, and
    exits 0.
  2. The attacker account was in no group while the owner was in one (a group
    the KCC fix required). Six of eleven endpoints then read as "already
    protected" because procest's SaaS middleware refused the group-less account —
    a 403 that looks exactly like the IDOR being absent.

Checked before trusting the guard

CaseAccessGuard::loadCase() ends in catch (Throwable) { return null; }
pipelinq#805's exact shape, where a fatal named-argument call becomes "deny
everyone". ObjectService::find() declares id, register and schema, so
the call cannot raise; the unchanged owner column confirms it live.

🔶 One product decision that needs sign-off (see the deployment banner at the top)

For the case-scoped endpoints the ownership field already existed. The
citizen-lookup guard is different
— the subject is a citizen, not a case, and
a KCC handler legitimately looks up someone they have never handled, so no
per-object predicate exists. I introduced one: membership of
kcc / klantcontact / beheerders, plus admin, copying the ALLOWED_GROUPS
idiom already used by ParaferingAuditExportController,
AiAuditExportController and ProcessMiningController.

The shape is the app's own; two of the four group names are mine. Checked
with grep -w: beheerders and admin are attested elsewhere in this app,
kcc and klantcontact are not attested anywhere as group names.

Shipped rather than leaving citizen personal data readable by every account, and
admins keep working so no deployment loses its administrator — but this is a
security control nobody has agreed. Please ratify or rename the two invented
names.
The assumption is stated on CitizenLookupGuard::ALLOWED_GROUPS itself
so nobody reading the constant has to guess which names were derived and which
were invented.

Still open from procest#801

EmailController::templates and EmailTemplateController::listTemplates /
variables (case-type config reads, weighted down by the audit as crossing no
per-user boundary), and TemplateController::activate — not an IDOR, but any
authenticated user can create caseTypes and statusTypes wholesale. Logged
separately rather than folded in here.


Commit 3 — PHPMD, and a retraction

CI caught what my local run missed: I ran phpmd at the tranche-1 checkpoint and
not again before committing tranche 2, and my three getCaseIdFor*() resolvers
were near-identical copies. Extracted to one OwningCaseResolver, which closes
CyclomaticComplexity 10 ×2, NPathComplexity 288 ×2 and ExcessiveClassComplexity
53 by deleting the duplication, not by moving a threshold. The upload guard
moved to DossierUploadHandler::hasCaseUploadAccess() — the collaborator that
performs the write — which also takes ZaakdossierController back under the
coupling threshold my added dependency had pushed it over. phpmd is now silent
on both rulesets; no baseline, threshold or test was weakened.

🔴 Retraction: POST /api/bezwaar/hearings/{sessionId}/attendance was never a live IDOR

Found while live-verifying the resolver's positive path. With a real
hearingSession seeded, the legitimate owner gets
400 {"error":"Hearing session not found"}:

$current = $objectService->find($sessionId, register: $register, schema: $schema);
if (is_array($current) === false) { throw new RuntimeException('Hearing session not found'); }

ObjectService::find() returns ?ObjectEntity, never an array — the throw is
unconditional. The endpoint has never worked and never leaked anything.

I only caught it because I probed the owner as well as the attacker: the
attacker arm returned a clean 403 and looked exactly like a closed
vulnerability, while the owner's refusal came back in the wrong dialect
(400 … not found where the guard's refusal is 403 Not authorized).

Deliberately not repaired — a legally-sensitive Awb art. 7:7 audit path
whose correct post-repair behaviour I cannot validate here. The guard is in
place first, so repairing the lookup cannot create a live IDOR.

Honest accounting — this PR does not close "33 live IDORs"

Guards were added at 33 call sites. What the live probes demonstrate:

category n
Demonstrated leak to an unrelated account (200 with the owner's data, or the write performed) 10
Attacker reached the same business logic, no payload (both got the owner's 409) 2
Indistinguishable — endpoint fails or is empty for everyone (dossier/export is 500 to its own owner in both arms; deelzaken/parent 404/404 because the fixture case has no parent) 2
Guarded but proven dead (hearing attendance, above) 1
Guarded, not live-probed — no fixture on this rig, or reachable only through UI flows I did not build 18

⚠️ dossier/export returning 500 to its owner is the same shape as the dead
hearing endpoint. I did not chase it, so it is not counted as a demonstrated
IDOR
either way — flagged for follow-up.


Commit 5 — the two gate cells my own change turned red

Reproduced locally against the canonical gate package
(ConductionNL/.github@57bcb2b), not the adjacent .github checkout.

⚠️ Both were invisible to me until CI reported them, for the same reason: when I
grepped this PR's check list for a gates cell it was not there yet.
gh pr checks returns a partial set — the run grew 23 → 24 → 29 → 32 — so
"there is no Hydra Gates job in this repo" was an absence claim read off a list
that had not finished being populated.

gate-9 semantic-auth — 3 findings, all mine

createTemplate / updateTemplate / seedDefaults kept @NoAdminRequired
while their bodies now require admin: the exact attribute-vs-body mismatch this
gate exists to catch, introduced by me in commit 2.

Fixed with #[AuthorizedAdminSetting(settings: AdminSettings::class)] — the
idiom KccRoutingController already uses for the same kind of config write — so
Nextcloud's middleware enforces admin before the controller runs. Simply
deleting @NoAdminRequired would have satisfied gate-9 and broken gate-5,
which requires every routed method to declare a posture. The in-body check stays
as defence in depth and is what the unit tests drive.

gate-19 e2e-coverage — 19 findings, none of them mine

All ten scenarios I added were already tagged and accepted. The 19 were
pre-existing untagged scenarios in authz-bypass-fixes/spec.md, pulled in
because the gate parses a touched spec file whole.

Tagged, not dodged. The tempting move was to put my new requirements in a
fresh spec file — instantly green, debt untouched and invisible again. I didn't.

Each reason names the specific test method, not just a class:
AdviceServiceAuthorizationTest, WOOAssessmentControllerAuthorizationTest,
ConflictOfInterestServiceTest. I read the two load-bearing ones to confirm
they assert the refusal and expects($this->never()) on the service beneath.

One scenario had no coverage at all"A missing conflict service denies
rather than skips"
; every fixture in MandaatCheckServiceTest binds a real
service. That one is covered by a new test, not waived, and the test carries
its own positive control: the identical call authorizes with the service bound
and denies when it is null.

Local verification, all green

gate-7 clean · gate-9 clean · gate-16 count=0 · gate-19 PASS · phpcs 0 ·
phpmd silent on both rulesets · psalm · phpstan · 1803 tests.

…ate-7)

`GET /api/kcc/voorblad?burgerId=…` returned a citizen's recent contact
history — caller phone number and the free-text summary of every previous
call — to ANY authenticated account. Reproduced live on a running instance
with two accounts, status codes printed: unrelated non-admin got HTTP 200
before, HTTP 403 after; a member of group `kcc` still gets 200.

gate-7 has always reported 0 for this app. `.github#365`: the checker accepts
`Http::STATUS_UNAUTHORIZED` / `401` as an authorisation guard, and 49 of
procest's 91 controller files open with that authentication preamble. The
silence was never a verdict.

What changed
- `CitizenLookupGuard` (new): role gate for the endpoints that resolve a raw
  `burgerId`. These have no per-object owner — a KCC handler legitimately
  answers a call from a citizen they have never handled — so the control is a
  role, not a per-case relationship, and `CaseAccessGuard` does not apply.
  Fails closed in the opposite direction to the bug `CaseAccessGuard` was
  written for: an absent group grants nothing.
  Applied to voorblad, contactmomenten index+create, nieuwe-zaak,
  klacht-registreren.
- `CaseAccessGuard::hasCaseReadAccess()` (new): per-case READ predicate —
  admin, `assignee`, or a member of `assignees`. Deliberately not delegated to
  `Sharing\CaseAccessPolicy::canUserAccessCase()`, which returns TRUE when
  OpenRegister is absent, when the schema is unconfigured, and when the lookup
  throws; three fail-open branches this guard must not inherit.
- `AiController::auditIndex()`: `caseId` was optional and the filter was built
  with `array_filter()`, so omitting it returned every AI decision record on
  the instance. Now required, and authorised with `hasCaseReadAccess()`.
- `TenantMiddleware`: removed three lines calling
  `IRequest::setParameter()`, a method that exists on no Nextcloud request
  class. Every request from a user who HAD a tenant died as an unhandled
  `Error` → HTTP 500 + an HTML page. Single-tenant installs return earlier, so
  neither CI nor any e2e rig ever reached it, and there is no unit test for
  this middleware. Nothing read the three keys back. The tenant-lifecycle
  enforcement above it is untouched.
- `composer.lock`: phpcsutils 1.2.2 → 1.2.3 (CVE-2026-65954).

Tests
- 7 new controller cases + 9 new guard cases + 2 new auditIndex cases.
- Negative control: inverting the guard predicate in ContactMomentController
  turns all 7 red (7 failures, 7 assertions), including the two positive arms;
  reverted.
- Full suite 1795 tests OK; phpcs 0 errors over lib/; psalm, phpstan, phpmd
  clean.

Refs ConductionNL/.github#365, ConductionNL/.github#372
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/procest @ eab9d8d

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 ✅ 552/552
PHPUnit
Newman ⏭️
Playwright
Hydra gates

Quality workflow — 2026-08-11 21:10 UTC

Download the full PDF report from the workflow artifacts.

…sGuard

Tranche 2 of procest#801. Twenty more `#[NoAdminRequired]` endpoints that took
a case (or an object resolving to a case) off the request and acted on it with
no per-object authorisation now call the guard the app already has.

The ownership field is not invented: `case.assignee` / `case.assignees` is the
model `CaseAccessGuard` already encodes and `WOOAssessmentController` already
enforces. Nothing here introduces a new notion of who owns a case.

Guarded
- CaseRelation list/create/destroy — read access to BOTH ends, which is what
  the docblocks already claimed. Their guard was correctly shaped and INERT:
  it keys off `find()` returning null, and OpenRegister returns every object
  to every authenticated user for a schema with no `authorization` block
  (.github#372). Every false docblock is corrected in this commit, including
  the one in CaseRelationService, with a note not to re-state the RBAC claim
  unless a test fails when the declaration is removed.
- Deelzaak list/parent/validate (read) and unlink (mutation — mass-detaches
  every sub-case of a parent).
- Berichtenbox send (mutation) / messages / poll. `send()` dispatches an
  official government message into a citizen's statutory message box.
- Appointment index/create/cancel/noShow.
- Zaakdossier uploadDocument — its sibling `linkExisting()` guards the
  DOCUMENT; the missing half was the CASE.
- StatusTransition available/history, Milestone progress, DossierExport export,
  EmailTemplate prefillDraft, Lhs recommend (persists an enforcement-sanction
  record, so guarded as a mutation despite reading like a query).
- BezwaarHearing recordAttendance.
- Advice dispatchReminder: `AdviceService` grows an authorised
  `dispatchReminderAsUser()` seam in front of the unguarded
  `dispatchReminder()` the cron uses — the same split this class already has
  between `transitionStatus()` and `applyTransition()`. The cron keeps working
  without a session.
- EmailTemplate create/update/seedDefaults are admin-only. These are config
  writes, not case data — a caseType belongs to the municipality, not to a
  handler, so there is no per-case relationship to authorise against.
  seedDefaults' docblock argued its posture "mirrors createTemplate()
  deliberately"; that argument was sound and the posture it mirrored was not.

Where a route carries only a child id (message, appointment, hearing session)
the owning case is resolved first via a new `getCaseIdFor*()` on the relevant
service. An unresolvable child DENIES, so none of these is an existence oracle.

Read access is `hasCaseReadAccess()`, deliberately not
`Sharing\CaseAccessPolicy::canUserAccessCase()` — that one returns TRUE when
OpenRegister is absent, when the schema is unconfigured, and when the lookup
throws.

Verified the callee signature before relying on it: `ObjectService::find()`
declares `id`, `register` and `schema`, so the named-argument call inside
`CaseAccessGuard::loadCase()` cannot raise the `Error` that its
`catch (Throwable) { return null; }` would convert into "deny everyone".

Tests: 1802 OK. Five existing test classes updated for the new constructor
arguments — each keeps its original assertions and gains a refusal arm.

Refs ConductionNL/.github#365, ConductionNL/.github#372, procest#801
…ngs my guards added

CI caught what my local run missed: I ran phpmd at the tranche-1 checkpoint and
not again before committing tranche 2, and the three `getCaseIdFor*()` resolvers
I had written were near-identical copies of one another.

- `Service\Support\OwningCaseResolver` (new) — one fail-closed lookup from a
  child object to its owning case. `AppointmentService::getCaseIdForAppointment`,
  `BerichtenboxService::getCaseIdForMessage` and
  `Bezwaar\HearingService::getCaseIdForSession` now delegate to it. Fixes
  CyclomaticComplexity 10 and NPathComplexity 288 on two of them and
  ExcessiveClassComplexity 53 on HearingService, by deleting the duplication
  rather than by loosening a threshold.
  It uses `is_callable()` rather than `method_exists()` for `jsonSerialize`:
  OpenRegister's `ObjectEntity` reaches several accessors through
  `Entity::__call()`, for which `method_exists()` is false.
- The upload case-guard moves from `ZaakdossierController` to
  `DossierUploadHandler::hasCaseUploadAccess()` — the collaborator that
  performs the write. That also takes the controller back under the
  CouplingBetweenObjects threshold my added dependency had pushed it over.
  Evaluated once per request, not once per file.

No gate threshold, baseline or test was weakened; phpmd is now silent on both
rulesets.

⚠️ Discovered while live-verifying the resolver's POSITIVE path:
`POST /api/bezwaar/hearings/{sessionId}/attendance` HAS NEVER WORKED.
`HearingService::recordAttendance()` does
`$current = $objectService->find(...); if (is_array($current) === false) throw`,
but `find()` returns `?ObjectEntity`, so the throw is unconditional for an
existing session. The owner gets `400 Hearing session not found`.

That endpoint was therefore NOT a live IDOR — it is a dead endpoint, and this
is recorded rather than repaired: it is a legally-sensitive Awb art. 7:7 audit
path and fixing it would change behaviour I cannot validate here. The guard
added in the previous commit stays, so whoever repairs the lookup cannot turn
it into a live IDOR in the process.

Refs ConductionNL/.github#365, procest#801
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/procest @ 1723b4d

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 ✅ 552/552
PHPUnit
Newman ⏭️
Playwright
Hydra gates

Quality workflow — 2026-08-11 21:37 UTC

Download the full PDF report from the workflow artifacts.

…ment

CI's phpcs found what I did not, for the second time in this branch and for the
same reason: after the OwningCaseResolver refactor I re-ran phpmd but not phpcs,
having assumed the earlier full-gate run still covered the tree.

- `DossierUploadHandler`: missing `@param` for `$caseAccessGuard` (a real
  error, not alignment).
- `ZaakdossierController`, `AppointmentService`, `OwningCaseResolver`: parameter
  alignment and one blank line, all phpcbf-fixable.

Lesson recorded for myself: run the whole gate set before every commit — a
subset that passed on an earlier tree says nothing about the current one, which
is the same shape as every "a check that did not run looks like one that passed"
finding in this programme.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/procest @ 8b42ffe

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 ✅ 552/552
PHPUnit
Newman ⏭️
Playwright
Hydra gates

Quality workflow — 2026-08-11 21:46 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/procest @ 4c37f68

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 ✅ 552/552
PHPUnit
Newman ⏭️
Playwright
Hydra gates

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

Download the full PDF report from the workflow artifacts.

…rned red

Reproduced locally against the CANONICAL gate package
(ConductionNL/.github @ 57bcb2b), not the adjacent .github checkout.

⚠️ Both were invisible to me until CI reported them, for the same reason twice
over: when I grepped the PR's check list for a gates cell it was not there yet.
`gh pr checks` returns a PARTIAL set — the run grew 23 → 24 → 29 → 32 — so
"there is no Hydra Gates job in this repo" was an absence claim read off a list
that had not finished being populated.

gate-9 semantic-auth (3 findings, all mine)
  `createTemplate`, `updateTemplate` and `seedDefaults` kept `@NoAdminRequired`
  while their bodies now require admin — the exact attribute-vs-body mismatch
  this gate exists to catch, and one I introduced in the previous commit.
  Fixed with `#[AuthorizedAdminSetting(settings: AdminSettings::class)]`, the
  idiom already used by KccRoutingController for the same kind of config write,
  so Nextcloud's middleware enforces admin BEFORE the controller runs. Simply
  deleting `@NoAdminRequired` would have satisfied gate-9 and broken gate-5,
  which requires every routed method to declare a posture. The in-body check
  stays as defence in depth and is what the unit tests drive.

gate-19 e2e-coverage (19 findings, NONE of them mine)
  All ten scenarios I added were already tagged and accepted. The 19 were
  PRE-EXISTING untagged scenarios in `authz-bypass-fixes/spec.md`, pulled into
  scope because the gate parses a touched spec file WHOLE.

  Tagged rather than dodged: each carries a reason naming the specific TEST
  METHOD that covers it, not just a class — `AdviceServiceAuthorizationTest`,
  `WOOAssessmentControllerAuthorizationTest`, `ConflictOfInterestServiceTest`.
  Spot-verified two of the load-bearing ones by reading them: both assert the
  refusal AND that the underlying service is never reached.

  One scenario — "A missing conflict service denies rather than skips" — had no
  coverage at all: every fixture in `MandaatCheckServiceTest` binds a real
  conflict service. It is COVERED, not waived, by a new test that carries its
  own positive control (the identical call authorizes when the service is
  bound, and denies when it is null).

  I did not move my requirements to a fresh spec file to keep the pre-existing
  debt out of scope. That would have gone green without closing anything.

Local, all green: gate-7 clean · gate-9 clean · gate-16 count=0 · gate-19 PASS ·
phpcs 0 · phpmd silent on both rulesets · psalm · phpstan · 1803 tests.
…d and which are assumed

The coordinator checked my group list against the tree with `grep -w` at
cb63aca rather than taking it on trust, and the answer splits:

  - `beheerders` and `admin` are ATTESTED — both already in use as group names
    by ProcessMiningController and ParaferingAuditExportController.
  - `kcc` and `klantcontact` are ASSUMED. Neither appears anywhere in this
    codebase as a group name; `kcc` occurs only as a feature name, spec slug
    and CSS class, `klantcontact` only as a ZGW domain term and a spec slug.
    I chose them.

The mechanism was precedented and the names were not, and until now the
constant did not say which was which. Someone reading
`['kcc', 'klantcontact', 'beheerders', 'admin']` had no way to tell a derived
name from an invented one — which is the same class of problem as a docblock
asserting a guard that does not exist, just pointed at configuration.

Recorded on the constant and in the class docblock, plus a deployment note: the
guard denies until the group exists.

The error direction is deliberate. A wrong group name means KCC staff get 403
and an operator creates or renames a group in a minute. The alternative was
leaving a live HTTP 200 that returned a citizen's phone number and the free-text
summary of every previous call to any authenticated account. Fail closed.

No behaviour change; comments only.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/procest @ e828db9

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 ✅ 552/552
PHPUnit
Newman ⏭️
Playwright
Hydra gates

Quality workflow — 2026-08-11 22:22 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/procest @ 328a8cb

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 ✅ 552/552
PHPUnit
Newman ⏭️
Playwright
Hydra gates

Quality workflow — 2026-08-11 22:39 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde
rubenvdlinde merged commit 347f2a4 into development Aug 11, 2026
32 checks passed
@rubenvdlinde
rubenvdlinde deleted the fix/gate7-idor-remediation branch August 11, 2026 22:40
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