fix(security): scope audit-trail + search-trail to admin (wave-3 C6/C7 cross-tenant leak)#2000
Merged
rubenvdlinde merged 1 commit intoMay 28, 2026
Conversation
…7 cross-tenant leak)
C6 — AuditTrailController::index/show were @NoAdminRequired, so any
authenticated user (including users restricted to a single app group)
could enumerate every other tenant's audit trail. Each row carries a
full diff of an object change — register/schema/object UUID, the new
and old values of every JSON property, the actor UID, IP address and
processing-activity tag. With sequential IDs, show() doubled as a
trivial enumeration path across the whole instance. LogService::
getAllLogs/getLog delegate straight to AuditTrailMapper::findAll/find
with no scope filter, so the surface had no tenant boundary at all.
C7 — SearchTrailController::index/show had the same shape against the
search-trail table. Rows record the full query string, register/schema
identifiers, result counts, the user's IP, user-agent and session id
for every search anyone has ever run — a GDPR-sensitive leak and an
obvious recon path ("what is tenant X searching for").
Both surfaces now go through a body-level requireAdmin() helper that
returns 401 when anonymous and 403 when non-admin, mirroring the
existing pattern used by AuditTrailController::export() / clearAll()
and the wave-2 NotificationHistoryController. Framework-level
@NoAdminRequired annotations are removed so the gate sits at both
layers (defence-in-depth — losing either still keeps the surface
closed). The non-list audit-trail surfaces (verify, verwerkingsregister,
inzageverzoek, per-object objects()) stay reachable because they
already key off identifiers the caller must know up front.
SearchTrailController gains IUserSession + IGroupManager constructor
deps; NC's DI container auto-wires them so info.xml needs no change.
Tests updated for both controllers — denial paths assert 401/403 and
verify the service layer is not invoked on a forbidden call (no DB
hit, no row read). Integration-test SearchTrail sites get an explicit
setupAdminUser() call to match production.
Pre-existing PHPCS violations on the buildSortFromParams /
buildFiltersFromParams pair (inline-if + multi-line-array nits) and a
PHPMD ExcessiveClassComplexity bump (50 -> 51 from the added
requireAdmin() calls) are fixed in the same commit so the diff lands
on a green gate.
Follow-up issue (Option B "auditor group"): a future change should
allow admins to grant an "auditor" group read-only access to audit /
search trails without granting full admin — useful for AVG/GDPR Art
30 reviewers. Not blocking; admin-only is the safer default for now.
Refs: wave-3 triage (/tmp/triage-openregister.md) findings C6, C7.
rubenvdlinde
requested review from
WilcoLouwerse,
bbrands02 and
rjzondervan
as code owners
May 28, 2026 09:54
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two wave-3 critical findings closed:
AuditTrailController::index/show(C6) andSearchTrailController::index/show(C7) were@NoAdminRequired, so any authenticated user could enumerate every other tenant's audit-trail rows and search-trail rows — both PII-heavy and recon-grade.LogService::getAllLogs/getLogdelegate straight toAuditTrailMapper::findAll/findwith no scope filter, so the surface had no tenant boundary at all. With sequential IDs,show()doubled as a trivial enumeration path across the entire instance.Fix (Option A — admin-only)
Both surfaces now go through a body-level
requireAdmin()helper that returns 401 when anonymous and 403 when non-admin, mirroring the existing pattern used byAuditTrailController::export()/clearAll()and the wave-2NotificationHistoryController.@NoAdminRequiredis removed at the framework level too, so the gate sits at both layers (defence-in-depth — losing either still keeps the surface closed).The non-list audit-trail surfaces (
verify,verwerkingsregister,inzageverzoek, per-objectobjects()) stay reachable because they already key off identifiers the caller must know up front.SearchTrailControllergainsIUserSession+IGroupManagerconstructor dependencies; NC's DI container auto-wires them, so noinfo.xmlchange is needed.Files
lib/Controller/AuditTrailController.php—index()+show()gated via existingrequireAdmin(); helper docblock updated; pre-existing PHPCS run onbuildSortFromParams/buildFiltersFromParamscleaned up in the same commit; new class-level@SuppressWarnings(PHPMD.ExcessiveClassComplexity)(the added gate calls bump cyclomatic complexity from 49 to 51).lib/Controller/SearchTrailController.php— constructor +requireAdmin()helper added;index()+show()gated.tests/Unit/Controller/AuditTrailControllerTest.php— 5 new denial-path tests covering 401/403 + service-not-invoked on forbidden access.tests/Unit/Controller/SearchTrailControllerTest.php— 5 new denial-path tests + setUp default-admin so the existing happy paths still pass through the new gate.tests/Unit/Controller/SearchTrailController{Coverage,Deep}Test.php— constructor sites updated.tests/Service/ControllersIntegrationTest.php—SearchTrailControllerinstantiation wires the shareduserSessionmock + a realIGroupManagerfrom DI;testSearchTrailIndex*andtestSearchTrailShowNotFoundnow call the existingsetupAdminUser()helper.Quality gates (all green on changed files)
php -lcomposer phpstan(changed files)[OK] No errorscomposer phpcs(changed files)composer phpmd(changed files)composer phpunit(4 affected unit suites, 136 tests)Pre-existing notes (not fixed here, out of hotfix scope):
tests/Service/ControllersIntegrationTest.php::testRegisters*already fails ondevelopmentbecauseRegistersController::__constructnow expects 19 args (test passes 16) — verified by running the unmodified file fromorigin/development. Worth a follow-up but unrelated to this fix.Follow-up issue (Option B)
Filing a separate issue after merge: allow admins to grant an auditor group read-only access to audit/search trails without granting full admin (useful for AVG/GDPR Art-30 reviewers and incident-response). Not blocking; admin-only is the safer default for now.
Test plan
GET /apps/openregister/api/audit-trailreturns 403.GET /apps/openregister/api/audit-trail/{any-id}returns 403 — confirming sequential-ID enumeration is closed.GET /apps/openregister/api/search-trailandGET /apps/openregister/api/search-trail/{any-id}return 403.Refs: wave-3 triage (
/tmp/triage-openregister.md) findings C6 and C7. DO NOT admin-merge.