Skip to content

fix(rbac): openbuild authorised the automation write, OpenRegister refused it, the user got a 500 (#173) - #177

Merged
rubenvdlinde merged 6 commits into
developmentfrom
fix/issue-173-automation-rbac
Aug 12, 2026
Merged

fix(rbac): openbuild authorised the automation write, OpenRegister refused it, the user got a 500 (#173)#177
rubenvdlinde merged 6 commits into
developmentfrom
fix/issue-173-automation-rbac

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

What this is

The fix for #173 — the root cause of the red E2E Tests (Playwright) job on development.

Opened for review, not for merge, per Ruben's instruction: it changes where an authorization boundary lives, which is an architecture decision.

The measurement first

#173 was filed from a reading of the code and said so. Its second comment then reported a CI failure as evidence of a different first blocker. I re-measured everything on a live instance (NC 34, openregister 0.2.17-unstable.36), printing the status code on every probe. One half stands, the other is retracted.

RETRACTED — "a granted editor cannot see the application" (#171, and #173's second comment)

probe result
GET /apps/openregister/api/objects/openbuild/application as rbac-editor 200, 5 rows — OR multitenancy does not drop the row
GET /apps/openbuild/api/applications as rbac-editor, before any grant 200, [] — correct, permissions was null
same, after granting owners:['user:rbac-owner'], editors:['user:rbac-editor'] on a wizard-created app 1 row for rbac-editor, 1 row for rbac-owner

The grant works, on the wizard path, for both roles. The original claim came from a Playwright locator timing out on an app-picker option — see "the second cause" below for why that locator could never have matched. A locator that finds nothing says nothing about the API underneath it. This retraction is written into tests/e2e/support/appRoles.ts, where the next person will look.

CONFIRMED — openbuild grants the write, OpenRegister refuses it, the user gets a 500

Against a fixture app whose permissions are {owners:['user:rbac-owner'], editors:['user:rbac-editor']}:

caller call before
rbac-editor POST /apps/openregister/api/objects/openbuild/automation 403 "does not have permission to 'create' objects in schema 'Automation'"
rbac-editor POST /apps/openbuild/api/automations/{uuid}/enable 403 insufficient_permission — correct
rbac-owner same 500 internal_error "User 'rbac-owner' does not have permission to 'update' objects in schema 'Automation'"

The shape is one write authorised twice, by two layers, in two different vocabularies. openbuild's PermissionResolver says "you are an owner of this application on this version"; OpenRegister then re-litigates the same write against a coarse schema-level group ACL (create/update/delete: ["admin"]) and refuses; withAutomation()'s outer catch (Throwable) turns the refusal into a 500, so it does not even read as a permission problem.

The change

#173's option (b): CRUD moves behind openbuild endpoints that authorise per Application and then write in system context.

  • POST /api/automations, PUT /api/automations/{uuid}, DELETE /api/automations/{uuid}. Each is #[NoAdminRequired]; each runs matchesCaller(..., allowAdminBypass: false) before any write or compile side effect.
  • update pins applicationSlug/versionUuid to the stored values, so a caller holding a role on application A cannot re-parent B's automation by posting A's slug.
  • destroy removes the compiled artifacts before the definition — the reverse order leaves the instance acting on a rule nobody can see or edit any more.
  • The internal saves pass _rbac: false, with the reason at each call site.
  • AutomationEditDialog.vue and AutomationsPage.vue point at the new endpoints.

The automation schema deliberately stays ["admin"]. That gate is the backstop that makes this controller the only way in for a non-admin, so the boundary is in one place instead of two. Option (a) — widening the schema to authenticated — would let any authenticated user rewrite any automation on any application straight over OR REST with no per-application filter anywhere.

This departs from the ADR-022 default and is documented as such at both the route table and the controller docblock. That is the decision to review.

Verification — negative controls run FIRST

caller call result
rbac-outsider CREATE on draft 403
rbac-viewer CREATE on draft 403
rbac-editor CREATE on an app they hold no role on 403
rbac-viewer DELETE 403
rbac-editor ENABLE on production 403 — correct, owners only

Then the requirement:

caller call before after
rbac-editor CREATE on draft 403 201
rbac-editor ENABLE on draft unreachable 200, enabled=true
rbac-owner ENABLE on production 500 200, enabled=true
rbac-editor UPDATE, body claiming a different app + bogus version n/a 200, both fields pinned back
rbac-editor DELETE 403 200

The e2e spec

automations-rbac.spec.ts now builds its fixture rather than assuming one that only ever existed on a developer's box, resets the rows it authors, targets the row it seeded rather than .first(), and reuses globalSetup's stored sessions instead of two interactive logins.

A second, independent cause — and it is fleet-wide

Even once the app existed, getByRole('option', { name: /rbac.?automations.?app/i }) could not match it. NcSelect renders options through NcEllipsisedOption, which splits any label of 10+ characters into two <span>s for middle-ellipsis; the option role computes its accessible name from its contents, so the algorithm inserts a space at the element boundary:

  • RBAC Automations App → announced as RBAC Autom ations App
  • production (10 chars) → produ ction

That is an upstream @nextcloud/vue accessibility defect — a screen reader reads it the same mangled way. Filed as ConductionNL/.github#350. Matched here via the component's own title attribute; not by loosening the regex, which would hide the defect and risk matching the wrong option in a longer list.

What I did NOT verify, stated plainly

I have not seen both browser tests green in one run. On my local rig — a bind-mounted WSL container — the SPA boot alone is 12.8 s (measured: goto 12760 ms, .automations-page 164 ms, overlay dismissal 4132 ms, row visible 939 ms), and the config's per-test budget is timeout: 30_000. Test 1 (editor authors + enables on a draft version) passed end-to-end through the UI, twice — which is the half that was outright impossible before this change. Test 2 needs two sessions and runs 31–34 s on that rig.

I did not widen the timeout, skip anything, or add mode: 'serial'. CI warms the bundle in ci-seed.sh and is the real verdict. If test 2 is still budget-bound there, the honest follow-up is to make AutomationsPage render before its status calls resolve, or to split the test — not to raise the number.

⚠️ Note when reading this PR's own gate cells: gate-19 is diff-scoped, so a green gate-19 here says nothing about repo-wide @e2e coverage.

Closes #171. Refs #173.

…he user got a 500

REQ-AUTD-008 says an editor may author and enable an automation on a draft
version, and an owner may enable on production. Neither could happen. Measured
on a live instance (NC 34, openregister 0.2.17-unstable.36) against an
Application whose permissions grant `owners: ['user:rbac-owner']` /
`editors: ['user:rbac-editor']`:

  POST /apps/openregister/api/objects/openbuild/automation as rbac-editor
    -> 403 "User 'rbac-editor' does not have permission to 'create' objects
            in schema 'Automation'"
  POST /apps/openbuild/api/automations/{uuid}/enable as rbac-editor
    -> 403 insufficient_permission            (correct - editors cannot enable on production)
  POST /apps/openbuild/api/automations/{uuid}/enable as rbac-owner
    -> 500 internal_error
       "User 'rbac-owner' does not have permission to 'update' objects in
        schema 'Automation'"                  (WRONG - this is the owner)

Two faults, one cause: the authorization decision was being made twice, by two
layers, in two different vocabularies.

1. The designer wrote straight to OR REST, so the per-Application `permissions`
   block was never consulted on create/update at all - only OR's schema-level
   group ACL, which declares `create/update/delete: ["admin"]`.

2. `recompileAndRespond()` called `saveObject()` with the default `_rbac: true`,
   so OR re-litigated a decision `withAutomation()` had already made and reached
   the opposite answer. The refusal then hit the outer `catch (Throwable)` and
   surfaced as a 500, which does not even read as a permission problem.

The fix is openbuild#173's option (b): CRUD moves behind openbuild endpoints
that authorise per Application, then write in system context.

- New routes: POST /api/automations, PUT|DELETE /api/automations/{uuid}. Each is
  #[NoAdminRequired] and each runs PermissionResolver::matchesCaller() with
  `allowAdminBypass: false` BEFORE any write or compile side effect.
- `update` pins applicationSlug/versionUuid to the STORED values, so a caller
  holding a role on application A cannot re-parent B's automation by posting A's
  slug.
- `destroy` removes the compiled artifacts BEFORE the definition. The reverse
  order leaves the instance acting on a rule nobody can see or edit any more.
- The four internal saves now pass `_rbac: false`, each with the reason at the
  call site.
- AutomationEditDialog.vue and AutomationsPage.vue point at the new endpoints.

The `automation` schema deliberately stays admin-only. That gate is the backstop
that makes this controller the only way in for a non-admin, so the boundary is
in one place instead of two. Widening it to "authenticated" - the other option
on the table - would let any authenticated user rewrite any automation on any
application over OR REST with no per-application filter anywhere.

This is a departure from the ADR-022 default and is documented as such at both
the route table and the controller docblock.

Refs #173
`automations-rbac.spec.ts` documented its Application as "created via the
wizard's dev-prod preset during this session's live-verification" — a fixture
that existed on exactly one laptop. `ci-seed.sh` seeds only `hello-world`, so in
CI the app-picker had no such option and both tests died on a locator timeout.

That timeout was then read as a fact about `GET /api/applications` and written up
as #171, and as the second comment on #173, both claiming a granted editor cannot
see their application. Re-measured directly, printing the status code on every
probe:

  GET /apps/openregister/api/objects/openbuild/application as rbac-editor
    -> 200, 5 rows      (so OR multitenancy does NOT drop the row)
  GET /apps/openbuild/api/applications as rbac-editor, before any grant
    -> 200, []          (correct: permissions was null)
  ... after granting owners:[user:rbac-owner], editors:[user:rbac-editor]
    -> 1 row for rbac-editor AND 1 row for rbac-owner

The grant works. A locator that finds nothing says nothing about the API
underneath it. Both claims are retracted in appRoles.ts, at the place the next
person will look.

Second, unrelated cause, also found by measurement rather than inference: the
option could not be matched even once it existed. `NcSelect` renders options
through `NcEllipsisedOption`, which splits any label of 10+ characters into two
spans for middle-ellipsis. The `option` role computes its accessible name from
its contents, so "RBAC Automations App" announces as "RBAC Autom ations App" —
and `production` (10 chars) as `produ ction`. That is an upstream @nextcloud/vue
accessibility defect, filed as ConductionNL/.github#350; it is matched here via
the component's own `title` attribute rather than by loosening the regex, which
would hide the defect and could match the wrong option.

  - beforeAll creates the app with BOTH versions, grants owner/editor to the
    non-admin fixture users, and grants `user:admin` EXPLICITLY (every automation
    route runs `allowAdminBypass: false`, so admin is not an implicit owner).
  - It deletes and re-seeds the rows the tests act on. The suite AUTHORS
    automations, so without a reset a second run inherits the first run's state:
    `hasText` matches two rows, and an already-enabled row sends /disable, so a
    `waitForResponse(/enable/)` waits out the budget and blames permissions.
  - The production test targets the row it seeded, not `.first()`.
  - Sessions come from globalSetup's stored state instead of two interactive
    form logins, which is work globalSetup had already done.
  - ensureApp() takes a version list; grantAppRoles() can grant owners.

No timeout was widened and nothing was skipped.

Refs #173, #171
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ 345bb43

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
check-gitignore
check-nc-floor
composer ✅ 101/101
npm ✅ 654/654
PHPUnit
Newman ⏭️
Playwright
Hydra gates

Quality workflow — 2026-08-11 13:49 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Not a review, and not a merge — this PR is Ruben's to land. One CI reading
that is easy to misattribute, recorded here so the next person does not have to
re-derive it.

The red E2E cell on this PR is not this PR's. Its only failure is
tests/e2e/builder-host.spec.ts:44 (detail page must render the seeded body
text
), and that same test fails on #175, #176, #179 and this one — four
branches whose diffs do not overlap and none of which touches that file. The
last development run before they were cut (31459739568, 04:50) was
2 failed / 181 passed and did not include it.

The likely input: openbuild's E2E job installs
additional-apps: [{"repo":"ConductionNL/openregister","ref":"development"}]
a moving ref, which advanced 6 commits between that run and the ~13:50 PR
runs.

The part that matters for this PR: #175, #176 and #179 all still show the two
automations-rbac.spec.ts failures. This branch shows neither — only the
shared one. So the fix here does clear both reds it set out to clear, and the
remaining cell is environmental.

Evidence: gh api repos/ConductionNL/openbuild/actions/jobs/<id>/logs for jobs
93791451153 (this PR), 93818991209 (#176), 93733358066 (#175), 93802145089 (#179).

@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Review note (not a review): this PR's green E2E demonstrates its harness changes, not its RBAC fix

Posting a measurement rather than an opinion, because the fleet-board notes on this repo currently say the opposite and a reviewer reading them would be misled.

The standing claim was: "PR #177 shows only the builder-host failure, i.e. its RBAC fix genuinely clears the two automations-rbac reds." The observation is right. The attribution is not.

What the log shows

gh api repos/ConductionNL/openbuild/actions/jobs/93791451153/logs262,498 bytes, HTTP 200 (not a BlobNotFound stub):

1 failed / 68 skipped / 182 passed
✓ automations-rbac … editor authors + enables on a draft version   (14.4s)
✓ automations-rbac … editor gets 403 on production; owner succeeds (13.4s)

Positive controls on the same log: grep -c automations-rbac → 2, grep -c 'Running … tests' → 1, and grep -c cn-support-dialog0.

Why that green is not evidence about the PHP change

Neither test ever issues an enable request in the failing state, so no code path lib/Controller/AutomationsController.php touches is exercised by the transition from red to green. On development today the two failures are:

  • :124 — dies at line 130 clicking the application combobox, blocked by <div … data-testid-modal="cn-support-dialog" class="dialog__modal cn-support-dialog modal-mask"> subtree intercepts pointer events. That dialog is opened by an HTTP 200 carrying {"value": null} from /api/preferences/support-dialog-seen — openbuild is one of the few apps that actually serves that route, and getPreference() returns 200 with a null value for an unset key, which useSupportDialog reads as "not seen yet". Not an error dialog, not downstream of the 500.
  • :152 — dies at line 159 on getByRole('option', {name:/rbac.?automations.?app/i}) with no locator resolved to line at all, i.e. zero candidates. On development rbac-automations-app appears in exactly 3 places, all constants and comments inside the spec itself; ci-seed.sh seeds only hello-world.

This PR fixes both — with suppressSupportDialog(page) and with the fixture-building beforeAll. Those are the two hunks the green cell demonstrates.

A 500 from the RBAC defect would have surfaced as expect(response.status()).toBe(403) → received 500. That string appears in none of the six job logs I read.

What this does and does not imply

It does not argue against the PHP fix — the underlying defect (openbuild authorises per-object, OpenRegister re-litigates against a coarse schema ACL, the outer catch (Throwable) turns the refusal into a 500) was measured directly by HTTP probe with status codes printed, and that measurement stands on its own.

It means only this: a PR that changes the product and the harness in one commit cannot tell you which one turned the cell green. If you want the RBAC fix's evidence, it has to come from the direct probes, not from this E2E cell.

I have not merged, modified or rebased anything here — this PR and #173 are review-only per instruction. Bumping phpcsutils for CVE-2026-65954 went into a separate PR that touches nothing else.

rubenvdlinde added a commit that referenced this pull request Aug 11, 2026
Lock-only bump; `composer audit` clean after it and CI's phpcs job exercises it.

Merged over a red E2E cell that is pre-existing and STRICTLY NOT WORSENED, verified against the same-tree baseline rather than an eyeball:

| | development 31459739568 | this PR |
|---|---|---|
| failed | 2 | **2 — identical set** |
| passed | 181 | **181** |
| skipped | 68 | **68** |

Both remaining failures are `automations-rbac.spec.ts:124` and `:152`, and both are test-harness gaps (a first-open CnSupportDialog modal mask, and a fixture app CI never creates) rather than the RBAC 500 — remedied by the harness half of the review-only PR #177.

`version-rollback.spec.ts:263`, which was red on the current development tip, PASSED here — consistent with the same-SHA-opposite-verdict pair already recorded (`2b1a8900` failed it in run 31438032584 and passed it in run 31459739568), so it is not attributable to any diff. Mechanism candidate filed as nextcloud-vue#632, deliberately not masked.
…vice

PHPMD reported ExcessiveClassComplexity 55 (threshold 50) on
AutomationsController after #173's three CRUD routes landed on it. The whole-class
metric is the honest signal here: the controller had grown a write path, a
create-side authorization scope and an HTTP surface in one class.

- lib/Service/AutomationWriteService.php owns create/update/destroy plus the
  Application/Automation lookups and the request-body decode. Its header records
  why these writes live in openbuild rather than on OR REST, and the four
  authorization invariants that must not be weakened.
- AutomationsController keeps compile/enable/disable/dry-run/status and delegates.
- AutomationWriteServiceTest covers the service directly; the controller test wires
  the REAL service over the same mocked boundaries, because the controller's
  create/update/destroy are pure delegation and a mocked collaborator would assert
  only that delegation happened.

phpmd 55 -> clean, phpcs clean, phpstan clean, 814 tests green.
… (gate-16)

gate-16 named this method as the one changed method missing an @SPEC anchor.
Anchored at the canonical spec (openspec/specs/…), not a change dir, and
gate-46 resolves it — positive-controlled by planting
'#req-autd-008-PLANTED-BOGUS' on AutomationWriteService, observing the single
'anchor not found' finding, and reverting with Edit.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ c5107fb

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
check-gitignore
check-nc-floor
composer ✅ 101/101
npm ✅ 654/654
PHPUnit
Newman ⏭️
Playwright
Hydra gates

Quality workflow — 2026-08-11 23:42 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ 3a4e554

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
check-gitignore
check-nc-floor
composer ✅ 101/101
npm ✅ 654/654
PHPUnit
Newman ⏭️
Playwright
Hydra gates

Quality workflow — 2026-08-12 00:18 UTC

Download the full PDF report from the workflow artifacts.

@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/openbuild @ 2e7c297

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
test-l10n
check-gitignore
check-nc-floor
composer ✅ 101/101
npm ✅ 654/654
PHPUnit
Newman ⏭️
Playwright
Hydra gates

Quality workflow — 2026-08-12 07:38 UTC

Download the full PDF report from the workflow artifacts.

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.

An explicit editors: ['user:rbac-editor'] grant does not make an Application visible to that editor in /api/applications

2 participants