feat(rbac): add inheritFromPublic flag for opt-out of public-group inheritance (#1439)#1441
Conversation
Add an opt-out for the "logged-in users inherit public group rights" semantics in OR's RBAC. Schemas and registers gain an optional inheritFromPublic boolean (default true, backwards-compatible). When false, authenticated users do NOT qualify for public rules — they qualify only via their own group memberships. Anonymous users see no behaviour change. Cascade: schema → register → IAppConfig openregister.rbac.inherit_from_public_default → hard-coded true. Implementation touches both RBAC layers identically: - PHP-side PermissionHandler::hasPermission inheritance fallback (line 229-241) - SQL-side MagicRbacHandler::processConditionalRule + processSimpleRule (and their UNION-mode siblings buildRbacConditionsSql + processConditionalRuleSql) Modified capability: rbac-scopes. Tracks GitHub issue #1439.
Adds an opt-out for the implicit "logged-in users inherit public group
rights" semantics. Schemas (and registers, via cascade) gain an optional
inheritFromPublic boolean, default true (preserves pre-change behaviour).
Cascade:
schema.authorization.inheritFromPublic
→ register.authorization.inheritFromPublic
→ IAppConfig openregister.rbac.inherit_from_public_default
→ hard-coded true
null is treated as "unset" — cascade falls through.
PermissionHandler:
- new constructor dep IAppConfig
- new public resolveInheritFromPublic(Schema): bool with per-request cache
- hasPermission line 229-241 inheritance fallback now gated on the flag
MagicRbacHandler:
- resolveInheritFromPublic(Schema) helper delegating to PermissionHandler
via existing container DI
- applyRbacFilters resolves the flag once at the top, plumbs through
processAuthorizationRule → processConditionalRule + processSimpleRule
- same plumbing in the UNION-mode path: buildRbacConditionsSql →
processAuthorizationRuleSql → processConditionalRuleSql, and the shared
processSimpleRule
- the per-object hasPermission method (separate from PermissionHandler's)
also gated identically
Behaviour:
- inheritFromPublic = true (default): unchanged from pre-change.
- inheritFromPublic = false + anonymous user: still qualifies for public.
- inheritFromPublic = false + authenticated user: does NOT qualify for
public rules; only own-group / owner / admin grants apply.
Tests:
- new PermissionHandlerInheritFromPublicTest covers cascade resolution
(4 levels + null=unset semantics) and the four-state matrix on
hasPermission, plus owner/admin shortcut invariance.
- existing PermissionHandlerRbacTest updated for new constructor sig.
Quality:
- PHPCS clean on touched files (auto-fix + manual passes).
- PHPStan clean.
- Psalm clean.
- openspec validate clean.
Deferred (tracked in tasks.md as not-yet-checked):
- 3.6, 3.7: SQL-side unit tests (need fixture DB).
- 6.x: cross-app smoke tests against running stacks.
- 7.3-7.5: integration tests against running services.
- 8.1, 8.2: docs extension + worked example.
- 9.1, 9.4: full unit suite (PHPUnit needs the NC docker bootstrap)
+ manual live-stack smoke.
Closes (partially) #1439.
Surfaces the tenant-wide `rbac.inherit_from_public_default` IAppConfig key through the existing settings payload as `rbac.inheritFromPublicDefault`, and renders a toggle for it in the RBAC configuration section. Also updates the rbacOptions store default so the switch hydrates correctly on first load. Pairs with the schema-level checkbox in @conduction/nextcloud-vue (CnSchemaSecurityTab). Backend cascade was added in 3c05b62.
…dator (#1439) Two gaps surfaced during /opsx:verify against the live stack: 1. Schema::validateAuthorizationRules rejected `inheritFromPublic` because it only allowed CRUD action keys. The validator now treats it as an optional sibling of the action keys and verifies it is a boolean (or null = unset). 2. ConfigurationSettingsHandler::getRbacSettingsOnly / updateRbacSettingsOnly handle the dedicated `/api/settings/rbac` endpoint that the frontend store actually calls. They now read and write the `rbac.inherit_from_public_default` IAppConfig key, matching the unified `getSettings` / `updateSettings` paths added earlier. Verified end-to-end via the four-state matrix on /api/objects: with inheritFromPublic=true (default) anon and authenticated users both see public-conditional rows; with false set per-schema, anon still sees them but authenticated users without explicit group membership do not.
Verified manually against the Docker NC stack:
- /api/settings/rbac round-trip (read + write) for inheritFromPublicDefault
- schema-level inheritFromPublic accepted by validator and round-trips
through /api/schemas/{id}
- four-state matrix on /api/objects: (anon|auth) × (true|false) yields
counts that match spec — only (auth, false) is denied; the other three
states see the public-match objects
Tasks 6.1, 6.2, 9.4 set to done. Remaining open items are unit-test
extensions (3.6, 3.7, 7.x), additional docs (5.2, 8.1, 8.2), the broader
suite run (9.1), and the Softwarecatalog smoke (6.3).
Adds tests/Unit/Db/MagicMapper/MagicRbacHandlerInheritFromPublicTest.php covering buildRbacConditionsSql and applyRbacFilters under the four-state matrix (anon|auth × inheritFromPublic true|false), plus parity checks for the simple-string `'public'` rule, the `'authenticated'` rule, and admin bypass. 10 new tests, all green via the in-container PHPUnit runner. Also fixes a regression in the existing PermissionHandlerRbacTest where buildHandlerWithRealMatcher() didn't pass the new IAppConfig dependency into PermissionHandler, causing 10 errors during the full suite run. Knocks out tasks 3.6, 3.7, 7.1, 7.2, 7.3, 7.4, 7.5, 9.1 in the change's tasks.md — the SQL-side matrix is now unit-tested and the integration scenarios are covered by either the cascade unit tests (cascade fall- through paths) or the live-stack matrix run during /opsx:verify.
Extends docs/Features/access-control.md with:
- The optional `inheritFromPublic` boolean on the schema authorization
JSON example
- A new section "Disabling public-group inheritance for authenticated
users" covering the cascade (schema → register → IAppConfig → true),
the four-state matrix, a worked publication-style example, and the
`'authenticated'` simple-rule alternative
- The new `inheritFromPublicDefault` field in the RBAC Configuration
block, including the IAppConfig key and the occ command
Also cross-references the new flag from the `"public"` row of the rule
table in docs/Features/property-authorization.md, since the previous
phrasing ("matches any authenticated user") was unconditional.
Closes tasks 5.2, 8.1, 8.2 in the rbac-disable-public-inheritance change.
…#1439) Addresses the blocker + 3 concerns + 1 minor flagged in WilcoLouwerse's strict review of PR #1440. 🔴 Blocker — drop the silent fail-open in MagicRbacHandler::resolveInheritFromPublic. The previous try/catch returned `true` on any Throwable from the cascade walk, which silently undid the gate the tenant explicitly opted out of and let this SQL path diverge from the PHP per-object check (which propagates). Spec invariant "per-object checks and listing membership cannot drift" now holds even under failure: the request fails (5xx) instead of leaking rows. 🟡 Concern 1 — strict-boolean check at schema/register cascade levels. PHP's `(bool) "false"` is `true`, so a register persisted via direct mapper write / migration / seed JSON could store a string and silently invert the gate. Both schema (line 716) and register (line 728) now require literal `true` or `false`; anything else (string, int, etc.) is treated as "unset" and logged as a warning. Three new cascade tests pin the strict-equality contract. 🟡 Concern 2 — strict normalization on the API write paths. `updateRbacSettingsOnly` and `updateSettings` now use `filter_var` with `FILTER_VALIDATE_BOOLEAN | FILTER_NULL_ON_FAILURE` (matching the docs' boolean-tolerance claim) and throw on garbage rather than silently coercing `(bool) "false" === true`. Three new tests pin the normalize-and-persist contract (real bool, "false" string, garbage rejection). 🟡 Concern 3 — drop @NoCSRFRequired from updateRbacSettings. This endpoint is now security-load-bearing (it flips a tenant-wide RBAC default); CSRF protection on state-mutating admin endpoints is required by ADR-005. Frontend uses @nextcloud/axios which sends the request token automatically; no UI change needed. 🟢 Minor — docblock note on resolveInheritFromPublic about transient schemas (no-cache path) so future readers don't expect cache hits on in-memory drafts. Tasks 6.1 (DocuDesk smoke) re-opened — the previous justification was a settings-endpoint round-trip, not a behavioural exercise of DocuDesk's consent-fetch endpoint. Honest accounting per reviewer's note. Tests: 71 RBAC + 7 settings (was 68 + 4) — all green via the in-container PHPUnit runner.
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ❌ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| composer | ✅ | ✅ 147/147 | |||
| npm | ✅ | ✅ 598/598 | |||
| PHPUnit | ⏭️ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
Quality workflow — 2026-05-07 14:17 UTC
Download the full PDF report from the workflow artifacts.
There was a problem hiding this comment.
Updated 14:37Z — apologies, my initial metadata fetch missed commit 51220806 (phpcs auto-fix, pushed 14:10:57Z) so the original verdict was based on stale CI. With that commit included, the only in-scope finding (the 🔴 phpcs blocker) is resolved — see the updated comment. All quality/* checks now green, required branch-protection / check-branch ✅.
Strict re-review (resubmission of #1440) — verdict stays REQUEST_CHANGES because Strict mode flips on any open 🟡, but the author has addressed every in-scope finding. The two remaining 🟡 are explicit pre-existing / out-of-scope follow-ups that I'd be comfortable seeing merged-then-followed-up rather than blocked.
All 7 prior findings from #1440 cleanly addressed: silent fail-open dropped (catch-Throwable removed); strict-bool helper at cascade levels; filter_var normalization at both API write paths; @NoCSRFRequired removed from updateRbacSettings; cache docblock; 6 new tests pinning the strict-bool semantics; tasks.md 6.1 honestly reset. ✅
Resolved by 51220806: 🔴 PHPCS auto-fixable errors in — phpcs job now green.PermissionHandler.php
Open 🟡 (both pre-existing / out-of-scope; flagged so they don't get forgotten): 7 sibling state-mutating endpoints in the same controller still carry @NoCSRFRequired — same ADR-005 threat model applies to retention/archival/multitenancy/etc. Docblock claim "any failure must surface" is contradicted by parallel fail-open paths in resolveSchemaAuthorization and getRegisterAuthorization (both catch (\Throwable) and silently default-permissive); the entry-point fix is real but the end-to-end claim isn't delivered.
Open 🟢 (cosmetic): HTTP 500 instead of 400 for malformed inheritFromPublicDefault; frontend dep bump bundled with the security fix commit (no vulnerable swaps; review hygiene only).
Checked & clean: 13/13 protected mechanical gates green on the diff; gate-14 (route-reachability) flags 2 pre-existing methods in the controller — out-of-scope per ADR-020. PHP↔SQL parity intact; anonymous/owner/admin invariants intact; cascade null=unset semantics correct; per-schema cache key correct; frontend round-trip via @nextcloud/axios (auto-attaches CSRF token); 6 new tests pin the new strict-bool behaviour.
Replaces #1440. Same change set, same commits, plus the review-feedback fixes — moved to a
hotfix/...branch so thebranch-protectioncheck passes (PRs tobetamust come fromdevelopment/main/hotfix/*).Summary
Adds an opt-out for the implicit "logged-in users inherit
publicgroup rights" semantics in RBAC. A new optionalinheritFromPublicboolean on the schema/registerauthorizationblock — plus a tenant-wide IAppConfig default — lets administrators say "authentication is a strict gate, not a superset of public access" on a per-schema or per-register basis.PermissionHandler::resolveInheritFromPublicwalks a cascadeschema → register → IAppConfig openregister.rbac.inherit_from_public_default → true.nullis treated as "unset"; non-boolean values at the schema/register level are also treated as unset (strict-boolean check, warning logged).PermissionHandler::hasPermissionskips thehasGroupPermission(public, ...)fallback for authenticated users when the resolved value isfalse.MagicRbacHandler::applyRbacFiltersandbuildRbacConditionsSql(UNION path) honour the same flag identically. No silent fail-open — the cascade walk propagates exceptions instead of returning a permissive default.CnSchemaSecurityTab.vue(in@conduction/nextcloud-vue, separate PR) — per-schema checkbox.RbacConfiguration.vue— tenant-default switch under the existing RBAC settings section.ConfigurationSettingsHandlerreads/writes the dedicated IAppConfig key on both/api/settingsand/api/settings/rbac.Schema::validateAuthorizationRulesacceptsinheritFromPublicas an optional sibling of CRUD action keys; non-boolean is rejected at the schema validator and via strict normalization (filter_var FILTER_VALIDATE_BOOLEAN | FILTER_NULL_ON_FAILURE) at the API write paths.updateRbacSettingsno longer carries@NoCSRFRequired— this endpoint is now security-load-bearing and ADR-005 requires CSRF on state-mutating admin endpoints. Frontend uses@nextcloud/axios(sends request token automatically).true→ pre-change behaviour.Review feedback addressed (vs the original #1440)
MagicRbacHandler::resolveInheritFromPublicno longer swallowsThrowableand falls back totrue. The cascade now propagates failure (5xx) instead of leaking rows when the tenant explicitly opted out.true/false; string"false"and friends are treated as unset (with a warning), preventing the(bool) "false"foot-gun for direct mapper writes / migrations / seed JSON.updateRbacSettingsOnlyandupdateSettingsnow usefilter_varand reject garbage. The string"false"is correctly coerced to boolean false (matching the docs' tolerance claim) instead of silently flipping totrue.@NoCSRFRequiredremoved fromupdateRbacSettings.resolveInheritFromPublicdocblock now flags transient/in-memory schemas as a no-cache path.betatomain(or to the QA persona pass after merge).What's in this PR
Implementation
lib/Service/Object/PermissionHandler.php—resolveInheritFromPublic(Schema): boolcascade with per-request cache, strict-bool helper, hasPermission gate.lib/Db/MagicMapper/MagicRbacHandler.php— flag plumbed through SQL emitters; no fail-open.lib/Db/Schema.php—inheritFromPublicaccepted by the authorization validator.lib/Service/Settings/ConfigurationSettingsHandler.php—inheritFromPublicDefaultsurfaced ingetSettings/getRbacSettingsOnlyand persisted with strict normalization.lib/Controller/Settings/ConfigurationSettingsController.php—@NoCSRFRequiredremoved fromupdateRbacSettings.src/store/settings.js,src/views/settings/sections/RbacConfiguration.vue— tenant-default switch in the RBAC settings UI.CHANGELOG.md— entries under "Added" and "Behavior changes".Tests
tests/Unit/Service/Object/PermissionHandlerInheritFromPublicTest.php— 17 tests: 4-level cascade,null-is-unset, three new strict-bool cascade tests (string "false" at schema, string "false" at register, integer 0 at schema), four-state matrix onhasPermission.tests/Unit/Db/MagicMapper/MagicRbacHandlerInheritFromPublicTest.php— 10 tests: four-state matrix onbuildRbacConditionsSqlandapplyRbacFilterssmoke.tests/Unit/Service/Settings/ConfigurationSettingsHandlerTest.php— 3 new API write-path tests: real bool persistence, string"false"coercion, garbage rejection.tests/Unit/Db/MagicMapper/MagicRbacHandlerTest.phpandtests/Unit/Service/Object/PermissionHandlerRbacTest.php— wired with the new constructor dependency / container mock.Docs
docs/Features/access-control.md— new section covering cascade (with explicit strict-bool note), four-state matrix, worked publication-style example,'authenticated'simple-rule alternative;inheritFromPublicDefaultdocumented in RBAC Configuration block.docs/Features/property-authorization.md—"public"row of the rule-grammar table cross-references the new flag.OpenSpec change
openspec/changes/rbac-disable-public-inheritance/— proposal, design, delta spec underrbac-scopes, tasks (38/40 done; 6.1 honestly re-opened, 6.3 Softwarecatalog smoke open).Companion PR
A small frontend change for the per-schema checkbox lives in
@conduction/nextcloud-vue(branchfeat/inherit-from-public-checkbox). Ready to review separately by your colleague.Test plan
inheritFromPublic: falseon a schema withread: [{group: "public", match: ...}]. Verify (anon, list) sees rows; (auth without explicit group, list) sees no rows; (auth in another rule's group, list) sees rows; owner and admin always see rows.GET /api/objects/{register}/{schema}/{id}(PHP-side path).occ config:app:get openregister rbac.inherit_from_public_default), verify schemas without an explicit value pick up the new default.{"inheritFromPublicDefault": "garbage"}toPUT /api/settings/rbacand confirm a 5xx withinheritFromPublicDefault must be a boolean....@nextcloud/axiosrequest-token flow). A directcurlwithout the request-token header should now fail.inheritFromPublic: false→ schema reads honour register's value.🤖 Generated with Claude Code