fix: unify RBAC condition matching onto ConditionMatcher (#1336)#1341
Conversation
Collapse PermissionHandler's equality-only evaluateMatchConditions and
MagicRbacHandler's private PHP-side matcher onto the shared ConditionMatcher
service, so schema-level RBAC speaks the same operator and dynamic-variable
grammar as the SQL row-level filter and property-level RBAC. Fixes three
list-vs-find parity bugs surfaced by OpenCatalogi PublicationsController:
1. Schema-level matcher was equality-only and knew only $organisation.
Rules using operators ($lte/$gte/$in/...) or $userId/$now were silently
rejected on find while working on list. Delete evaluateMatchConditions;
inject and delegate to ConditionMatcher (used already by
PropertyRbacHandler). Same pattern applied to MagicRbacHandler's PHP-side
hasPermission; 7 private helpers removed. SQL emitter (applyRbacFilters)
untouched.
2. OperatorEvaluator used raw PHP <=/>=/</> and in_array without null
guards. PHP coerced null to ""/0, so null $lte "<datetime>" returned
true even though SQL NULL <= X correctly filtered the row. Add null
guards to $gt/$gte/$lt/$lte (false if either side null), $in (false
if value null), $nin/$ne (false if value null). $eq: null and $exists
unchanged.
3. $now emitted different string formats in the two evaluators
(ConditionMatcher ISO 8601 'c', MagicRbacHandler Y-m-d H:i:s). For
text/JSON-stored date columns the raw lex comparison diverged at the
T-vs-space position. Align ConditionMatcher to Y-m-d H:i:s (matches
DateTimeNormalizer's input format).
Tests: 102 tests / 148 assertions across OperatorEvaluatorTest (+11 null
cases), PermissionHandlerRbacTest (+14 delegation and real-wired
scenarios including the composite 'publicatiedatum/depublicatiedatum'
pattern), new MagicRbacHandlerTest (14), and unchanged ConditionMatcherTest
(21). composer phpcs/phpmd/psalm/phpstan clean for all changed files.
Integration tests in ObjectHandlersIntegrationTest.php that exercised the
deleted evaluateMatchConditions are removed; their coverage lives in
ConditionMatcherTest and the new delegation tests.
Out of scope (flagged in proposal.md and as follow-ups):
- Schema.php has a 4th duplicate with test coverage but zero production
callers; separate cleanup.
- OpenCatalogi's PublicationsController::show _rbac:false flag, double-
find in ::attachments, FileService::getFiles RBAC integration, and
the 'User Anonymous does not have permission' exception wording are
consumer-side concerns handled elsewhere.
|
Critical issue:
|
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| composer | ✅ | ✅ 147/147 | |||
| npm | ✅ | ✅ 599/599 | |||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ⏭️ |
Quality workflow — 2026-04-24 09:22 UTC
Download the full PDF report from the workflow artifacts.
WilcoLouwerse
left a comment
There was a problem hiding this comment.
Two merge blockers (resolved-relation unwrapping regression and incomplete $in null guard) need to be addressed. The consolidation direction is correct, the SQL emitter is verified untouched, and the admin/owner short-circuits and envelope fold all check out. Fix the two blockers and this is ready to ship.
Responds to review feedback on the RBAC unification change:
1. Resolved-relation unwrapping (Wilco, blocker): the deleted
PermissionHandler::evaluateMatchConditions had a fallback that
unwrapped array values with an `id` key to the scalar id. The new
ConditionMatcher path had no equivalent, so rules like
{"match": {"parent": "uuid-123"}} flipped from allow to deny against
schemas with expanded relations. Added unwrapResolvedRelation() to
ConditionMatcher and call it in singleConditionMatches before any
comparison branch. Arrays without an `id` key pass through unchanged.
2. Unknown operator fail-closed (bbrands02, critical): OperatorEvaluator's
default switch arm returned true on unrecognised operators, granting
access on malformed rules while the SQL path correctly denied. Now
returns false and logs a warning, aligning the two paths.
3. $in null guard (Wilco, blocker): the PR claimed `$in/$nin/$ne` return
false for null object values, but operatorIn had been left untouched —
in_array(null, [null, 'x'], true) returns true in PHP while SQL
`NULL IN (NULL, 'x')` returns NULL (filtered out). Added explicit null
guard to operatorIn.
4. Schema::hasPermission and Schema::evaluateMatchConditions now carry
@deprecated annotations pointing to the canonical
PermissionHandler/MagicRbacHandler pair. The methods have no production
callers but extensive test coverage via BasicCrudTest/RbacTest; the
annotations make the §3.7 follow-up visible in-file.
5. PermissionHandler::hasGroupPermission gained an explicit comment on the
`+` array-union precedence in the envelope fold (existing @self wins
over the separately-passed $objectOrganisation — matches pre-unification
behaviour).
6. MagicRbacHandler class docblock tightened: distinguishes local string-rule
dispatch (kept in-class, no operator vocabulary needed) from the
delegated match:-branch evaluation (must live in ConditionMatcher /
OperatorEvaluator going forward).
Tests:
- OperatorEvaluatorTest: flipped testUnknownOperatorReturns* from true
to false; added testInAgainstNullValueWithNullInOperandArrayStillReturnsFalse.
- ConditionMatcherTest: added three tests for resolved-relation unwrapping
(match, mismatch, plain-array non-unwrap).
- PermissionHandlerRbacTest: added two real-wired end-to-end tests
(testResolvedRelationUnwrappingViaRealConditionMatcher and
testUnknownOperatorFailsClosedViaRealConditionMatcher) that exercise
the full handler-to-OperatorEvaluator stack without mocks.
- Refactor: extracted unwrapResolvedRelation helper to keep
singleConditionMatches under PHPMD's NPath/cyclomatic thresholds.
Spec delta: added scenarios for resolved-relation unwrapping, unknown-
operator fail-closed, and a new requirement "Divergences from strict SQL
three-valued logic (documented)" with scenarios for `$eq: null` (escape
hatch) and `$ne: null` (PHP-sensible, SQL-denies-all) so consumers know
where the contract intentionally parts ways.
CHANGELOG: three new bullets covering the three substantive code fixes
(unwrap, unknown-operator, $in null). Aligns the user-facing notes with
what actually ships.
All RBAC tests green (108 tests / 155 assertions). composer phpcs/phpmd
clean for touched files.
Review responseThanks for the sharp review — addressed in 🔴 Blockers — fixed1. Resolved-relation unwrapping (Wilco) 2. 3. Unknown operator fail-closed (bbrands02) 🟡 Non-blocking concerns — addressed
Not touched (pre-existing, flagged by reviewer as follow-up)
CHANGELOGThree new bullets under Unreleased/Fixed covering the substantive code fixes (unwrap, unknown-operator, QualityAll RBAC tests green: 108 tests / 155 assertions. Ready for another look. 🙏 |
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| composer | ✅ | ✅ 147/147 | |||
| npm | ✅ | ✅ 599/599 | |||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ⏭️ |
Quality workflow — 2026-04-24 10:06 UTC
Download the full PDF report from the workflow artifacts.
WilcoLouwerse
left a comment
There was a problem hiding this comment.
Both blockers addressed — unwrapResolvedRelation() in ConditionMatcher and the operatorIn null guard are in and tested. The $ne: null SQL divergence is documented in the spec rather than code-fixed, which is the right call given $exists is the canonical operator. @deprecated annotations added to Schema::hasPermission/evaluateMatchConditions, the + union precedence comment is in place, and the MagicRbacHandler docblock is accurate. 108 tests / 155 assertions green. Ready to merge.
Summary
PermissionHandler::evaluateMatchConditionsandMagicRbacHandler's private PHP-side matcher onto the sharedConditionMatcherservice, so schema-level RBAC now honours the full operator set ($eq/$ne/$gt/$gte/$lt/$lte/$in/$nin/$exists) and dynamic variables ($organisation/$userId/$now) that the SQL row-level filter and property-level RBAC already supported.PublicationsController::attachmentsreport.rbac-scopesspec already required but the schema-level implementation was not delivering.Refs #1336.
What changed
1. Schema-level matcher unified onto
ConditionMatcherPermissionHandler::evaluateMatchConditions()deleted;hasGroupPermission()now delegates via an@self.organisationenvelope fold.hasPermission()no longer fetchesactiveOrganisationseparately —ConditionMatcherresolves$organisationitself.MagicRbacHandler::hasPermission()rewritten to delegate; 7 private PHP-side helpers removed (objectMatchesConditions,objectPropertyMatchesCondition,valueMatchesOperator,singleOperatorMatches,comparisonOperatorMatches,arrayOperatorMatches,existsOperatorMatches). SQL emitter path (applyRbacFilters,buildRbacConditionsSql,buildMatchConditions,buildPropertyCondition, ...) untouched.MagicMapper::initializeHandlersresolvesConditionMatcherfrom the container when constructingMagicRbacHandler.2. Null-valued properties follow SQL three-valued logic
OperatorEvaluatorused raw PHP<=/>=/</>without null guards.null <= "<datetime>"in PHP returned true (coercesnullto"", which is lex-less than any non-empty string), while SQLNULL <= Xcorrectly returned NULL → filtered out. Now:$gt/$gte/$lt/$ltefalseif either side is null$infalseif value is null$ninfalseif value is null (wastrue)$newith non-null operandfalseif value is null (wastrue)$eq: null$exists3.
$nowaligned to SQL-native formatConditionMatcher::resolveDynamicValuenow emitsY-m-d H:i:s(was'c'=2026-04-24T14:43:49+00:00). For text/JSON columns storing dates, the raw lex comparison diverged at theT-vs-space position, causing list/find drift. Aligned toMagicRbacHandler's format, which also matchesDateTimeNormalizer's input format.Out of scope (follow-up work)
Schema::hasPermission()/Schema::evaluateMatchConditions()inlib/Db/Schema.phpis a fourth duplicate of the same equality-only matcher. It has test coverage inBasicCrudTest.phpbut zero production callers — logged as §3.7 intasks.mdfor a separate cleanup.PublicationsController::show _rbac:false, double-find in::attachments,FileService::getFilesRBAC integration, and the "User 'Anonymous' does not have permission" exception wording are consumer-side concerns — will be handled in separate PRs againstopencatalogi.Test plan
PermissionHandlerRbacTest24 tests including 14 new ones (10 delegation tests with mockedConditionMatcher+ 4 real-wired scenarios using realConditionMatcher+OperatorEvaluator).MagicRbacHandlerTestwith 14 tests covering admin/owner bypass, simple rules, conditional rules,$in,$userId, and delegation shape.OperatorEvaluatorTest+11 null-handling tests (6 started as failing, all 39 now pass).ConditionMatcherTestunchanged, 21 tests still passing.tests/Service/ObjectHandlersIntegrationTest.phphad 5 tests calling the removedevaluateMatchConditions; removed and replaced with a pointer comment.composer phpcs— clean (auto-fixed 8, 2 manual docblock shortenings).composer phpmd— clean for changed files (added@SuppressWarnings(PHPMD.NPathComplexity)onMagicRbacHandler::hasPermission— inlined rule dispatch intentionally).composer psalm— clean for changed files (48 unrelated pre-existingDeletionAnalysiserrors remain).composer phpstan— clean for changed files.PublicationsController::attachmentswith a publication schema using{ "read": [{ "group": "public", "match": { "publishedAt": { "$lte": "$now" } } }] }: past-dated publication with attachment returns 200; future-dated returns 404 (not 500 with "User 'Anonymous'");publishedAt: nullreturns 404.publicatiedatum+depublicatiedatumrule (covered bytestCompositePublishedAndNotDepublishedRuleas an end-to-end PHP test, but worth running against a real database for list-find parity).localhost:3030) with a logged-in user querying a schema using$userIdmatch: results user-scoped in both list and detail views.🤖 Generated with Claude Code