# Payload CMS Access Control - Advanced Patterns Advanced access control patterns including context-aware access, time-based restrictions, factory functions, and production templates. ## Context-Aware Access Patterns ### Locale-Specific Access Control access based on user locale for internationalized content. ```ts 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 } // Usage in collection export const Posts: CollectionConfig = { slug: 'posts', access: { read: localeSpecificAccess, }, fields: [{ name: 'title', type: 'text', localized: true }], } ``` **Source**: `docs/access-control/overview.mdx` (req.locale argument) ### Device-Specific Access Restrict access based on device type or user agent. ```ts import type { Access } from 'payload' 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) } // Usage export const MobileContent: CollectionConfig = { slug: 'mobile-content', access: { read: mobileOnlyAccess, }, fields: [{ name: 'title', type: 'text' }], } ``` **Source**: Synthesized (headers pattern) ### IP-Based Access Restrict access from specific IP addresses (requires middleware/proxy headers). ```ts import type { Access } from 'payload' 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), }, fields: [{ name: 'content', type: 'richText' }], } ``` **Note**: Requires your server to pass IP address via headers (common with proxies/load balancers). **Source**: Synthesized (headers pattern) ## Time-Based Access Patterns ### Today's Records Only ```ts import type { Access } from 'payload' 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(), }, } } ``` **Source**: `test/access-control/config.ts` (query constraint patterns) ### Recent Records (Last N Days) ```ts import type { Access } from 'payload' 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), }, fields: [{ name: 'message', type: 'text' }], } ``` ### Scheduled Content (Publish Date Range) ```ts import type { Access } from 'payload' 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 } }], }, ], } } ``` **Source**: Synthesized (query constraint + date patterns) ## Subscription-Based Access ### Active Subscription Required ```ts import type { Access } from 'payload' 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 } } // Usage export const PremiumContent: CollectionConfig = { slug: 'premium-content', access: { read: activeSubscriptionAccess, }, fields: [{ name: 'title', type: 'text' }], } ``` ### Subscription Tier-Based Access ```ts import type { Access } from 'payload' 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'), }, fields: [{ name: 'feature', type: 'text' }], } ``` **Source**: Synthesized (async + cross-collection pattern) ## Factory Functions Reusable functions that generate access control configurations. ### createRoleBasedAccess Generate access control for specific roles. ```ts import type { Access } from 'payload' 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 const Posts: CollectionConfig = { slug: 'posts', access: { create: adminOrEditor, update: adminOrEditor, delete: moderatorAccess, }, fields: [{ name: 'title', type: 'text' }], } ``` **Source**: `test/access-control/config.ts` ### createOrgScopedAccess Generate organization-scoped access with optional admin bypass. ```ts import type { Access } from 'payload' 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 const Projects: CollectionConfig = { slug: 'projects', access: { read: orgScoped, update: orgScoped, delete: strictOrgScoped, }, fields: [ { name: 'title', type: 'text' }, { name: 'organizationId', type: 'text', required: true }, ], } ``` **Source**: `test/access-control/config.ts` ### createTeamBasedAccess Generate team-scoped access with configurable field name. ```ts import type { Access } from 'payload' 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 || [] }, } } } // Usage with custom field name const projectTeamAccess = createTeamBasedAccess('projectTeam') export const Tasks: CollectionConfig = { slug: 'tasks', access: { read: projectTeamAccess, update: projectTeamAccess, }, fields: [ { name: 'title', type: 'text' }, { name: 'projectTeam', type: 'text', required: true }, ], } ``` **Source**: Synthesized (org pattern variation) ### createTimeLimitedAccess Generate access limited to records within specified days. ```ts import type { Access } from 'payload' 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(), }, } } } // Usage: Users see 90 days, admins see all export const ActivityLogs: CollectionConfig = { slug: 'activity-logs', access: { read: createTimeLimitedAccess(90), }, fields: [{ name: 'action', type: 'text' }], } ``` **Source**: Synthesized (time + query pattern) ## Configuration Templates Complete collection configurations for common scenarios. ### Basic Authenticated Collection ```ts import type { CollectionConfig } from 'payload' export const BasicCollection: CollectionConfig = { slug: 'basic-collection', access: { create: ({ req: { user } }) => Boolean(user), read: ({ req: { user } }) => Boolean(user), update: ({ req: { user } }) => Boolean(user), delete: ({ req: { user } }) => Boolean(user), }, fields: [ { name: 'title', type: 'text', required: true }, { name: 'content', type: 'richText' }, ], } ``` **Source**: `docs/access-control/collections.mdx` ### Public + Authenticated Collection ```ts import type { CollectionConfig } from 'payload' 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' }, ], } ``` **Source**: `templates/website/src/collections/Posts/index.ts` ### Multi-User/Self-Service Collection ```ts import type { CollectionConfig } from 'payload' 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, }, }, ], } ``` **Source**: `templates/website/src/collections/Users/index.ts` ## Debugging Tips ### Log Access Check Execution ```ts 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 } ``` ### Verify Arguments Availability ```ts 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 } ``` ### Measure Async Operation Timing ```ts export const timedAsyncAccess: Access = async ({ req }) => { const start = Date.now() const result = await fetch('https://auth-service.example.com/validate', { headers: { userId: req.user?.id }, }) console.log(`Access check took ${Date.now() - start}ms`) return result.ok } ``` ### Test Access Without User ```ts // 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) ``` **Source**: Synthesized (debugging best practices) ## Performance Considerations ### Async Operations Impact ```ts // ❌ 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 } ``` ### Query Constraint Optimization ```ts // ❌ 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 }) ``` ### Field Access on Large Arrays ```ts // ❌ Slow: Complex access on array fields const arrayField: ArrayField = { 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 const optimizedArrayField: ArrayField = { 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 }, }, }, ], } ``` ### Avoid N+1 Queries ```ts // ❌ 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 } } } ``` **Performance Best Practices:** 1. **Minimize Async Operations**: Use query constraints over async lookups when possible 2. **Cache Expensive Checks**: Store results in `req.context` for reuse 3. **Index Query Fields**: Ensure fields in query constraints are indexed 4. **Avoid Complex Logic in Array Fields**: Simple boolean checks preferred 5. **Use Query Constraints**: Let database filter rather than loading all records **Source**: Synthesized (operational best practices) ## Enhanced Best Practices Comprehensive security and implementation guidelines: 1. **Default Deny**: Start with restrictive access, gradually add permissions 2. **Type Guards**: Use TypeScript for user type safety and better IDE support 3. **Validate Data**: Never trust frontend-provided IDs or data 4. **Async for Critical Checks**: Use async operations for important security decisions 5. **Consistent Logic**: Apply same rules at field and collection levels 6. **Test Edge Cases**: Test with no user, wrong user, admin user scenarios 7. **Monitor Access**: Log failed access attempts for security review 8. **Regular Audit**: Review access rules quarterly or after major changes 9. **Cache Wisely**: Use `req.context` for expensive operations 10. **Document Intent**: Add comments explaining complex access rules 11. **Avoid Secrets in Client**: Never expose sensitive logic to client-side 12. **Rate Limit External Calls**: Protect against DoS on external validation services 13. **Handle Errors Gracefully**: Access functions should return `false` on error, not throw 14. **Use Environment Vars**: Store configuration (IPs, API keys) in env vars 15. **Test Local API**: Remember to set `overrideAccess: false` when testing 16. **Consider Performance**: Measure impact of async operations on login time 17. **Version Control**: Track access control changes in git history 18. **Principle of Least Privilege**: Grant minimum access required for functionality **Sources**: `docs/access-control/*.mdx`, synthesized best practices