Skip to content

feat(rbac): add inheritFromPublic flag for opt-out of public-group inheritance (#1439)#1441

Merged
rjzondervan merged 11 commits into
betafrom
hotfix/1439/rbac-disable-public-inheritance
May 7, 2026
Merged

feat(rbac): add inheritFromPublic flag for opt-out of public-group inheritance (#1439)#1441
rjzondervan merged 11 commits into
betafrom
hotfix/1439/rbac-disable-public-inheritance

Conversation

@rjzondervan
Copy link
Copy Markdown
Member

@rjzondervan rjzondervan commented May 7, 2026

Replaces #1440. Same change set, same commits, plus the review-feedback fixes — moved to a hotfix/... branch so the branch-protection check passes (PRs to beta must come from development/main/hotfix/*).

Summary

Adds an opt-out for the implicit "logged-in users inherit public group rights" semantics in RBAC. A new optional inheritFromPublic boolean on the schema/register authorization block — 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.

  • PHP-side: PermissionHandler::resolveInheritFromPublic walks a cascade schema → register → IAppConfig openregister.rbac.inherit_from_public_default → true. null is treated as "unset"; non-boolean values at the schema/register level are also treated as unset (strict-boolean check, warning logged).
  • PHP-side enforcement: PermissionHandler::hasPermission skips the hasGroupPermission(public, ...) fallback for authenticated users when the resolved value is false.
  • SQL-side enforcement: MagicRbacHandler::applyRbacFilters and buildRbacConditionsSql (UNION path) honour the same flag identically. No silent fail-open — the cascade walk propagates exceptions instead of returning a permissive default.
  • Frontend:
    • CnSchemaSecurityTab.vue (in @conduction/nextcloud-vue, separate PR) — per-schema checkbox.
    • RbacConfiguration.vue — tenant-default switch under the existing RBAC settings section.
    • ConfigurationSettingsHandler reads/writes the dedicated IAppConfig key on both /api/settings and /api/settings/rbac.
  • Validator: Schema::validateAuthorizationRules accepts inheritFromPublic as 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.
  • CSRF: updateRbacSettings no 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).
  • Defaults stay backwards-compatible: unset everywhere → resolves to true → pre-change behaviour.

Review feedback addressed (vs the original #1440)

  • 🔴 Blocker — silent fail-open dropped. MagicRbacHandler::resolveInheritFromPublic no longer swallows Throwable and falls back to true. The cascade now propagates failure (5xx) instead of leaking rows when the tenant explicitly opted out.
  • 🟡 Strict-bool at cascade levels. Schema- and register-level reads now require literal 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.
  • 🟡 Strict normalization at API write paths. updateRbacSettingsOnly and updateSettings now use filter_var and reject garbage. The string "false" is correctly coerced to boolean false (matching the docs' tolerance claim) instead of silently flipping to true.
  • 🟡 CSRF. @NoCSRFRequired removed from updateRbacSettings.
  • 🟢 Cache docblock. resolveInheritFromPublic docblock now flags transient/in-memory schemas as a no-cache path.
  • 🟢 Tasks 6.1 honest accounting. Re-opened — was marked done on a settings-endpoint round-trip; the actual DocuDesk consent-flow smoke is deferred to before promoting from beta to main (or to the QA persona pass after merge).

What's in this PR

Implementation

  • lib/Service/Object/PermissionHandler.phpresolveInheritFromPublic(Schema): bool cascade 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.phpinheritFromPublic accepted by the authorization validator.
  • lib/Service/Settings/ConfigurationSettingsHandler.phpinheritFromPublicDefault surfaced in getSettings / getRbacSettingsOnly and persisted with strict normalization.
  • lib/Controller/Settings/ConfigurationSettingsController.php@NoCSRFRequired removed from updateRbacSettings.
  • 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 on hasPermission.
  • tests/Unit/Db/MagicMapper/MagicRbacHandlerInheritFromPublicTest.php — 10 tests: four-state matrix on buildRbacConditionsSql and applyRbacFilters smoke.
  • 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.php and tests/Unit/Service/Object/PermissionHandlerRbacTest.php — wired with the new constructor dependency / container mock.
  • 71/71 RBAC tests + 7/7 settings tests pass against the in-container PHPUnit runner. All static analysis (Psalm, PHPStan, PHPCS) clean on changed files.

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; inheritFromPublicDefault documented 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 under rbac-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 (branch feat/inherit-from-public-checkbox). Ready to review separately by your colleague.

Test plan

  • Schema-level toggle: set inheritFromPublic: false on a schema with read: [{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.
  • Per-object check: same matrix on GET /api/objects/{register}/{schema}/{id} (PHP-side path).
  • Tenant-default toggle: flip the new switch under RBAC Configuration, verify the IAppConfig key persists (occ config:app:get openregister rbac.inherit_from_public_default), verify schemas without an explicit value pick up the new default.
  • Strict normalization: send {"inheritFromPublicDefault": "garbage"} to PUT /api/settings/rbac and confirm a 5xx with inheritFromPublicDefault must be a boolean....
  • CSRF: confirm the settings UI still saves successfully (via @nextcloud/axios request-token flow). A direct curl without the request-token header should now fail.
  • Cascade: schema unset, register inheritFromPublic: false → schema reads honour register's value.
  • Default behaviour: any existing schema with no flag set behaves exactly as before this PR.
  • DocuDesk consent / OpenCatalogi publications: regression smoke for default flag (DocuDesk consent-flow smoke is the deferred 6.1 task).

🤖 Generated with Claude Code

rjzondervan and others added 10 commits May 7, 2026 13:08
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.
…1439)

Five auto-fixable violations the local cached run missed:

  - 2× "Expected 1 blank line after function; 2 found" between the
    `coerceStrictBoolOrLog` helper and its neighbours
  - 3× parameter-type alignment in the helper's @param block

Picked up by composer phpcs (CI scope: lib/) on PR #1441.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 7, 2026

Quality Report — ConductionNL/openregister @ f618adc

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.

Comment thread lib/Service/Object/PermissionHandler.php
Comment thread lib/Controller/Settings/ConfigurationSettingsController.php
Comment thread lib/Db/MagicMapper/MagicRbacHandler.php
Comment thread lib/Service/Settings/ConfigurationSettingsHandler.php
Comment thread package.json
Copy link
Copy Markdown
Contributor

@WilcoLouwerse WilcoLouwerse left a comment

Choose a reason for hiding this comment

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

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 PermissionHandler.php — phpcs job now green.

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.

Copy link
Copy Markdown
Contributor

@SudoThijn SudoThijn left a comment

Choose a reason for hiding this comment

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

FE looks fine

@rjzondervan rjzondervan merged commit 69e3edc into beta May 7, 2026
17 of 21 checks passed
@rjzondervan rjzondervan deleted the hotfix/1439/rbac-disable-public-inheritance branch May 7, 2026 14:42
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.

4 participants