fix(security): close the default-open write hole on decidesk's OpenRegister objects - #521
Merged
Merged
Conversation
…gister objects BEHAVIOUR CHANGE, stated up front: a user who is neither an object's owner, nor a Nextcloud admin, nor a member of `decidesk-administrators` can no longer UPDATE or DELETE another user's decidesk object. Reads, listings and creates are unchanged. Until now they could. Every decidesk object is reachable at /apps/openregister/api/objects/decidesk/<schema> — the API the frontend uses directly under ADR-022 — and no decidesk controller guard sits in front of it. What decides who may write there is the `authorization` block on the schema, or failing that on the register row. This tree had neither. OpenRegister's PermissionHandler::hasGroupPermission() tests `empty($authorization)`, and PHP's empty() is true for null and [] alike, so an ABSENT block takes the same default-OPEN branch as an empty one. `enforce_default_closed` reads IAppConfig with default:false, so on a stock instance its deny arm never fires — and even switched on it covers only writes, never reads. Measured across all 25 register files on this tree: 93 schemas, 24 carrying a block (every one of them read-only), and the register row carrying none. So 69 schemas — Decision, VotingRound, Vote, Participant and EngagementRecord among them — granted create, update AND delete to any logged-in account. The same shape as docudesk#631, where a plain user overwrote another user's template. The fix sits on the REGISTER row because of the cascade: resolveAuthorization() uses a schema's own block when it has one and falls back to the register's only when it does not. So one declaration reaches exactly the 69 unprotected schemas and changes nothing for the 24 that already declare their own — their public-read publication rules are untouched. Every canonical action is written out deliberately. Once a block is non-empty, OpenRegister DENIES any action it omits, so a half-written block breaks the app rather than securing it: read/list/create stay `authenticated`, and only update/delete are narrowed. The owner bypass is unconditional and SQL-side and precedes every rule, so an author keeps full control of their own object. Both version bumps are load-bearing and neither is cosmetic. ImportHandler's REGISTER path skips outright when the incoming version is <= the stored one and, unlike the schema path, has no content-differs fallback — so the register goes 0.7.0 -> 0.8.0. And InitializeSettings is a <post-migration> repair step, which runs only on `occ upgrade`, which is a no-op when the app version has not moved — so appinfo goes 0.4.6 -> 0.4.7. Either bump alone leaves a correct block sitting on disk on every existing instance.
Seven tests, shown red on development's own state before they were shown green: 5 of 7 fail there (the block is absent, and both versions are behind), and the two that stay green are the ones asserting the UNCHANGED 24 schema-level blocks — which is what they should do. They deliberately do NOT re-implement OpenRegister's evaluator. An instrument built from the same source as the bug reports zero, and zero reads as a pass. What this repository owns is the DECLARATION the evaluator reads, so that is what is pinned: the block exists and names every canonical action with a non-empty rule list; read/list/create still grant `authenticated` (if this goes red the fix has become an outage); update/delete grant neither `authenticated` nor `public`; no write action anywhere names `public`, which is the one thing that would re-open the anonymous writes openregister#1955 closed. Two of the seven guard the deploy path rather than the policy, because a correct block that never reaches an instance is a fix that reports success and changes nothing: the register/config version must be past 0.7.0 and the app version past 0.4.6, each asserted against the last release that shipped WITHOUT the block so the assertion survives future bumps. The app-version test carries a positive control on its own reader (info.xml's <id> must read `decidesk`), so a failure means 'not bumped' and never 'parsed an empty document'. The schema-block test carries the same kind of control: it asserts the COUNT is 24, so it cannot pass vacuously if the schemas are renamed, moved, or stop being found. REQ-RBAC-006 records the requirement, with an @e2e exclude that names the real reason: the owner bypass is unconditional and SQL-side, so a browser test driven by one seeded (owning, usually admin) session cannot observe this denial at all and would report success over the exact hole. The per-user behaviour needs a two-account probe against a live instance, and that is recorded as verification owed rather than claimed.
rubenvdlinde
requested review from
Rem-Dam,
WilcoLouwerse,
bbrands02 and
rjzondervan
as code owners
August 17, 2026 07:51
…hem, and CI proved it Two defects in the first version of this PR, both found by CI and neither findable locally. Recording what happened, because the first one is the exact trap this change was supposed to avoid and I walked into it anyway. 1. THE BLOCK CLOSED ANONYMOUS READS. All six PHPUnit legs failed with `NotAuthorizedException: User 'Anonymous' does not have permission to 'read' objects in schema 'Meeting'`. Before any block existed, hasGroupPermission() took its default-OPEN branch for EVERY principal — the anonymous one included — so a block naming only `authenticated` on `read` does not preserve the status quo, it CLOSES anonymous reads. Omission is the deny, and that is as true for the principal as it is for the action. PHPUnit's CLI has no session, so those integration tests exercise exactly the path a #[PublicPage] citizen-participation surface takes. Left as it was, this PR would have 403'd every public consultation and budget-proposal read. `read` and `list` now name `public` as well, which is precisely the pre-change behaviour. `public` appears on NO write action, so openregister #1955's anonymous fail-closed rule keeps denying anonymous create/update/delete exactly as before, and the write hole stays closed. Closing anonymous reads may well be worth doing — it is a far larger policy change than this PR, and it does not belong smuggled inside it. Guarded by a new test, shown red on the omission. 2. MY OWN TEST FAILED ON AN EXTENSION CI DOES NOT HAVE. `simplexml_load_file()` worked locally and returned FALSE on every CI leg, failing the suite on 'appinfo/info.xml must be readable XML'. The assertion is about one scalar in a file this repository owns; it now reads the file as text and preg_matches <version>, with the positive control moved to a string match on <id>.⚠️ Local green could not have caught either one. The unit environment stubs OCA\OpenRegister\*, so the integration tests that hit the real permission path are precisely the ones that cannot run there — and the XML failure needed CI's own PHP image. Both are the 'local green means nothing for this bug class' shape.
Contributor
Quality Report — ConductionNL/decidesk @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 532/532 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ✅ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-17 08:15 UTC
Download the full PDF report from the workflow artifacts.
Picks up #522's composer cooldown in .github/dependabot.yml. The gate package moved from 742f370e to 0b189e30 mid-review and added gate-93 composer-cooldown-config, which this branch failed purely by predating the fix that development already carries.
Contributor
Quality Report — ConductionNL/decidesk @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 532/532 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-17 10:40 UTC
Download the full PDF report from the workflow artifacts.
Contributor
Quality Report — ConductionNL/decidesk @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 104/104 | |||
| npm | ✅ | ✅ 532/532 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-17 11:47 UTC
Download the full PDF report from the workflow artifacts.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A user who is neither an object's owner, nor a Nextcloud admin, nor a member of
decidesk-administratorscan no longer UPDATE or DELETE another user's decidesk object. Reads, listings and creates are unchanged, for everyone.Until now they could — through OpenRegister's own API, with no decidesk code involved.
The hole
Every decidesk object is reachable at
/apps/openregister/api/objects/decidesk/<schema>— the API the frontend uses directly under ADR-022. No decidesk controller guard sits in front of it. What decides who may write there is theauthorizationblock on the schema, or failing that on the register row. This tree had neither.OpenRegister's
Service/Object/PermissionHandler::hasGroupPermission():PHP's
empty()is true fornulland[]alike, so an absent block takes the same default-OPEN branch as an empty one — there is no branch anywhere that distinguishes them.isDefaultClosedEnforced()readsIAppConfigwithdefault: false(and itscatchalso returns false), so on a stock instance the deny arm never fires; even switched on it covers only the three write actions and neverread. The anonymous fail-closed rule from openregister#1955 does not help either — it is checked only on the$userId === nullbranch, and an authenticated caller never reaches it.Measured across all 25 register files on this tree (
lib/Settings/decidesk_register.json+ 24lib/Settings/register.d/*.json):authorizationblockread-onlydecideskregister rowauthorization: null)Decision,VotingRound,Vote,ParticipantandEngagementRecordare among the 69. This is the same shape as docudesk#631, where a plain user overwrote another user's template.(Parse the fragments, not just the main file: the main file alone reads 39 schemas / 5 blocks and understates both sides.)
Why the fix is one declaration on the register row
PermissionHandler::resolveAuthorization()uses a schema's own block when it has one and falls back to the register's only when it does not. That cascade means a single register-level baseline reaches exactly the 69 unprotected schemas and changes nothing for the 24 that already declare their own — theirpublic-read-under-publication-date rules are untouched, and they already fail closed on writes by naming no write action.🔴
publicon the read actions is a CORRECTION, and CI found itThe first push of this PR omitted
public, and all six PHPUnit legs failed:Before any block existed,
hasGroupPermission()took its default-OPEN branch for every principal — the anonymous one included. So a block naming onlyauthenticatedonreaddoes not preserve the status quo, it closes anonymous reads. Omission is the deny, and that is as true of the principal as of the action. PHPUnit's CLI has no session, so those integration tests exercise exactly the path a#[PublicPage]citizen-participation surface takes: left as it was, this PR would have 403'd every public consultation and budget-proposal read.publicnow appears onreadandlist— precisely the pre-change behaviour — and on no write action, so openregister#1955's anonymous fail-closed rule keeps denying anonymous create/update/delete exactly as before and the write hole stays closed. Closing anonymous reads may well be worth doing; it is a much larger policy change than this PR and does not belong smuggled inside it. A new test guards it, shown red on the omission.Neither defect was findable locally — the unit environment stubs
OCA\OpenRegister\*, so the integration tests that hit the real permission path are precisely the ones that cannot run there. (A second, smaller one came back with it: my own test usedsimplexml_load_file(), which worked locally and returnedfalseon every CI leg. It now reads the file as text.)Every canonical action is written out on purpose. Once a block is non-empty,
hasGroupPermission()reachesif (empty($authorization[$action])) return false;— OpenRegister denies any action the block omits. Aread-only block bolted onto 69 schemas would not secure this app, it would break every write in it. That trap has already cost this programme five bad PRs;read,listandcreateare named deliberately so nothing goes dark.What actually narrows is only
updateanddelete, and only for third parties. OpenRegister bypasses the object owner unconditionally and SQL-side, before any rule is consulted, and bypassesadmintoo — so an author keeps full control of their own object.decidesk-administratorsis an admin-provisioned Nextcloud group for a griffie/secretariat that must edit records it did not create; on an instance where that group does not exist this resolves to owner-plus-admin, which is fail-closed rather than open.Per-body actor authorization (the
chair/signatoryscopes inx-decidesk-rbac-scopes) is a separate layer and is unaffected — OpenRegister cannot template a group name per object, which is exactly why that determination is made at the app boundary inGovernanceScopeGuard.Both version bumps are load-bearing
Neither is cosmetic, and either one alone leaves a correct block sitting on disk on every existing instance:
0.7.0 → 0.8.0(andinfo.versionwith it).ImportHandler's REGISTER path skips outright when the incoming version is<=the stored one — and unlike the SCHEMA path (which falls back to comparing content) it has no content-differs escape.0.4.6 → 0.4.7.InitializeSettingsis a<post-migration>repair step, and post-migration steps run only onocc upgrade, which is a no-op when the app version has not moved.Two of the seven tests assert exactly these, each against the last version that shipped without the block, so they survive future bumps.
The tests, and their red control
Seven tests. On
development's own state — register JSON andinfo.xmlreverted, tests kept — 5 of the 7 fail:They deliberately do not re-implement OpenRegister's evaluator. An instrument built from the same source as the bug reports zero, and zero reads as a pass. What this repository owns is the declaration the evaluator reads, so that is what is pinned — including the assertions that would catch a well-meaning future edit re-opening the hole (
update/deletemust contain neitherauthenticatednorpublic; no write action anywhere may namepublic, sincepublicGroupExplicitlyGranted()is the one thing that re-opens the anonymous writes openregister#1955 closed).Two carry positive controls on their own reader, so a failure can never mean "read nothing": the app-version test also asserts
info.xml's<id>readsdecidesk, and the schema-block test asserts the count is 24 so it cannot pass vacuously if schemas are renamed or moved.🔴 Verification I am NOT claiming
I did not run a two-account, non-owner probe against a live instance. decidesk is not installed on the shared dev instance, and standing up the rig was out of budget for this change.
This matters and is stated rather than glossed: the owner bypass is unconditional and SQL-side, so any test driven by a single seeded session — which in practice owns its objects, and is usually admin — cannot observe this denial at all and would report success over the exact hole. That is also why REQ-DCDH's new scenario carries an
@e2e excludenaming that reason instead of a Playwright test.What a reviewer should run before merging:
decidesk-administrators),PUTandDELETEthat Decision through/apps/openregister/api/objects/decidesk/decision/<id>— both must be refused.GET/list decidesk objects andPOSTa new one — all must still work.MotionCoauthorService) and a griffier editing a meeting created by a chair. If either is a real requirement, the answer is to add that role's projected group toupdate, not to widen it back toauthenticated.Checks
Both sides on gate package
742f370e152b296acdca3289230a0119d8bb23b8:run-hydra-gates.sh --base a9de4096developmentfailure this PR does not touch.64 of 64 applicable gates ran.[gate-47] security-change-has-tests[gate-16]/[gate-46]/[gate-22]/[gate-56]phpunit -c phpunit.xmlTests: 980, Assertions: 3836, Errors: 87, Failures: 1, Skipped: 33Tests: 973, Errors: 87, Failures: 1OCA\OpenRegister\Service\FileServicemocks; CI checks outopenregister@developmentas an additional app and binds the real class. This PR contributes exactly +7 tests / +127 assertions and moves neither number.Parity: this PR fails
gate-24and nothing else, which is precisely whatdevelopmentfails today.#520is the PR that closes gate-24; the two are independent and can land in either order.