HarperFast / HarperFast/harper
roles.yaml cannot grant an operation the same component registers: roles plugin loads before jsResource
- Dominant language
- JavaScript
- Stars
- 89
- Forks
- 10
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 200
Description
A component's own `roles.yaml` cannot grant an operation that the same component's `resources.js` registers, because the `roles` plugin runs before `jsResource` in the same worker. Static trace against `main` @ ddb27164b; not reproduced against a live instance, so please correct me if I've misread the load order.
This is adjacent to but distinct from the cross-thread grantability gap being fixed separately (companion PR linked below) — that one was main-thread `validateOperations` never learning about worker registrations at all. This one is a same-thread ordering problem and survives that fix.
## The mechanism
`server.registerOperation({ requiresSuperUser })` calls `registerOperationPermission`, which marks the operation grantable in the registering thread's module-local set (`utility/operationPermissions.ts` → `dynamicallyRegisteredOps`). A component's `roles.yaml` is applied by `resources/roles.ts → handleApplication`, which calls `addRole`/`alterRole` (`security/role.ts`) → `addRoleValidation` → `validateOperations`.
Both run in the **same worker** (`componentLoader.ts:669` gates `handleApplication` on `resources.isWorker`), so grantability is visible in principle. The problem is ordering:
- `components/DEFAULT_CONFIG.ts` declares plugins in the order `rest`, `graphqlSchema`, `roles`, `jsResource`, `fastifyRoutes`, `static`.
- `componentLoader.ts:542` iterates with `for (const componentName in config)` — insertion order.
So `roles` is processed before `jsResource`. When `roles.yaml` is validated, the `registerOperation` calls in `resources.js` have not run yet, the name is not in `dynamicallyRegisteredOps`, and the role is rejected with `INVALID_OPERATIONS_OP`:
```
Invalid operations value ''. Must be a valid operation name or group (e.g. 'read_only').
```
## Why it matters
Declaring an operation and the role that may call it in the same component is the natural shape — it's the whole point of shipping `roles.yaml` alongside `resources.js`. Today an author has to either grant the operation out-of-band via the `add_role` API after deploy, or split the operation and its role across two components ordered so the registering one loads first.
It fails closed (a rejected role, never a widened one), so this is a DX/correctness bug, not a security hole.
## Repro sketch
A single component with:
```js
// resources.js
server.registerOperation({
name: 'my_component_op',
requiresSuperUser: true,
execute: async function myComponentOp() { return { ok: true }; },
});
```
```yaml
# roles.yaml
my_op_role:
operations:
- my_component_op
```
Expected: the role is created with the grant. Actual (expected from the trace): component load reports the role as invalid.
## Possible directions
- Order `jsResource` before `roles` in `DEFAULT_CONFIG` — smallest change, but relies on object key order as a load-order contract, and only helps the default config (an explicit component config with its own key order would still be able to get this wrong).
- Defer `roles.yaml` application until after the component's other plugins have loaded (e.g. apply on the existing `deploy:end`/ready hook rather than inline at `handleEntry`) — `resources/roles.ts` already has a `deploy:end` reconcile path, so this may be mostly a matter of which pass performs the *first* application.
- Make load order explicit rather than incidental, so a plugin can declare that it must run after `jsResource`.
I'd lean toward the second — the reconcile pass already exists and ordering-by-key-insertion is a fragile contract to lean on.
## Tests worth adding
An integration test with a single fixture component that registers a grantable operation in `resources.js` and grants it in its own `roles.yaml`, asserting the role exists with the grant after load. `integrationTests/components/registered-operation.test.ts` now covers the API-side (`add_role`) path across the worker/main boundary but nothing exercises `roles.yaml`.
---
🤖 Filed by Claude on behalf of @dawsontoth
Contributor guide
Research direction
Start with components/DEFAULT_CONFIG.ts and componentLoader.ts to trace plugin ordering, then read resources/roles.ts, security/role.ts, and utility/operationPermissions.ts. Use integrationTests/components/registered-operation.test.ts as the testing entry point and add a fixture covering resources.js with its own roles.yaml. Done means the role loads with the registered operation granted.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, node.js, typescript
- Domain
- authorization, backend, testing
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 56/100