feat(rbac): add _rbac_as_public primitive for public-endpoint discipline (WOO-536 precursor) - #2855
feat(rbac): add _rbac_as_public primitive for public-endpoint discipline (WOO-536 precursor)#2855WilcoLouwerse wants to merge 3 commits into
Conversation
WOO-536 needs to preserve session-independent visibility on the OpenCatalogi
public search endpoint while migrating from the current _rbac:false + PHP
post-filter to OpenRegister's schema-level RBAC. Currently there is no way
for a server-side caller to say "run RBAC as if the caller is anonymous"
because MagicRbacHandler reads the live session unconditionally and admin
group + _owner grants widen the result set.
Add _rbac_as_public boolean flag (RBA-PUBLIC-001..006) that:
- forces $userId = null and $userGroups = [] at RBAC entry
- skips the admin-group bypass early-return
- suppresses the _owner OR-in condition (auto-skips via null userId)
- routes to the public-group hasGroupPermission on the PHP find() path
- is stripped from client-supplied query dicts and only accepted via
trusted server-side method parameter on ObjectService (HTTP hardening)
Threads through both search paths (buildFilteredQuery single-schema +
buildWhereConditionsSql UNION arm — also used by MagicFacetHandler, so
facets are automatically filtered) and the per-object find() path via
PermissionHandler::hasPermission.
The multi-schema UNION path needs no MagicMapper changes: the flag rides
in $searchQuery and is dict-copied to $unionQuery and $schemaCountQuery.
New OpenSpec change: openspec/changes/rbac-as-public-toggle/ (kind: code)
Unit tests: 11 tests, 16 assertions, all green (0 regressions on 82
targeted regression tests).
Follow-up captured as WOO-545 — wire the placeholder RBAC block in
MagicMapper::findInRegisterSchemaTable to the new primitive.
@SPEC openspec/changes/rbac-as-public-toggle/specs/rbac-as-public-toggle/spec.md
WilcoLouwerse
left a comment
There was a problem hiding this comment.
Verdict: REQUEST_CHANGES (Strict mode)
Well-designed primitive, tight tests, and the OpenSpec artifacts are strong. Two things need to be resolved before merge:
🔴 Blockers
1. phpcs failing — 6 missing docblock entries for the new $_rbacAsPublic / $asPublic params (CI-blocking).
New signatures gained a parameter but the corresponding @param bool $_rbacAsPublic ... line was omitted:
lib/Service/ObjectService.php:302— privatecheckPermissionlib/Service/ObjectService.php:554—find()(existing docblock has_rbac/_multitenancybut not_rbacAsPublic— see inline)lib/Service/ObjectService.php:1819—searchObjectsPaginatedlib/Service/Object/PermissionHandler.php:419—filterUuidsForPermissionslib/Db/MagicMapper/MagicSearchHandler.php:427—buildRbacConditionSql(private, docblock only lists$schema)lib/Db/MagicMapper/MagicSearchHandler.php:812—applyAccessControlFilters(5@param, 6 params)
CI log: job 97789035657.
🟡 Concerns
See inline comments:
- Multitenancy interaction silently breaks uniform-visibility unless caller also passes
_multitenancy=false—MagicOrganizationHandler::applyOrganizationFilterreads the session directly and doesn't honor_rbacAsPublic. Result: admin sees "public rows AND admin's org", anon caller with same query gets1=0(deny all). SCH-PFTS-001 collapses. [Inline comment onMagicSearchHandler.php:843] find()docblock at line 554 doesn't mention_rbacAsPublic— this is the same as Blocker #1 for that method; noting for RBA-PUBLIC-006 API-doc surface. [Inline onObjectService.php:581]- HTTP-hardening test (task 5.4) deferred — reasonable, but under Strict I'd want a 15-line unit test pinning the strip against future refactors. [Inline on
tasks.md:30]
🟢 Minor (non-blocking)
- Three spellings for one concept (
_rbac_as_public/_rbacAsPublic/asPublic). Intentional per PHP↔JSON boundary convention, but a cross-reference in the docblock of each definition would help grep. proposal.mdrefers totests/unit/(lowercase); actual location istests/Unit/. Both exist in the tree — doc-only typo.- Well-tracked
MagicMapper::findRBAC-placeholder follow-up (WOO-545) — good documentation in the inline comment atObjectService.php:591-595.
CI note
Pre-existing failures (do NOT block this PR): info.xml lint, Security (composer), Frontend Tests (unit) — reproduce on main's latest CI run (compare 32843850005 vs 31673490209). Only phpcs is PR-introduced.
Design note (for Wilco, from chat)
Answered in the chat: the "no session → public" check IS already in place at PermissionHandler.php:210-224 and your _rbacAsPublic=true early-return calls the exact same hasGroupPermission('public', ...). The flag exists because public endpoints get entered by both anonymous callers and authenticated ones (admin browsing while logged in) in the same code path — the endpoint's contract has to decide, not the caller's session state. See design.md D1 for the rejected alternatives.
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ❌ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| composer | ❌ | ✅ 148/148 | |||
| npm | ✅ | ✅ 598/598 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ❌ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ✅ | ||||
| Playwright | ⏭️ | ||||
| Hydra gates | ⏭️ |
Quality workflow — 2026-08-25 12:30 UTC
Download the full PDF report from the workflow artifacts.
Two review-driven refinements to the _rbac_as_public primitive:
1. Multitenancy interaction (RBA-PUBLIC-002, task 3.5):
MagicOrganizationHandler::applyOrganizationFilter reads the live
user session directly and does NOT honour _rbacAsPublic. Leaving
multitenancy enabled under _rbac_as_public: true therefore filters
an admin's rows to their active organisation while an anonymous
caller receives 1=0 — breaking the SCH-PFTS-001 uniform-visibility
contract. resolveMultitenancyFlag() now auto-bypasses multitenancy
under the forced-anon context, matching the pre-existing public-
schema auto-bypass. Explicit _multitenancy_explicit: true still
overrides.
2. HTTP-hardening helper extraction (task 4.1 + 5.4):
The strip-and-set pattern in ObjectService::searchObjectsPaginated
is now normalizeRbacAsPublicFlag() — a private helper that can be
unit-tested without constructing the full ObjectService dependency
graph. Prior implementation was inline in searchObjectsPaginated,
which made the security property hard to cover with a targeted
unit test.
New tests (12 additional):
- MagicSearchHandlerAsPublicTest — 5 tests covering the multi-
tenancy auto-bypass matrix (forced-anon on non-public schema,
explicit override, default preserved, public-schema bypass,
already-off case)
- ObjectServiceAsPublicTest — 7 tests covering normalizeRbacAsPublicFlag:
client-supplied flag stripped when method-param false, trusted
method-param re-sets, string-truthy client value also stripped
(no type-coercion vulnerability), empty-query round-trip
Total: 23 AsPublic tests / 31 assertions — all green.
Regression: 71 tests on companion test files — 0 regressions.
Spec-delta updated: RBA-PUBLIC-002 gains the multitenancy-bypass
scenario + acceptance criterion.
@SPEC openspec/changes/rbac-as-public-toggle/specs/rbac-as-public-toggle/spec.md#RBA-PUBLIC-002
WilcoLouwerse
left a comment
There was a problem hiding this comment.
Verdict: APPROVE (Strict mode) — self-review posted as COMMENT (GitHub blocks self-APPROVE)
Fix commit dc2cc83543 addresses all 4 findings from the prior review round:
Resolved
- 🔴 phpcs — 6 missing docblock entries. All added;
quality / PHP Quality (phpcs)now passes (1m52s). - 🟡 Multitenancy asymmetry (SCH-PFTS-001).
resolveMultitenancyFlagnow auto-bypasses multitenancy under forced-anon context when_multitenancy_explicit=false, mirroring the pre-existing public-schema bypass.MagicOrganizationHandler::applyOrganizationFilter(which reads the live session directly and does not honour_rbacAsPublic) is therefore no longer reached on the single-schema forced-anon path. UNION path was already immune (buildWhereConditionsSqldoesn't callapplyOrganizationFilter). Spec updated with RBA-PUBLIC-002 scenario + acceptance criterion. 5 new tests inMagicSearchHandlerAsPublicTest. - 🟡
find()docblock missing_rbacAsPublic. Docblock updated atObjectService.php:562— resolved as part of the phpcs fix. - 🟡 HTTP-hardening test deferral. Strip pattern extracted into
ObjectService::normalizeRbacAsPublicFlag()private helper; 7 new tests inObjectServiceAsPublicTestcover client-injection cases including a string-truthy value case (?_rbac_as_public=trueas string) to rule out type-coercion vulnerability. Tasks 5.4 and 5.6 now checked.
New findings from the fix commit
None.
Tests
- 23 AsPublic tests / 31 assertions — all green (docker exec phpunit).
- 87 regression tests on directly-affected test files (
tests/Unit/Db/MagicMapper/+tests/Unit/Service/Object/PermissionHandler*Test.php) — 0 regressions. openspec validate rbac-as-public-toggle— clean.
CI
- phpcs: PASS on new head (PR-introduced concern resolved).
- Pre-existing failures on main (
info.xml lint,Security (composer),Frontend Tests (unit)) — not introduced by this PR, no required-check overrides onmain.
Resolving the 3 prior threads.
Fold the delta spec into the main spec at openspec/specs/rbac-as-public-toggle/spec.md (6 requirements, 14 scenarios) and move the change directory to openspec/changes/archive/2026-08-25-rbac-as-public-toggle/. CHANGELOG.md entry added under Unreleased/Added describing the _rbac_as_public primitive with reference to PR #2855. Runs per the opsx-workflow discipline: sync + archive happen in-PR after approval, before merge, so canonical spec update and code change land in one mergeable unit (Fase 3 plan). Task 5.2 (applyRbacFilters QueryBuilder unit tests) archived with documented deferral — contract-verified indirectly via 5.1's admin+asPublic vs anon parity test.
Summary
OR-side precursor for WOO-536 — the OpenCatalogi architecture fix Robert Zondervan diagnosed on 2026-08-12. WOO-536 needs to preserve session-independent visibility on
GET /apps/opencatalogi/api/searchwhile migrating from the current_rbac: false+ PHP post-filter to OpenRegister's schema-level RBAC.Currently there is no way for a server-side caller to say "run RBAC as if the caller is anonymous" —
MagicRbacHandlerreads the live session unconditionally, admin group membership triggers an early bypass, and_owner = <userId>gets OR'd in whenever there's a session. On a public endpoint that means an admin browsing the site in an authenticated tab would see content that citizens can't (SCH-PFTS-001 defense collapses).This PR adds the primitive.
What this ships
New boolean flag
_rbac_as_public(RBA-PUBLIC-001..006). When set (server-side only), the RBAC engine:$userId = nulland$userGroups = []at method entry_ownerOR-in condition (auto-skips via null userId)hasGroupPermission('public', ...)on the PHPfind()pathObjectService::searchObjectsPaginated()/find()— HTTP hardeningThe flag threads through both search paths (single-schema
buildFilteredQuery+ UNION-armbuildWhereConditionsSql, which is also used byMagicFacetHandler— so facets are automatically filtered) and the per-object find path viaPermissionHandler::hasPermission.The multi-schema UNION path needs no
MagicMapperchanges: the flag rides in$searchQueryand is dict-copied to$unionQueryand$schemaCountQuery.Why not app-local?
Per ADR-022 (apps-consume-or-abstractions), the primitive lives in OR — reusable by any Conduction app that needs public-endpoint RBAC discipline (Q7 Interpretation A). The consuming app for now is OpenCatalogi (WOO-536); other public surfaces can consume the same flag.
Files changed
lib/Db/MagicMapper/MagicRbacHandler.php—buildRbacConditionsSql+applyRbacFiltersaccept$asPublic, forced-anon guardlib/Db/MagicMapper/MagicSearchHandler.php— 4 threading changes + reserved-params entrylib/Service/Object/PermissionHandler.php—hasPermission/checkPermission/filterObjectsForPermissions/filterUuidsForPermissionsaccept$_rbacAsPublic, PHP-side early-route tohasGroupPermission('public')lib/Service/ObjectService.php— HTTP hardening (strip + method-param-only set) onsearchObjectsPaginated; thread throughfind→checkPermissionOpenSpec change
openspec/changes/rbac-as-public-toggle/—kind: code, proposal + design + tasks + spec-delta (RBA-PUBLIC-001..006).openspec validategreen.Tests
MagicRbacHandlerAsPublicTest, 5 inPermissionHandlerAsPublicTesttasks.md:applyRbacFilters) — contract verified indirectly via 5.1's admin-vs-anon parity testFollow-ups
::text ILIKEportability (orthogonal, separate ticket)MagicMapper::findInRegisterSchemaTableRBAC placeholder wire-up (tech debt observed during this change)ADR anchors
kind: code, single method-family touch + testsTest plan
openspec validate rbac-as-public-togglegreenphpunit tests/Unit/Db/MagicMapper/MagicRbacHandlerAsPublicTest.php tests/Unit/Service/Object/PermissionHandlerAsPublicTest.php— 11/11 passtests/Unit/Db/MagicMapper/+tests/Unit/Service/Object/Permission*Test.php— 82/82 passbash scripts/run-hydra-gates.sh— to be run by reviewer / CI🤖 Generated with Claude Code