Skip to content

fix(auth): scope the contact-person read-outs to the caller's organisation (#459) - #466

Merged
rubenvdlinde merged 1 commit into
developmentfrom
fix/contactpersonen-org-scope-459
Aug 9, 2026
Merged

fix(auth): scope the contact-person read-outs to the caller's organisation (#459)#466
rubenvdlinde merged 1 commit into
developmentfrom
fix/contactpersonen-org-scope-459

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Closes #459.

The defect

GET /api/contactpersonen/organisation/{organisationId} is @NoAdminRequired and its only guard was "is somebody logged in". $organisationId is a path parameter and was never compared to the caller's own organisation, so any authenticated user could read the contact persons of any organisation — and the response carries each contact's Nextcloud username, group membership and enabled/disabled state.

Every sibling on the same controller that touches that data already refuses it to non-admins: getUserInfo, getBulkUserInfo, updateUserGroups, disableUser, enableUser. This one did not.

The fix

admin any organisation
anybody else only the organisation their own contactpersoon belongs to
organisation unresolvable refused (403), fail-closed — the same posture verifyCrossTenantScope() already applies to writes

Applied to getContactpersonen and to the sibling getContactPersonsWithUserDetailsForOrganization, which #459 names as having the same shape.

The second, independent problem in #459

The search filter is a bare top-level 'organisation' => $organisationId. Whether OpenRegister reads that as an object-property filter, as @self metadata, or ignores it entirely is not visible from the call site — and an ignored filter returns an UNSCOPED result set that looks exactly like a scoped one. The contactpersoon schema in lib/Settings/softwarecatalogus_register.json declares organisatie, not organisation, so the filter is at best not the key the schema uses.

Rather than guess at the query semantics with no data to measure against (the dev instance holds zero OpenRegister objects), the organisation of every returned record is re-checked in PHP. A filter that fails to scope therefore cannot leak, and a record carrying no resolvable organisation is not served at all. total now counts what is actually returned rather than the unfiltered server-side total.

resolveContactOrganisation() also now normalises the stored value: organisatie is a related object, so it can arrive as a nested envelope, and comparing that raw against a plain UUID read as "different tenant" and denied legitimate members.

The enrichment loop now reuses buildUserInfoData() — the shape the admin-gated siblings already return. That reports the three software-catalog group memberships instead of every GID the account holds, so even an authorised read exposes less.

Can-fail proof

Reverting only lib/Controller/ContactpersonenController.php to origin/development and re-running the new tests:

1) testForeignOrganisationIsForbiddenForNonAdmin        Failed asserting that 200 is identical to 403.
2) testForeignRecordIsAbsentFromTheBodyWhenTheQueryDoesNotScope
   a contact belonging to another organisation leaked into the response body
   ...does not contain "victim@b.example"
3) testSiblingWithUserDetailsRouteIsAlsoScoped          Failed asserting that 500 is identical to 403.
4) testUnresolvableCallerOrganisationIsForbidden        Failed asserting that 200 is identical to 403.
Tests: 7, Assertions: 11, Failures: 4.

The item-level assertion is on the item, not the envelope: the foreign organisation's username must be absent from the body. The remaining 3 tests assert the legitimate surface still works (own org allowed, admin allowed, nested related-object reference resolved) and pass in both directions by design — that is what makes them a regression net rather than a mirror of the fix.

Measurements

  • phpcs lib/: 0 errors / 87 warnings — byte-identical to origin/development.
  • phpmd (with the repo baseline): clean. The first draft tripped TooManyMethods (26) and ExcessiveMethodLength (106); both were resolved by removing code — folding two helpers into existing ones and reusing buildUserInfoData() — not by suppression.
  • psalm: no errors. phpstan: no errors.
  • Unit suite: 519 tests, 1829 assertions, 0 failures (25 pre-existing skips).
  • hydra-gates 48c88ba1e0d049f8f38538c33e790d3e603c55d0, full-repo: 13 gates fail, identical to origin/development gate-for-gate, except gate-25 contract-coverage 44 → 43. No count regressed.

…ation (#459)

`GET /api/contactpersonen/organisation/{organisationId}` was `@NoAdminRequired`
with "is somebody logged in" as its only guard. `$organisationId` is a path
parameter and was never compared to the caller's own organisation, so any
authenticated user could read the contact persons of any organisation — and the
response carries each contact's Nextcloud username, group membership and
enabled/disabled state. Every sibling on the same controller that touches that
data (`getUserInfo`, `getBulkUserInfo`, `updateUserGroups`, `disableUser`,
`enableUser`) already refuses it to non-admins.

- `checkOrganisationReadPermission()`: instance admins may read any
  organisation; everybody else only the organisation their own contactpersoon
  belongs to; a caller whose organisation cannot be resolved is refused. This
  mirrors `verifyCrossTenantScope()`, which already fails closed for writes.
- The same guard is applied to the sibling route
  `getContactPersonsWithUserDetailsForOrganization`, named in the issue as
  having the same shape.
- The per-record organisation is re-checked in PHP. The search filter is a bare
  top-level `organisation` key; whether OpenRegister reads that as an object
  property, as `@self` metadata, or ignores it is not visible from the call
  site, and an ignored filter returns an UNSCOPED result set that looks exactly
  like a scoped one. A record with no resolvable organisation is not served.
- `resolveContactOrganisation()` now normalises the stored value.
  `organisatie` is declared as a related object in the register, so it can
  arrive as a nested envelope; comparing that raw against a plain UUID read as
  "different tenant" and denied legitimate members.
- The enrichment loop reuses `buildUserInfoData()`, the shape the admin-gated
  siblings already return — three catalog group memberships rather than every
  GID the account holds.
- `total` now counts what is actually returned instead of the unfiltered
  server-side total.

Can-fail proof: reverting the controller to `origin/development` turns 4 of the
7 new tests red, including the item-level assertion — `victim@b.example` and
`org-uuid-B` appear in the response body. The other 3 assert the legitimate
surface still works and pass in both directions by design.

phpcs lib/: 0 errors / 87 warnings, identical to origin/development.
phpmd, psalm, phpstan clean. Unit suite 519 tests green.
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/softwarecatalog @ 103ae79

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
check-vue-demi
test-l10n
composer ✅ 128/128
npm ✅ 718/718
PHPUnit
Newman ⏭️
Playwright
Hydra gates

Quality workflow — 2026-08-09 14:19 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde
rubenvdlinde merged commit eff9e05 into development Aug 9, 2026
29 checks passed
@rubenvdlinde
rubenvdlinde deleted the fix/contactpersonen-org-scope-459 branch August 9, 2026 14:30
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