title: Access Control - Advanced Patterns description: Context-aware, time-based, subscription-based access, factory functions, templates tags: [payload, access-control, security, advanced, performance]
Advanced access control patterns including context-aware access, time-based restrictions, factory functions, and production templates.
import type { Access } from 'payload'
export const localeSpecificAccess: Access = ({ req: { user, locale } }) => {
// Authenticated users can access all locales
if (user) return true
// Public users can only access English content
if (locale === 'en') return true
return false
}
export const mobileOnlyAccess: Access = ({ req: { headers } }) => {
const userAgent = headers?.get('user-agent') || ''
return /mobile|android|iphone/i.test(userAgent)
}
export const desktopOnlyAccess: Access = ({ req: { headers } }) => {
const userAgent = headers?.get('user-agent') || ''
return !/mobile|android|iphone/i.test(userAgent)
}
export const restrictedIpAccess = (allowedIps: string[]): Access => {
return ({ req: { headers } }) => {
const ip = headers?.get('x-forwarded-for') || headers?.get('x-real-ip')
return allowedIps.includes(ip || '')
}
}
// Usage
const internalIps = ['192.168.1.0/24', '10.0.0.5']
export const InternalDocs: CollectionConfig = {
slug: 'internal-docs',
access: {
read: restrictedIpAccess(internalIps),
},
}
export const todayOnlyAccess: Access = ({ req: { user } }) => {
if (!user) return false
const now = new Date()
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000)
return {
createdAt: {
greater_than_equal: startOfDay.toISOString(),
less_than: endOfDay.toISOString(),
},
}
}
export const recentRecordsAccess = (days: number): Access => {
return ({ req: { user } }) => {
if (!user) return false
if (user.roles?.includes('admin')) return true
const cutoff = new Date()
cutoff.setDate(cutoff.getDate() - days)
return {
createdAt: {
greater_than_equal: cutoff.toISOString(),
},
}
}
}
// Usage: Users see only last 30 days, admins see all
export const Logs: CollectionConfig = {
slug: 'logs',
access: {
read: recentRecordsAccess(30),
},
}
export const scheduledContentAccess: Access = ({ req: { user } }) => {
// Editors see all content
if (user?.roles?.includes('admin') || user?.roles?.includes('editor')) {
return true
}
const now = new Date().toISOString()
// Public sees only content within publish window
return {
and: [
{ publishDate: { less_than_equal: now } },
{
or: [{ unpublishDate: { exists: false } }, { unpublishDate: { greater_than: now } }],
},
],
}
}
export const activeSubscriptionAccess: Access = async ({ req: { user } }) => {
if (!user) return false
if (user.roles?.includes('admin')) return true
try {
const subscription = await req.payload.findByID({
collection: 'subscriptions',
id: user.subscriptionId,
})
return subscription?.status === 'active'
} catch {
return false
}
}
export const tierBasedAccess = (requiredTier: string): Access => {
const tierHierarchy = ['free', 'basic', 'pro', 'enterprise']
return async ({ req: { user } }) => {
if (!user) return false
if (user.roles?.includes('admin')) return true
try {
const subscription = await req.payload.findByID({
collection: 'subscriptions',
id: user.subscriptionId,
})
if (subscription?.status !== 'active') return false
const userTierIndex = tierHierarchy.indexOf(subscription.tier)
const requiredTierIndex = tierHierarchy.indexOf(requiredTier)
return userTierIndex >= requiredTierIndex
} catch {
return false
}
}
}
// Usage
export const EnterpriseFeatures: CollectionConfig = {
slug: 'enterprise-features',
access: {
read: tierBasedAccess('enterprise'),
},
}
export function createRoleBasedAccess(roles: string[]): Access {
return ({ req: { user } }) => {
if (!user) return false
return roles.some((role) => user.roles?.includes(role))
}
}
// Usage
const adminOrEditor = createRoleBasedAccess(['admin', 'editor'])
const moderatorAccess = createRoleBasedAccess(['admin', 'moderator'])
export function createOrgScopedAccess(allowAdmin = true): Access {
return ({ req: { user } }) => {
if (!user) return false
if (allowAdmin && user.roles?.includes('admin')) return true
return {
organizationId: { in: user.organizationIds || [] },
}
}
}
// Usage
const orgScoped = createOrgScopedAccess() // Admins bypass
const strictOrgScoped = createOrgScopedAccess(false) // Admins also scoped
export function createTeamBasedAccess(teamField = 'teamId'): Access {
return ({ req: { user } }) => {
if (!user) return false
if (user.roles?.includes('admin')) return true
return {
[teamField]: { in: user.teamIds || [] },
}
}
}
export function createTimeLimitedAccess(daysAccess: number): Access {
return ({ req: { user } }) => {
if (!user) return false
if (user.roles?.includes('admin')) return true
const cutoff = new Date()
cutoff.setDate(cutoff.getDate() - daysAccess)
return {
createdAt: {
greater_than_equal: cutoff.toISOString(),
},
}
}
}
export const PublicAuthCollection: CollectionConfig = {
slug: 'posts',
access: {
// Only admins/editors can create
create: ({ req: { user } }) => {
return user?.roles?.some((role) => ['admin', 'editor'].includes(role)) || false
},
// Authenticated users see all, public sees only published
read: ({ req: { user } }) => {
if (user) return true
return { _status: { equals: 'published' } }
},
// Only admins/editors can update
update: ({ req: { user } }) => {
return user?.roles?.some((role) => ['admin', 'editor'].includes(role)) || false
},
// Only admins can delete
delete: ({ req: { user } }) => {
return user?.roles?.includes('admin') || false
},
},
versions: {
drafts: true,
},
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'content', type: 'richText', required: true },
{ name: 'author', type: 'relationship', relationTo: 'users' },
],
}
export const SelfServiceCollection: CollectionConfig = {
slug: 'users',
auth: true,
access: {
// Admins can create users
create: ({ req: { user } }) => user?.roles?.includes('admin') || false,
// Anyone can read user profiles
read: () => true,
// Users can update self, admins can update anyone
update: ({ req: { user }, id }) => {
if (!user) return false
if (user.roles?.includes('admin')) return true
return user.id === id
},
// Only admins can delete
delete: ({ req: { user } }) => user?.roles?.includes('admin') || false,
},
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'email', type: 'email', required: true },
{
name: 'roles',
type: 'select',
hasMany: true,
options: ['admin', 'editor', 'user'],
access: {
// Only admins can read/update roles
read: ({ req: { user } }) => user?.roles?.includes('admin') || false,
update: ({ req: { user } }) => user?.roles?.includes('admin') || false,
},
},
],
}
// ❌ Slow: Multiple sequential async calls
export const slowAccess: Access = async ({ req: { user } }) => {
const org = await req.payload.findByID({ collection: 'orgs', id: user.orgId })
const team = await req.payload.findByID({ collection: 'teams', id: user.teamId })
const subscription = await req.payload.findByID({ collection: 'subs', id: user.subId })
return org.active && team.active && subscription.active
}
// ✅ Fast: Use query constraints or cache in context
export const fastAccess: Access = ({ req: { user, context } }) => {
// Cache expensive lookups
if (!context.orgStatus) {
context.orgStatus = checkOrgStatus(user.orgId)
}
return context.orgStatus
}
// ❌ Avoid: Non-indexed fields in constraints
export const slowQuery: Access = () => ({
'metadata.internalCode': { equals: 'ABC123' }, // Slow if not indexed
})
// ✅ Better: Use indexed fields
export const fastQuery: Access = () => ({
status: { equals: 'active' }, // Indexed field
organizationId: { in: ['org1', 'org2'] }, // Indexed field
})
// ❌ Slow: Complex access on array fields
{
name: 'items',
type: 'array',
fields: [
{
name: 'secretData',
type: 'text',
access: {
read: async ({ req }) => {
// Async call runs for EVERY array item
const result = await expensiveCheck()
return result
},
},
},
],
}
// ✅ Fast: Simple checks or cache result
{
name: 'items',
type: 'array',
fields: [
{
name: 'secretData',
type: 'text',
access: {
read: ({ req: { user }, context }) => {
// Cache once, reuse for all items
if (context.canReadSecret === undefined) {
context.canReadSecret = user?.roles?.includes('admin')
}
return context.canReadSecret
},
},
},
],
}
// ❌ N+1 Problem: Query per access check
export const n1Access: Access = async ({ req, id }) => {
// Runs for EACH document in list
const doc = await req.payload.findByID({ collection: 'docs', id })
return doc.isPublic
}
// ✅ Better: Use query constraint to filter at DB level
export const efficientAccess: Access = () => {
return { isPublic: { equals: true } }
}
export const debugAccess: Access = ({ req: { user }, id }) => {
console.log('Access check:', {
userId: user?.id,
userRoles: user?.roles,
docId: id,
timestamp: new Date().toISOString(),
})
return true
}
export const checkArgsAccess: Access = (args) => {
console.log('Available arguments:', {
hasReq: 'req' in args,
hasUser: args.req?.user ? 'yes' : 'no',
hasId: args.id ? 'provided' : 'undefined',
hasData: args.data ? 'provided' : 'undefined',
})
return true
}
// In test/development
const testAccess = await payload.find({
collection: 'posts',
overrideAccess: false, // Enforce access control
user: undefined, // Simulate no user
})
console.log('Public access result:', testAccess.docs.length)
req.context for expensive operationsfalse on error, not throwoverrideAccess: false when testingMinimize Async Operations: Use query constraints over async lookups when possible
Cache Expensive Checks: Store results in req.context for reuse
Index Query Fields: Ensure fields in query constraints are indexed
Avoid Complex Logic in Array Fields: Simple boolean checks preferred
Use Query Constraints: Let database filter rather than loading all records