Access Control Guide
This guide shows how to use @verevoir/access to add identity resolution, role-based access control, and editorial workflows to a Verevoir application.
Installation
npm install @verevoir/access
No runtime dependencies. Works with or without the other Verevoir packages.
Overview
@verevoir/access provides three building blocks:
- Auth adapters — resolve tokens from any identity provider into a standard
Identityobject - Policies — define which roles can perform which actions, with optional ownership scoping
- Workflows — define state machines with guard-protected transitions
These are independent concepts. You can use policies without workflows, or workflows without policies. Your application code connects them to storage and the editor.
Step 1: Identity Resolution
An auth adapter maps whatever token your identity provider gives you into an Identity:
import { defineAuthAdapter } from '@verevoir/access';
import type { Identity } from '@verevoir/access';
const auth = defineAuthAdapter({
resolve: async (token): Promise<Identity | null> => {
const user = await verifyJwt(token as string);
if (!user) return null;
return {
id: user.sub, // unique user ID
roles: user.roles, // ['admin'], ['editor'], etc.
groups: user.groups, // optional — raw IdP groups
metadata: { // optional — extra claims
email: user.email,
name: user.name,
},
};
},
});
const identity = await auth.resolve(jwtToken);
The library doesn't handle login flows, sessions, or token refresh. It consumes tokens and produces identities. Roles come from your identity provider, not from Verevoir.
Anonymous Identity
When no token is present, use the built-in ANONYMOUS identity instead of returning null. This gives unauthenticated requests a viewer identity that policies can evaluate:
import { defineAuthAdapter, ANONYMOUS, isAnonymous } from '@verevoir/access';
const auth = defineAuthAdapter({
resolve: async (token) => {
if (!token) return ANONYMOUS;
const user = await verifyJwt(token as string);
if (!user) return ANONYMOUS;
return { id: user.sub, roles: user.roles };
},
});
// Every request gets an identity — no null checks needed
const identity = await auth.resolve(token);
policy.can(identity, 'read'); // true — viewer can read
// Detect anonymous users structurally (works after serialization)
if (isAnonymous(identity)) {
// Show sign-in prompt, hide edit controls, etc.
}
ANONYMOUS is frozen (Object.freeze) to prevent mutation. isAnonymous() compares by id, not by reference, so it works with serialized copies.
Google Auth Adapter
For Google OAuth2 / Workspace authentication, use the subpath export:
import { createGoogleAuthAdapter } from '@verevoir/access/google';
import { OAuth2Client } from 'google-auth-library';
const client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);
const auth = createGoogleAuthAdapter({
client,
allowedClientIds: [process.env.GOOGLE_CLIENT_ID!],
hostedDomain: 'yourcompany.com', // optional — restrict to a Workspace domain
mapRoles: (payload) => // optional — defaults to ['viewer']
payload.hd === 'yourcompany.com' ? ['editor'] : ['viewer'],
});
Install the optional peer dependency:
npm install google-auth-library
The adapter:
- Verifies ID tokens via
client.verifyIdToken() - Maps
payload.subtoidentity.id, email/name/picture tometadata - Returns
nullfor invalid, missing, or expired tokens (never throws) - Named
create...(notdefine...) because it wraps an external client
Test Auth Adapter
For development and testing, use the built-in test accounts adapter:
import { createTestAuthAdapter } from '@verevoir/access/test-accounts';
import type { TestAccount } from '@verevoir/access/test-accounts';
const testAccounts: TestAccount[] = [
{ token: 'admin-token', identity: { id: 'user-admin', roles: ['admin'], metadata: { email: 'admin@example.com' } } },
{ token: 'editor-token', identity: { id: 'user-editor', roles: ['editor'], metadata: { email: 'editor@example.com' } } },
{ token: 'author-token', identity: { id: 'user-author', roles: ['author'] } },
];
const auth = createTestAuthAdapter({ accounts: testAccounts });
The adapter:
- Looks up the token in the accounts list and returns the matching identity
- Returns
ANONYMOUSfor unknown, null, undefined, or empty tokens (never returnsnull) - Zero dependencies, no external clients
- Named
create...for consistency withcreateGoogleAuthAdapter
The import path is the safety mechanism. import { createTestAuthAdapter } from '@verevoir/access/test-accounts' in a code review is an immediate red flag — no runtime env checks needed. In production, replace the entire file with createGoogleAuthAdapter or a custom defineAuthAdapter.
Role Store
Google (and most IdPs) gives you an identity — sub, email, name — but knows nothing about application-level roles. The mapRoles callback needs to get role data from somewhere. The role store provides persistent user→roles mapping backed by any StorageAdapter.
npm install @verevoir/storage # optional peer dep — only needed for role-store
import { createRoleStore } from '@verevoir/access/role-store';
import { MemoryAdapter } from '@verevoir/storage';
const storage = new MemoryAdapter();
await storage.connect();
const roleStore = createRoleStore({
storage,
seedAdmin: { // optional — first-admin seeding
userId: 'google-114823947', // the Google `sub` of the first admin
roles: ['admin'],
},
});
The store uses a structural type for StorageAdapter (only create, list, update, delete), so any compatible implementation works — no import from @verevoir/storage is required inside @verevoir/access.
Seed admin: On the first getRoles call for the matching userId with an empty store, the seed admin assignment is auto-created. This ensures a fresh deployment isn't locked out. The seed only fires once and only when the store is empty.
API:
getRoles(userId)— returns the user's roles, or[]if not assignedsetRoles(userId, roles)— creates or updates a role assignmentlistAssignments()— returns all{ userId, roles }entries
Wiring to Google Auth
The mapRoles callback accepts both sync and async functions. Use it to look up roles from the store:
import { createGoogleAuthAdapter } from '@verevoir/access/google';
import { createRoleStore } from '@verevoir/access/role-store';
const roleStore = createRoleStore({ storage, seedAdmin: { userId: 'google-114823947', roles: ['admin'] } });
const auth = createGoogleAuthAdapter({
client,
allowedClientIds: [process.env.GOOGLE_CLIENT_ID!],
mapRoles: async (payload) => {
if (!payload.sub) return ['viewer'];
const roles = await roleStore.getRoles(payload.sub);
return roles.length > 0 ? roles : ['viewer'];
},
});
This is the full production pattern: Google verifies the token, the role store provides application roles, and the seed admin ensures someone can manage roles on first deploy.
Step 2: Define a Policy
A policy maps roles to permitted actions. Policies are deny-by-default — anything not explicitly allowed is denied.
import { definePolicy } from '@verevoir/access';
const contentPolicy = definePolicy({
rules: [
{ role: 'admin', actions: ['create', 'read', 'update', 'delete'] },
{ role: 'editor', actions: ['create', 'read', 'update'] },
{ role: 'author', actions: ['create', 'read'] },
{ role: 'author', actions: ['update', 'delete'], scope: 'own' },
{ role: 'viewer', actions: ['read'] },
],
});
Evaluating Access
contentPolicy.can(identity, 'create');
// → true for admin, editor, author; false for viewer
contentPolicy.can(identity, 'update', { ownerId: doc.data.createdBy });
// → true for admin/editor (any doc), true for author (own docs only)
The standalone can function works the same way:
import { can } from '@verevoir/access';
can(contentPolicy, identity, 'delete', { ownerId: 'user-author' });
Scope: Own vs All
Rules default to scope: 'all' — the role can perform the action on any document. Setting scope: 'own' restricts to documents where context.ownerId === identity.id.
A role that needs both unrestricted and restricted actions needs two rules. For example, authors can create and read freely (they can't own a document before it exists), but can only update and delete their own:
{ role: 'author', actions: ['create', 'read'] }, // unrestricted
{ role: 'author', actions: ['update', 'delete'], scope: 'own' } // own only
Tracking Ownership Without Package Changes
No storage interface change is needed. Track ownership in your document data:
// On create
await storage.create('article', { ...formData, createdBy: identity.id });
// On update (preserve the original creator)
await storage.update(id, { ...formData, createdBy: doc.data.createdBy });
// When checking permissions
contentPolicy.can(identity, 'update', {
ownerId: doc.data.createdBy as string,
});
Step 3: Define a Workflow
Workflows are stateless state machines. You define states and transitions; the library evaluates which transitions are available for a given state and identity.
import { defineWorkflow, hasRole, or } from '@verevoir/access';
const publishing = defineWorkflow({
name: 'publishing',
states: ['draft', 'review', 'published', 'archived'],
initial: 'draft',
transitions: [
{
from: 'draft',
to: 'review',
guard: or(hasRole('author'), hasRole('editor'), hasRole('admin')),
},
{
from: 'review',
to: 'published',
guard: or(hasRole('editor'), hasRole('admin')),
},
{
from: 'review',
to: 'draft',
guard: or(hasRole('author'), hasRole('editor'), hasRole('admin')),
},
{
from: 'published',
to: 'archived',
guard: hasRole('admin'),
},
],
});
Querying Available Transitions
// What can this user do from the current state?
const transitions = publishing.availableTransitions('draft', identity);
// Author: [{ from: 'draft', to: 'review', guard: ... }]
// Viewer: []
// Can this specific transition happen?
publishing.canTransition('review', 'published', identity);
// Editor: true, Author: false
Workflows Are Stateless
The workflow object doesn't store the current state. Your document's data holds the state (e.g., data.status), and you pass it to the workflow for evaluation:
const currentStatus = doc.data.status as string;
const available = publishing.availableTransitions(currentStatus, identity);
This means workflow definitions are pure functions — no side effects, no database dependency, fully testable.
Guards
Guards are composable boolean functions used in workflow transitions. Five are built in:
import { hasRole, isOwner, and, or, not } from '@verevoir/access';
hasRole
Passes if the identity has any of the specified roles:
hasRole('admin') // single role
hasRole('admin', 'editor') // any of these roles
isOwner
Passes if identity.id matches a field in the context:
isOwner() // checks context.ownerId (default)
isOwner('createdBy') // checks context.createdBy
Combinators
and(hasRole('editor'), isOwner()) // must be editor AND owner
or(hasRole('admin'), hasRole('editor')) // admin OR editor
not(isOwner()) // anyone except the owner
Custom Guards
A guard is any function matching this signature:
type Guard = (
identity: Identity,
context?: Record<string, unknown>,
) => boolean;
Write your own for domain-specific logic:
const isVerifiedEmail: Guard = (identity) =>
!!identity.metadata?.emailVerified;
const canPublish = and(hasRole('editor'), isVerifiedEmail);
Connecting Workflows to Auth and the Editor
Workflows and policies work together but remain independent — policies control who can perform CRUD actions; workflows control state transitions within a document.
Workflow-Driven Status Field
The editor's override mechanism lets you replace the default select field with a workflow-aware component:
import type { FieldEditorProps } from '@verevoir/editor';
function StatusField({ value, onChange }: FieldEditorProps<string>) {
const { identity, workflow } = useUser(); // your app's context
const current = (value as string) || workflow.initial;
const transitions = workflow.availableTransitions(current, identity);
return (
<div>
<span>{current}</span>
{transitions.map((t) => (
<button key={t.to} onClick={() => onChange(t.to)}>
→ {t.to}
</button>
))}
</div>
);
}
// Use via the editor's overrides prop
<BlockEditor
block={article}
value={data}
onChange={setData}
overrides={{ status: StatusField }}
/>
The status is no longer a free-form select — users can only move to valid workflow states. Different roles see different transition buttons:
| Role | From draft | From review | From published |
|---|---|---|---|
| Admin | → review | → published, → draft | → archived |
| Editor | → review | → published, → draft | — |
| Author | → review | → draft | — |
| Viewer | — | — | — |
The Full Integration Pattern
Here's how auth, policies, and workflows connect in a document editor:
function DocumentEditor({ blockType, documentId }) {
const { identity, can, workflow } = useUser();
const [createdBy, setCreatedBy] = useState<string>();
// Load document — track ownership
useEffect(() => {
if (documentId) {
storage.get(documentId).then((doc) => {
setCreatedBy(doc.data.createdBy as string);
actions.onChange(doc.data);
});
}
}, [documentId]);
// Policy gates the save button
const isNew = !documentId;
const canSave = isNew
? can('create')
: can('update', { ownerId: createdBy });
const handleSave = async () => {
if (!canSave) return;
if (documentId) {
await storage.update(documentId, { ...state.value, createdBy });
} else {
await storage.create(blockType, {
...state.value,
createdBy: identity.id,
});
}
};
// Workflow drives the status field (for articles)
const overrides = blockType === 'article'
? { status: StatusField }
: undefined;
return (
<>
<button disabled={!canSave}>Save</button>
<BlockEditor
block={block}
value={state.value}
onChange={actions.onChange}
overrides={overrides}
/>
</>
);
}
React Context for Identity
Wrap your app in a context provider that manages the current identity:
const UserContext = createContext<UserContextValue | null>(null);
function UserProvider({ children }) {
const [role, setRole] = useState('admin');
const [identity, setIdentity] = useState(defaultIdentity);
useEffect(() => {
auth.resolve(role).then((id) => { if (id) setIdentity(id); });
}, [role]);
const can = useCallback(
(action, context) => contentPolicy.can(identity, action, context),
[identity],
);
return (
<UserContext.Provider value={{ identity, role, setRole, can, workflow }}>
{children}
</UserContext.Provider>
);
}
In production, replace the mock auth adapter with your real identity provider. The rest of the integration code stays the same.
Workflow Design Patterns
Single Approval
defineWorkflow({
name: 'approval',
states: ['pending', 'approved', 'rejected'],
initial: 'pending',
transitions: [
{ from: 'pending', to: 'approved', guard: hasRole('approver') },
{ from: 'pending', to: 'rejected', guard: hasRole('approver') },
{ from: 'rejected', to: 'pending' }, // resubmit — open to all
],
});
Multi-Stage Pipeline
defineWorkflow({
name: 'content-pipeline',
states: ['draft', 'review', 'legal', 'published'],
initial: 'draft',
transitions: [
{ from: 'draft', to: 'review', guard: hasRole('author') },
{ from: 'review', to: 'legal', guard: hasRole('editor') },
{ from: 'legal', to: 'published', guard: hasRole('legal') },
{ from: 'review', to: 'draft' }, // send back
{ from: 'legal', to: 'review' }, // send back
],
});
Owner-Gated Transitions
defineWorkflow({
name: 'ownership',
states: ['active', 'deactivated'],
initial: 'active',
transitions: [
{
from: 'active',
to: 'deactivated',
guard: or(hasRole('admin'), isOwner()),
},
{
from: 'deactivated',
to: 'active',
guard: hasRole('admin'),
},
],
});
// Pass ownership context when evaluating
workflow.canTransition('active', 'deactivated', identity, {
ownerId: doc.data.createdBy,
});
Edge Cases
- Pre-existing documents without createdBy:
can('update', { ownerId: undefined })returns false forscope: 'own'rules, true forscope: 'all'rules. Safe default — admins and editors can still update, authors cannot. - Workflow transitions without saving: Clicking a transition button updates form state but doesn't auto-save. The user must click Save to persist. This matches the "stage then commit" editing pattern.
- Multiple roles: If an identity has multiple roles (
roles: ['author', 'editor']), any matching rule grants access. The most permissive applicable rule wins. - No matching rule: Deny-by-default. If no rule grants the action,
can()returns false.
Working Examples
Both example apps demonstrate the full access control integration:
- React (Vite):
projects/examples/examples/react/— role switcher in sidebar,src/access/,src/context/UserContext.tsx,src/components/StatusField.tsx - Next.js:
projects/examples/examples/nextjs/— simulated Google auth with sign in/out,ANONYMOUSidentity for unauthenticated state,src/components/AuthButton.tsx
The Next.js example starts in anonymous (read-only) mode. Sign in with a simulated Google account to get create/edit/delete controls. The React example uses a role switcher dropdown.