fix(rbac): openbuild authorised the automation write, OpenRegister refused it, the user got a 500 (#173) - #177
Conversation
…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
Quality Report — ConductionNL/openbuild @
|
| 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.
|
Not a review, and not a merge — this PR is Ruben's to land. One CI reading The red E2E cell on this PR is not this PR's. Its only failure is The likely input: openbuild's E2E job installs The part that matters for this PR: #175, #176 and #179 all still show the two Evidence: |
Review note (not a review): this PR's green E2E demonstrates its harness changes, not its RBAC fixPosting 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 What the log shows
Positive controls on the same log: Why that green is not evidence about the PHP changeNeither test ever issues an enable request in the failing state, so no code path
This PR fixes both — with A 500 from the RBAC defect would have surfaced as What this does and does not implyIt does not argue against the PHP fix — the underlying defect (openbuild authorises per-object, OpenRegister re-litigates against a coarse schema ACL, the outer 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 |
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.
Quality Report — ConductionNL/openbuild @
|
| 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.
Quality Report — ConductionNL/openbuild @
|
| 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.
Quality Report — ConductionNL/openbuild @
|
| 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.
What this is
The fix for #173 — the root cause of the red
E2E Tests (Playwright)job ondevelopment.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,
openregister0.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)
GET /apps/openregister/api/objects/openbuild/applicationasrbac-editorGET /apps/openbuild/api/applicationsasrbac-editor, before any grant[]— correct,permissionswasnullowners:['user:rbac-owner'], editors:['user:rbac-editor']on a wizard-created apprbac-editor, 1 row forrbac-ownerThe 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
permissionsare{owners:['user:rbac-owner'], editors:['user:rbac-editor']}:rbac-editorPOST /apps/openregister/api/objects/openbuild/automationrbac-editorPOST /apps/openbuild/api/automations/{uuid}/enableinsufficient_permission— correctrbac-ownerinternal_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
PermissionResolversays "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 outercatch (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 runsmatchesCaller(..., allowAdminBypass: false)before any write or compile side effect.updatepinsapplicationSlug/versionUuidto the stored values, so a caller holding a role on application A cannot re-parent B's automation by posting A's slug.destroyremoves the compiled artifacts before the definition — the reverse order leaves the instance acting on a rule nobody can see or edit any more._rbac: false, with the reason at each call site.AutomationEditDialog.vueandAutomationsPage.vuepoint at the new endpoints.The
automationschema 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 toauthenticated— 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
rbac-outsiderrbac-viewerrbac-editorrbac-viewerrbac-editorThen the requirement:
rbac-editorrbac-editorenabled=truerbac-ownerenabled=truerbac-editorrbac-editorThe e2e spec
automations-rbac.spec.tsnow 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.NcSelectrenders options throughNcEllipsisedOption, which splits any label of 10+ characters into two<span>s for middle-ellipsis; theoptionrole computes its accessible name from its contents, so the algorithm inserts a space at the element boundary:RBAC Automations App→ announced asRBAC Autom ations Appproduction(10 chars) →produ ctionThat is an upstream
@nextcloud/vueaccessibility defect — a screen reader reads it the same mangled way. Filed as ConductionNL/.github#350. Matched here via the component's owntitleattribute; 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:
goto12760 ms,.automations-page164 ms, overlay dismissal 4132 ms, row visible 939 ms), and the config's per-test budget istimeout: 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 inci-seed.shand is the real verdict. If test 2 is still budget-bound there, the honest follow-up is to makeAutomationsPagerender before its status calls resolve, or to split the test — not to raise the number.@e2ecoverage.Closes #171. Refs #173.