Guides
•5 min read
How to Implement Role-Based Permissions
Admin sees user management, editor can publish, viewer can only read — role-based UI gating with derivations, cross-module constraints, and dynamic disable().
The Problem
The auth guide covers login/logout but not role-based access control. Real apps need: computed permissions from roles, UI elements that appear/disappear based on permissions, features gated by constraints that are disabled entirely for unauthorized users, and permission loading from an API. This is a natural constraint-satisfaction problem — "this feature requires admin permissions" is literally a constraint.
The Solution
import { createModule, createSystem, t } from '@directive-run/core';
const auth = createModule('auth', {
schema: {
userId: t.string(),
role: t.string<'admin' | 'editor' | 'viewer' | ''>(),
token: t.string(),
},
init: (facts) => {
facts.userId = '';
facts.role = '';
facts.token = '';
},
derive: {
isAuthenticated: (facts) => facts.token !== '',
},
constraints: {
validateSession: {
when: (facts) => facts.token !== '' && facts.role === '',
require: (facts) => ({
type: 'VALIDATE_SESSION',
token: facts.token,
}),
},
},
resolvers: {
validateSession: {
requirement: 'VALIDATE_SESSION',
resolve: async (req, context) => {
const res = await fetch('/api/auth/validate', {
headers: { Authorization: `Bearer ${req.token}` },
});
const data = await res.json();
context.facts.userId = data.userId;
context.facts.role = data.role;
},
},
},
});
const permissions = createModule('permissions', {
schema: {
permissions: t.object<string[]>(),
loaded: t.boolean(),
},
init: (facts) => {
facts.permissions = [];
facts.loaded = false;
},
derive: {
canEdit: (facts) => facts.permissions.includes('content.edit'),
canPublish: (facts) => facts.permissions.includes('content.publish'),
canManageUsers: (facts) => facts.permissions.includes('users.manage'),
canViewAnalytics: (facts) => facts.permissions.includes('analytics.view'),
// Composition: admin inherits all permissions
isAdmin: (facts, derive) => {
return derive.canManageUsers;
},
},
constraints: {
loadPermissions: {
after: ['auth::validateSession'],
crossModuleDeps: ['auth.role'],
when: (facts) => facts.auth.role !== '' && !facts.loaded,
require: (facts) => ({
type: 'FETCH_PERMISSIONS',
role: facts.auth.role,
}),
},
},
resolvers: {
fetchPermissions: {
requirement: 'FETCH_PERMISSIONS',
resolve: async (req, context) => {
const res = await fetch(`/api/permissions?role=${req.role}`);
const data = await res.json();
context.facts.permissions = data.permissions;
context.facts.loaded = true;
},
},
},
});
const content = createModule('content', {
schema: {
articles: t.object<Array<{ id: string; title: string; status: string }>>(),
loaded: t.boolean(),
publishRequested: t.string(),
},
init: (facts) => {
facts.articles = [];
facts.loaded = false;
facts.publishRequested = '';
},
constraints: {
loadContent: {
after: ['permissions::loadPermissions'],
crossModuleDeps: ['permissions.canEdit'],
when: (facts) => !facts.loaded,
require: { type: 'LOAD_CONTENT' },
},
publishArticle: {
crossModuleDeps: ['permissions.canPublish'],
when: (facts) => {
return facts.publishRequested !== '' && facts.permissions.canPublish;
},
require: (facts) => ({
type: 'PUBLISH_ARTICLE',
articleId: facts.publishRequested,
}),
},
},
resolvers: {
loadContent: {
requirement: 'LOAD_CONTENT',
resolve: async (req, context) => {
const res = await fetch('/api/content');
const data = await res.json();
context.facts.articles = data.articles;
context.facts.loaded = true;
},
},
publishArticle: {
requirement: 'PUBLISH_ARTICLE',
resolve: async (req, context) => {
await fetch(`/api/content/${req.articleId}/publish`, { method: 'POST' });
context.facts.articles = context.facts.articles.map((a) =>
a.id === req.articleId ? { ...a, status: 'published' } : a,
);
context.facts.publishRequested = '';
},
},
},
events: {
requestPublish: (facts, { articleId }: { articleId: string }) => {
facts.publishRequested = articleId;
},
},
});
const system = createSystem({
modules: { auth, permissions, content },
});
function ContentList({ system }) {
const articles = useFact(system, 'content::articles');
const canPublish = useDerived(system, 'permissions::canPublish');
const canEdit = useDerived(system, 'permissions::canEdit');
return (
<ul>
{articles.map((article) => (
<li key={article.id}>
<span>{article.title}</span>
<span className="badge">{article.status}</span>
{canEdit && <button>Edit</button>}
{canPublish && article.status === 'draft' && (
<button onClick={() => system.events.requestPublish({ articleId: article.id })}>
Publish
</button>
)}
</li>
))}
</ul>
);
}
function AdminPanel({ system }) {
const canManageUsers = useDerived(system, 'permissions::canManageUsers');
if (!canManageUsers) {
return null;
}
return <UserManagement system={system} />;
}
Step by Step
Permission derivations —
canEdit,canPublish,canManageUsersare computed from thepermissionsarray. Components read these derivations to conditionally render UI elements.Constraint ordering —
loadPermissionsusesafter: ['auth::validateSession']to wait for auth.loadContentusesafter: ['permissions::loadPermissions']to wait for permissions. The chain: auth → permissions → content.Cross-module gating —
publishArticlechecksfacts.permissions.canPublishin itswhenclause. If the user doesn't have publish permission, the constraint never fires even ifpublishRequestedis set.Dynamic constraint disable — for more aggressive gating, use
system.constraints.disable('content::publishArticle')when the user lacks permissions. This is more efficient thanwhenreturning false because it removes the constraint from evaluation entirely.Permission inheritance — the
isAdminderivation composes other permission derivations. Admin UI checksisAdmininstead of individual permissions.
Common Variations
Dynamic permissions from API
constraints: {
refreshPermissions: {
when: (facts) => facts.permissionsStale,
require: { type: 'FETCH_PERMISSIONS' },
},
},
Feature gating with constraint disable
// Disable entire feature modules based on permissions
effects: {
gateFeatures: {
deps: ['loaded'],
run: (facts) => {
// Constraint gating is handled by crossModuleDeps — constraints that
// read permissions facts automatically skip when permissions are absent.
// For explicit disable, call system.constraints.disable() from outside the effect.
},
},
},
Permission inheritance hierarchy
derive: {
effectivePermissions: (facts) => {
const rolePermissions = {
admin: ['users.manage', 'content.publish', 'content.edit', 'analytics.view'],
editor: ['content.publish', 'content.edit', 'analytics.view'],
viewer: ['analytics.view'],
};
const base = rolePermissions[facts.role] || [];
return [...new Set([...base, ...facts.permissions])];
},
},
Related
- Interactive Example — try it in your browser
- Authentication Flow — login and session management
- Async Chains — cross-module
afterpatterns - Dynamic Modules — loading modules conditionally
- Constraints —
disable()and cross-module deps

