Skip to content

feat(rbac): add _rbac_as_public primitive for public-endpoint discipline (WOO-536 precursor) - #2855

Open
WilcoLouwerse wants to merge 3 commits into
mainfrom
hotfix/woo-536-fts-catalog-model
Open

feat(rbac): add _rbac_as_public primitive for public-endpoint discipline (WOO-536 precursor)#2855
WilcoLouwerse wants to merge 3 commits into
mainfrom
hotfix/woo-536-fts-catalog-model

Conversation

@WilcoLouwerse

@WilcoLouwerse WilcoLouwerse commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

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/search 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" — MagicRbacHandler reads 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:

  • forces $userId = null and $userGroups = [] at method entry
  • skips the admin-group bypass early-return
  • suppresses the _owner OR-in condition (auto-skips via null userId)
  • routes to hasGroupPermission('public', ...) on the PHP find() path
  • is stripped from client-supplied query dicts and only accepted via trusted server-side method parameter on ObjectService::searchObjectsPaginated() / find() — HTTP hardening

The flag threads through both search paths (single-schema buildFilteredQuery + UNION-arm buildWhereConditionsSql, which is 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.

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.phpbuildRbacConditionsSql + applyRbacFilters accept $asPublic, forced-anon guard
  • lib/Db/MagicMapper/MagicSearchHandler.php — 4 threading changes + reserved-params entry
  • lib/Service/Object/PermissionHandler.phphasPermission / checkPermission / filterObjectsForPermissions / filterUuidsForPermissions accept $_rbacAsPublic, PHP-side early-route to hasGroupPermission('public')
  • lib/Service/ObjectService.php — HTTP hardening (strip + method-param-only set) on searchObjectsPaginated; thread through findcheckPermission

OpenSpec change

openspec/changes/rbac-as-public-toggle/kind: code, proposal + design + tasks + spec-delta (RBA-PUBLIC-001..006). openspec validate green.

Tests

  • 11 new unit tests (16 assertions) — 6 in MagicRbacHandlerAsPublicTest, 5 in PermissionHandlerAsPublicTest
  • Regression: 82 tests on the modified files' companion test files — 0 regressions
  • 2 tests deferred with rationale in tasks.md:
    • 5.2 (QueryBuilder path unit tests for applyRbacFilters) — contract verified indirectly via 5.1's admin-vs-anon parity test
    • 5.4 (HTTP-hardening unit test) — 5-line strip pattern is deterministic + inline; integration coverage comes via the OC WOO-536 PR smoke tests

Follow-ups

  • WOO-536 — the OpenCatalogi PR that consumes this primitive (opens after this one merges)
  • WOO-544 — MariaDB ::text ILIKE portability (orthogonal, separate ticket)
  • WOO-545MagicMapper::findInRegisterSchemaTable RBAC placeholder wire-up (tech debt observed during this change)

ADR anchors

  • ADR-005 (security) — public endpoints have different contracts than admin surfaces
  • ADR-022 (apps-consume-or-abstractions) — new primitive lives in OR, consumed by apps
  • ADR-023 (action-authorization) — per-request scope-override of the action-authorization computation
  • ADR-032 (spec-sizing) — kind: code, single method-family touch + tests

Test plan

  • openspec validate rbac-as-public-toggle green
  • phpunit tests/Unit/Db/MagicMapper/MagicRbacHandlerAsPublicTest.php tests/Unit/Service/Object/PermissionHandlerAsPublicTest.php — 11/11 pass
  • Regression on tests/Unit/Db/MagicMapper/ + tests/Unit/Service/Object/Permission*Test.php — 82/82 pass
  • bash scripts/run-hydra-gates.sh — to be run by reviewer / CI
  • Integration test with OC WOO-536 PR — verified end-to-end after both PRs green

🤖 Generated with Claude Code

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 WilcoLouwerse left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

CI log: job 97789035657.

🟡 Concerns

See inline comments:

  • Multitenancy interaction silently breaks uniform-visibility unless caller also passes _multitenancy=falseMagicOrganizationHandler::applyOrganizationFilter reads the session directly and doesn't honor _rbacAsPublic. Result: admin sees "public rows AND admin's org", anon caller with same query gets 1=0 (deny all). SCH-PFTS-001 collapses. [Inline comment on MagicSearchHandler.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 on ObjectService.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.md refers to tests/unit/ (lowercase); actual location is tests/Unit/. Both exist in the tree — doc-only typo.
  • Well-tracked MagicMapper::find RBAC-placeholder follow-up (WOO-545) — good documentation in the inline comment at ObjectService.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.

Comment thread lib/Db/MagicMapper/MagicSearchHandler.php
Comment thread lib/Service/ObjectService.php
Comment thread openspec/changes/rbac-as-public-toggle/tasks.md Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openregister @ c560b76

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 WilcoLouwerse left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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). resolveMultitenancyFlag now 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 (buildWhereConditionsSql doesn't call applyOrganizationFilter). Spec updated with RBA-PUBLIC-002 scenario + acceptance criterion. 5 new tests in MagicSearchHandlerAsPublicTest.
  • 🟡 find() docblock missing _rbacAsPublic. Docblock updated at ObjectService.php:562 — resolved as part of the phpcs fix.
  • 🟡 HTTP-hardening test deferral. Strip pattern extracted into ObjectService::normalizeRbacAsPublicFlag() private helper; 7 new tests in ObjectServiceAsPublicTest cover client-injection cases including a string-truthy value case (?_rbac_as_public=true as 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 on main.

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.
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