Skip to content

roles.yaml cannot grant an operation the same component registers: roles plugin loads before jsResource #2203

Description

@dawsontoth

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 @ ddb2716; 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.tsdynamicallyRegisteredOps). A component's roles.yaml is applied by resources/roles.ts → handleApplication, which calls addRole/alterRole (security/role.ts) → addRoleValidationvalidateOperations.

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 '<op>'. 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:

// resources.js
server.registerOperation({
	name: 'my_component_op',
	requiresSuperUser: true,
	execute: async function myComponentOp() { return { ok: true }; },
});
# 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Fields

    Priority

    P2

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions