--- title: Access Control - Advanced Patterns description: Context-aware, time-based, subscription-based access, factory functions, templates tags: [payload, access-control, security, advanced, performance] priority: high --- # Advanced Access Control Patterns Advanced access control patterns including context-aware access, time-based restrictions, factory functions, and production templates. ## Context-Aware Access Patterns ### Locale-Specific Access ```typescript 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 } ``` ### Device-Specific Access ```typescript 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) } ``` ### IP-Based Access ```typescript 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), }, } ``` ## Time-Based Access Patterns ### Today's Records Only ```typescript 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(), }, } } ``` ### Recent Records (Last N Days) ```typescript 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), }, } ``` ### Scheduled Content (Publish Date Range) ```typescript 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 } }], }, ], } } ``` ## Subscription-Based Access ### Active Subscription Required ```typescript 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 } } ``` ### Subscription Tier-Based Access ```typescript 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'), }, } ``` ## Factory Functions ### createRoleBasedAccess ```typescript 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']) ``` ### createOrgScopedAccess ```typescript 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 ``` ### createTeamBasedAccess ```typescript 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 || [] }, } } } ``` ### createTimeLimitedAccess ```typescript 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(), }, } } } ``` ## Configuration Templates ### Public + Authenticated Collection ```typescript 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' }, ], } ``` ### Self-Service Collection ```typescript 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, }, }, ], } ``` ## Performance Considerations ### Avoid Async Operations in Hot Paths ```typescript // ❌ 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 ```typescript // ❌ 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 ```typescript // ❌ 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 }, }, }, ], } ``` ### Avoid N+1 Queries ```typescript // ❌ 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 } } } ``` ## Debugging Tips ### Log Access Check Execution ```typescript 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 ```typescript 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 } ``` ### Test Access Without User ```typescript // 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) ``` ## Best Practices 1. **Default Deny**: Start with restrictive access, gradually add permissions 2. **Type Guards**: Use TypeScript for user type safety 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. **Handle Errors Gracefully**: Access functions should return `false` on error, not throw 13. **Test Local API**: Remember to set `overrideAccess: false` when testing 14. **Consider Performance**: Measure impact of async operations 15. **Principle of Least Privilege**: Grant minimum access required ## Performance Summary **Minimize 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