Access Control Setup in KeystoneJS
KeystoneJS provides multi-level access management: at operation level (CRUD), individual items, and specific fields. Granularity allows implementing any role model without external libraries.
Access Levels
Operation Access — allow/deny operation entirely (before fetching from DB):
access: {
operation: {
query: ({ session }) => !!session, // only authorized
create: ({ session }) => session?.data?.role === 'editor',
update: ({ session }) => ['editor', 'admin'].includes(session?.data?.role),
delete: ({ session }) => session?.data?.role === 'admin',
},
},
Filter Access — restrict visible data via automatic WHERE filter:
access: {
filter: {
// Authors see only their posts, admin — all
query: ({ session }) => {
if (session?.data?.role === 'admin') return true;
return { author: { id: { equals: session?.data?.id } } };
},
update: ({ session }) => {
if (session?.data?.role === 'admin') return true;
return { author: { id: { equals: session?.data?.id } } };
},
},
},
Item Access — check for specific item (after fetching):
access: {
item: {
update: async ({ session, item }) => {
if (session?.data?.role === 'admin') return true;
// Can edit only drafts
return item.status === 'draft' && item.authorId === session?.data?.id;
},
delete: async ({ session, item }) => {
return session?.data?.role === 'admin' || item.authorId === session?.data?.id;
},
},
},
Field-Level Access Control
fields: {
title: text(),
// Regular editors don't see internal notes
internalNotes: text({
access: {
read: ({ session }) => session?.data?.role === 'admin',
create: ({ session }) => session?.data?.role === 'admin',
update: ({ session }) => session?.data?.role === 'admin',
},
}),
// Salary — only HR and admin
salary: integer({
access: {
read: ({ session }) => ['admin', 'hr'].includes(session?.data?.role),
update: ({ session }) => session?.data?.role === 'admin',
},
}),
},
Role Model via Database
Instead of hardcoding roles — store permissions in DB:
// lists/Role.ts
export const Role = list({
access: {
operation: {
query: allowAll,
create: ({ session }) => session?.data?.role === 'admin',
update: ({ session }) => session?.data?.role === 'admin',
delete: ({ session }) => session?.data?.role === 'admin',
},
},
fields: {
name: text({ validation: { isRequired: true }, isIndexed: 'unique' }),
canManagePosts: checkbox({ defaultValue: false }),
canManageUsers: checkbox({ defaultValue: false }),
canManageRoles: checkbox({ defaultValue: false }),
canPublish: checkbox({ defaultValue: false }),
users: relationship({ ref: 'User.role', many: true }),
},
});
// lists/Post.ts — use role permissions from DB
access: {
operation: {
create: ({ session }) => !!session?.data?.role?.canManagePosts,
update: ({ session }) => !!session?.data?.role?.canManagePosts,
delete: ({ session }) => !!session?.data?.role?.canManagePosts,
},
},
Include needed role fields in sessionData:
// auth.ts
sessionData: 'id name email role { canManagePosts canManageUsers canPublish }',
Public API for Frontend
Part of data should be publicly accessible for headless:
// Helper for mixed access
const isSignedIn = ({ session }) => !!session;
const isAdmin = ({ session }) => session?.data?.role === 'admin';
const isPublicOrSignedIn = ({ session }) => true; // open to all
export const Article = list({
access: {
operation: {
query: isPublicOrSignedIn, // articles read by all
create: isSignedIn,
update: isAdmin,
delete: isAdmin,
},
filter: {
query: ({ session }) => {
if (session?.data?.role === 'admin') return true;
return { status: { equals: 'published' } }; // guests and users — only published
},
},
},
});
Typical role model setup (3–4 roles, 5–10 Lists) takes 2–4 days, including testing access scenarios.







