fix(security): tenant-scope object-name resolution — closes gate-7 (SEC-CTRL-2 step 2) - #2541
Merged
rubenvdlinde merged 7 commits intoAug 17, 2026
Merged
Conversation
Eight controls over CacheHandler's name resolution. Six of them FAIL on the current implementation and demonstrate the disclosure: - an object owned by organisation A is named to a caller in organisation B through the object-mapper path; - organisation A's own name is disclosed the same way; - a name cache warmed as tenant A serves A's names to tenant B (the arm a query-only fix would miss); - getAllObjectNames() hands the whole warm cache to whoever asks; - the magic-table warmup ignores the _organisation column entirely; - a name whose owning organisation cannot be established is served anyway. The remaining two are functional controls: in-scope names must still resolve, keyed by UUID, in the exact shape the six internal callers of getMultipleObjectNames() consume, and an empty request must still answer with an empty map. Measured before any fix: Tests: 8, Assertions: 10, Failures: 6.
…ion (SEC-CTRL-2 step 2)
Closes the authenticated cross-tenant name disclosure behind
NamesController::index and ::create. The fix is in CacheHandler, not in
the controller: NamesController is byte-identical to development.
WHAT LEAKED. getMultipleObjectNames() and getAllObjectNames() resolved
names through three paths with no tenant dimension at all — the
organisation mapper, MagicMapper::findMultiple() (a raw UNION over every
magic table) and the magic-table SQL — and then cached the answer in a
process-wide map any later caller could read.
WHAT THIS DOES.
1. A per-object authorisation predicate, hasOrganisationAccess(), mirrors
Db\MultiTenancyTrait::applyOrganisationFilter(): multitenancy off or an
admin under an enabled override is unrestricted; otherwise the caller
sees the active organisation plus its parents, and no active
organisation means no names. An entity whose owning organisation cannot
be established is refused — absence of tenancy is not permission.
2. The magic-table queries now select _organisation alongside _name. That
column is the tenancy oracle for every object-backed name, because an
ObjectEntity produced by MagicMapper::rowToObjectEntity() never carries
an organisation at all (measured, and left alone here — populating it
would change PermissionHandler's behaviour and belongs in its own
change).
3. THE CACHE KEY IS DELIBERATELY UNCHANGED. The tenancy rides in the
VALUE: in memory as a parallel nameCacheOrganisation map, in the
distributed cache as a {n, o} envelope. Prefixing the key with an
organisation would have forced every invalidation site to learn the
prefix, and would orphan an entry under a stale organisation whenever
an object moves tenant. Keeping the key means all six invalidation
sites keep matching untouched. A value written before this change is a
bare string with no tenancy and is read as a MISS, not served unscoped.
4. The scope is memoised for one call and dropped at the top of every
public reader, so a cron worker serving several callers in one process
cannot carry one caller's scope into the next.
Also fixed on the way: batchLoadNamesFromMagicTables() fataled on a null
registerMapper (reachable now that an unknown-tenancy hit falls through to
it), and getSingleObjectName() passed a null name into a non-nullable
parameter when an entity had neither name nor uuid.
The pre-existing CacheHandler suites are pinned to multitenancy=false —
the configuration they were actually written against — and say so; the
scoped behaviour has its own eight controls.
…solver phpcs's RequireNamedParameters sniff covers internal calls, so every hasOrganisationAccess()/readNameEnvelope() call site now names its argument, and the inline ternary in queryTableForNames() is an if-block. phpmd: $nameCacheOrganisation renamed to $nameOrganisations (LongVariable), and three reason-bearing suppressions for the growth this change causes — two constructor parameters (the same IGroupManager/IAppConfig pair MultiTenancyTrait consumes) and the added guard lines in getSingleObjectName()/batchLoadNamesFromMagicTables(). phpcs and phpmd both clean on the three changed lib files. phpstan cannot run in this worktree (its vendored quality-config points at a path that does not exist locally); CI is the measurement for phpstan and psalm.
Scoping makes getMultipleObjectNames() answer with a PARTIAL map: an identifier the caller may not see is ABSENT, never present-with-null and never an exception. That is the only behaviour change the six internal callers can observe, and the test names each one and how it already handles an absent key — ObjectsController::collectNamesForResponse, ObjectService::collectNamesForResults, ExportService::resolveUuidNameMap, MetadataHydrationHandler, PerformanceHandler and MagicFacetHandler (both its isset() single-value sites and its batch foreach).
Marks step 2 done in the repo's own improvement plan, names the controls, and states the two things that remain open rather than implying more than was fixed: the name resolver still evaluates only the ORGANISATION dimension (register/schema RBAC blocks are not consulted), and MagicMapper::rowToObjectEntity() still drops _organisation on every entity it builds — a separate defect, left alone because populating it changes PermissionHandler::filterUuidsForPermissions() behaviour.
…ays-true is_string CI phpstan (4 errors, all in CacheHandler): - the two new scope helpers used $this->userSession?->getUser(), but the property is non-nullable. phpstan.neon baselines that pattern for EXACTLY ONE occurrence in this file, so three occurrences failed the baseline itself, not just the call sites. Back to one. - getAllObjectNames()'s filter moved from ARRAY_FILTER_USE_KEY to ARRAY_FILTER_USE_BOTH, which lets phpstan read the declared key type as string and makes is_string($key) === false always-false dead code. Replaced with a cast, which also keeps the original intent for keys PHP narrowed to int. Behaviour is unchanged: 2177 tests in tests/Unit/Service/Object/ green, phpcs clean.
Contributor
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ❌ | ||||
| psalm | ✅ | ||||
| phpstan | ❌ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 528/528 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-17 07:47 UTC
Download the full PDF report from the workflow artifacts.
…phpmd
CI phpmd flagged an UnusedFormalParameter: getAllObjectNames()'s filter
took ($name, $key) under ARRAY_FILTER_USE_BOTH and only used the key.
Back to ARRAY_FILTER_USE_KEY, keeping the (string) cast that satisfied
phpstan.
CI's diff-scoped coverage ratchet then measured, honestly:
Changed files, head: 83.39% (2651/3179)
Changed files, base: 83.91% (2509/2990)
i.e. +189 statements of which 142 covered, and +47 UNCOVERED. That is not
the documented deletion-arithmetic shape and not xdebug noise — I really
did add 47 uncovered statements, and every one of them is an arm of the
function that decides who may be told a name. Covering them is warranted
on merit, not to move a number:
- resolveNameScope(): multitenancy on/off, unparseable and empty config,
no active organisation, the anonymous default-organisation fallback,
the parent hierarchy, hierarchy failure, and total failure (denies all);
- the admin override: enabled, revoked by SaaS mode, refused to a
non-admin, absent when unconfigured, never granted to an anonymous
caller, unreachable from an unparseable config or a missing app config;
- readNameEnvelope(): a pre-upgrade bare string, a null, a missing or
non-string name, a non-string organisation — all rejected;
- a stored entry with no tenancy settles nothing while scoping is on;
- getSingleObjectName(): refused from memory, and both ways round for an
object and for an organisation resolved from the database;
- batchLoadNamesFromMagicTables(): _organisation carried through and
enforced, and the no-dependencies path returning [] instead of fataling.
23 tests / 31 assertions, shown able to fail: mutating the SaaS-mode arm
from 'return false' to 'return true' reddens
testSaasModeRevokesTheAdminOverride, and the mutation was reverted.
Contributor
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ❌ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 528/528 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-17 08:00 UTC
Download the full PDF report from the workflow artifacts.
Contributor
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 175/175 | |||
| npm | ✅ | ✅ 528/528 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-17 08:27 UTC
Download the full PDF report from the workflow artifacts.
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.
What this closes
development's last failing gate:gate-7 no-admin-idor — 2 method(s)(NamesController::indexand::create). The finding is true, not a false positive, and it is closed by fixing the defect — no@no-admin-idor-exempttag, andlib/Controller/NamesController.phpis byte-identical todevelopmentapart from two staleTODO(SEC-CTRL-2)comments. The fix lives in the collaborator, which is where the leak was.This is SEC-CTRL-2 step 2, written down in the repo's own
CODE-REVIEW-IMPROVEMENT-PLAN.md. Step 1 (#2523) removedshow/stats/warmupand dropped@PublicPage; it left an authenticated cross-tenant name disclosure open.The defect, measured
CacheHandler::getMultipleObjectNames()/getAllObjectNames()resolved names through three paths with no tenant dimension at all —OrganisationMapper::findMultipleByUuid()/findAllWithUserCount()(every organisation),MagicMapper::findMultiple()→findMultipleAcrossAllMagicTables(), a rawUNION ALLover every magic table with no organisation predicate and no flag to switch on,loadNamesFromMagicTables()/queryTableForNames(), which selected_uuid, _nameand never looked at_organisation,— and then cached the answer in a process-wide map that any later caller could read, plus a distributed cache shared by every tenant on the instance.
What this does
A per-object authorisation predicate.
hasOrganisationAccess()mirrorsDb\MultiTenancyTrait::applyOrganisationFilter()exactly: multitenancy switched off, or an admin under an enabled (non-SaaS) override, is unrestricted; otherwise the caller sees the active organisation plus its parents; no active organisation means no names (the trait's1 = 0arm). An entity whose owning organisation cannot be established is refused — absence of tenancy is not permission to disclose.The magic-table queries now select
_organisation. That column is the tenancy oracle for every object-backed name, because — measured while writing this —MagicMapper::rowToObjectEntity()never populatesorganisationon the entities it builds, so filtering on$object->getOrganisation()alone would have filtered onnullfor every row read out of a magic table. Left alone deliberately: populating it would switch onPermissionHandler::filterUuidsForPermissions()'s currently-dead organisation branch, which is its own change. Recorded in the plan.The cache KEY is deliberately unchanged; the tenancy rides in the VALUE. In memory as a parallel
nameOrganisationsmap, in the distributed cache as a{n, o}envelope under the samename_<identifier>key.The dispatch's suggested mechanism was to change the key. This is better and the reason is the failure mode the previous attempt hit: prefixing the key forces every invalidation site to learn the prefix, and it orphans an entry under a stale prefix whenever an object moves organisation — a stale name servable to the old tenant forever. Keeping the key means all six invalidation sites keep matching untouched:
invalidateForObjectChange()create/updateorganisation:invalidateForObjectChange()deleteclearObjectNameFromCache()(BUG-OBJ-7)clearAllCaches()clearNameCache()persistNameCacheToDistributed()A value written before this change is a bare string with no tenancy and is read as a MISS, not served unscoped — the fail-closed direction across a deploy.
The scope is memoised for one call and dropped at the top of every public reader, so a cron worker or any long-lived process serving several callers cannot carry one caller's scope into the next.
Also fixed on the way, both reachable now:
batchLoadNamesFromMagicTables()fataled on a nullregisterMapper, andgetSingleObjectName()passed a null name into a non-nullable parameter for an entity with neither name nor uuid.RED-then-green control
tests/Unit/Service/Object/CacheHandlerTenantScopeTest.php, committed first, measured against the pre-fix implementation:The six that were RED: the object-mapper path, the organisation path, a cache warmed as tenant A served to tenant B,
getAllObjectNames()handing over the whole warm cache, the magic-table warmup ignoring_organisation, and a name with unresolvable tenancy being served anyway. The two that were GREEN throughout are functional controls (in-scope names must still resolve; an empty request answers empty) — they are what a fix that simply returned nothing would fail.Gate-7, before and after
Gate package
ConductionNL/.github@742f370e(today'smain), the gate's own helper, 139 controller files scanned on both sides:The base count reproduces CI's
gate-7 no-admin-idor — 2 method(s)exactly, which is the calibration. Positive control on the same package and tree: an unguarded#[NoAdminRequired] show(string $id)planted inlib/Controller/is reported (1 finding), removed again →0. (The first probe I planted read as clean — it called a*Mapper, which Pattern 2 exempts inside this repo. Worth knowing.)The clearing mechanism is the gate's Pattern 4:
CacheHandleris a typed collaborator ofNamesController, and its name readers now call an authorisation predicate the gate reads out ofCacheHandler's own source. The controller itself is unchanged.Regression evidence
Full unit suite, same container, same command, base vs head:
The 8 errors are the identical pre-existing ones in
IconControllerTest(3),GraphQLControllerExplorerTest(3) andWebPushControllerTest(2) — local-environment, present on the base.+8tests are the new controls.phpcsandphpmdare clean on all three changedlib/files.phpstan/psalmcannot run in a worktree here (the vendored quality-config resolves a path that does not exist locally) — CI is the measurement for those.The pre-existing
CacheHandlersuites are now pinned tomultitenancy.enabled = false— the configuration they were actually written against — and say so in a docblock. Nothing is left unmeasured: the scoped behaviour has its own nine controls.The six internal callers
Every one consumes
array<uuid, string>and already tolerates an absent key; the only change they can observe is that the map is now partial. Pinned bytestPartialMapContractHoldsForCallersThatIndexTheResult:Controller\ObjectsController::collectNamesForResponseService\ObjectService::collectNamesForResultsService\ExportService::resolveUuidNameMaparray_mergeonto a pre-seeded mapService\Object\SaveObject\MetadataHydrationHandlerempty($names[$uuid]) === false, else falls back to the UUIDService\Object\PerformanceHandlerrelatedNamesDb\MagicMapper\MagicFacetHandlerisset($names[$value])at both single-value sites (falls back to a shortened UUID); its batch siteforeaches over exactly what came backPlus the three external
setObjectName()writers inSaveObject.php, all now passingorganisation:.What this does NOT close
authorization.readwould refuse them. Closing that needs aSchemathe name cache does not hold —PermissionHandler::hasPermission()requires one — and is a separate change.getSingleObjectName()'s database lookup is still_rbac: false, _multitenancy: false. It is the ANSWER that is gated, not the query. The method has no caller inlib/; its docblock now says exactly this rather than the old "deliberately unscoped".MagicMapper::rowToObjectEntity()still drops_organisation. Recorded, not fixed.🤖 Generated with Claude Code