Skip to content

Permission-Based Access Control

Problem

A workflow must allow only the role that owns each domain action.

Solution

Keep the role in context and use pure guards to select allowed transitions.

ts
import { defineMachine } from '@vielzeug/clockwork';

type Event = { payload: Record<string, unknown>; type: 'SUBMIT' } | { type: 'APPROVE' };
type Context = { role: 'reviewer' | 'submitter'; submission: Record<string, unknown> };
const can = (role: Context['role'], action: 'approve' | 'submit') => role === 'reviewer' ? action === 'approve' : action === 'submit';

const approval = defineMachine<Context, Event>()({
  context: { role: 'submitter', submission: {} },
  initial: 'draft',
  states: {
    draft: {
      on: {
        SUBMIT: {
          guard: ({ context }) => can(context.role, 'submit'),
          reduce: ({ context, event }) => ({ ...context, submission: event.payload }),
          target: 'review',
        },
      },
    },
    review: { on: { APPROVE: { guard: ({ context }) => can(context.role, 'approve'), target: 'approved' } } },
    approved: {},
  },
});

const actor = approval.createActor();
console.log(actor.can({ payload: { title: 'Proposal' }, type: 'SUBMIT' })); // true
actor.send({ payload: { title: 'Proposal' }, type: 'SUBMIT' });
console.log(actor.snapshot.state); // 'review'
actor.dispose();

Pitfalls

  • A guard is a client-side workflow rule, not server-side authorization.
  • A failed guard results in ignored; report denial at the application boundary.