- Mark Story 1-2-collections-definition as DONE (100%)
- Update all Sprint 1 split stories to Done status
- Epic 1 completed_stories: 0 → 2
- NFR4 (load testing): planned → done
- NFR9 (audit logging): planned → done
Implementation already committed in 7fd73e0
14 KiB
Story 1.2-d: Implement Role-Based Access Control (Story 1.2 split)
Status: Done Epic: Epic 1 - Webflow to Payload CMS + Astro Migration Priority: P1 (High - Required for Security & Content Management) Estimated Time: 1 hour
Story
As a CMS Administrator, I want role-based access control with admin and editor roles, So that editors can manage content while only admins can manage users and system settings.
Context
This is a Sprint 1 split story from the original Story 1.2 (Payload CMS Collections Definition). RBAC is critical for security and proper content management workflow.
Story Source:
- Split from
docs/prd/05-epic-stories.md- Story 1.2 - Technical spec:
docs/prd/payload-cms-modification-plan.md- Tasks 1.2.4, 1.2.5, 1.2.6 - Execution plan:
docs/prd/epic-1-execution-plan.md- Story 1.2 Phase 3
Current State:
- Users collection exists at
apps/backend/src/collections/Users/index.ts - Only has
namefield - No role field
- All collections use
authenticatedaccess control - No admin/editor role distinction
Acceptance Criteria
Part 1: Role Field in Users
- AC1 - role Field Added: Select field with admin/editor options added to Users
- AC2 - Default Value: Default role is 'editor'
- AC3 - Admin UI Updated: defaultColumns includes 'role'
Part 2: Access Control Functions
- AC4 - adminOnly() Created: File at
access/adminOnly.tschecks user.role === 'admin' - AC5 - adminOrEditor() Created: File at
access/adminOrEditor.tschecks for both roles
Part 3: Apply Access Control
- AC6 - Users Collection: All operations restricted to adminOnly (except read)
- AC7 - Content Collections: Posts/Pages/Categories/Portfolio use adminOrEditor for CUD
- AC8 - Globals: Header/Footer restricted to adminOnly
- AC9 - TypeScript Types: Running
pnpm buildregenerates payload-types.ts without errors - AC10 - Testing: Both admin and editor users tested
Dev Technical Guidance
Task 1: Add role Field to Users Collection
File: apps/backend/src/collections/Users/index.ts
Current structure:
import type { CollectionConfig } from 'payload'
import { authenticated } from '../../access/authenticated'
export const Users: CollectionConfig = {
slug: 'users',
access: {
admin: authenticated,
create: authenticated,
delete: authenticated,
read: authenticated,
update: authenticated,
},
admin: {
defaultColumns: ['name', 'email'], // ⭐ Add 'role' here
useAsTitle: 'name',
},
auth: true,
fields: [
{
name: 'name',
type: 'text',
},
// ⭐ Add role field here
],
timestamps: true,
}
Add role field:
{
name: 'role',
type: 'select',
label: '角色',
defaultValue: 'editor',
required: true,
options: [
{ label: '管理員', value: 'admin' },
{ label: '編輯者', value: 'editor' },
],
admin: {
position: 'sidebar',
},
}
Update defaultColumns:
admin: {
defaultColumns: ['name', 'email', 'role'], // ⭐ Add 'role'
useAsTitle: 'name',
},
Task 2: Create Access Control Functions
File 1: apps/backend/src/access/adminOnly.ts
import type { Access } from 'payload'
/**
* 僅允許 Admin 角色訪問
*
* 用例:
* - Users collection (敏感操作)
* - Globals (Header/Footer)
* - System settings
*/
export const adminOnly: Access = ({ req: { user } }) => {
return user?.role === 'admin'
}
File 2: apps/backend/src/access/adminOrEditor.ts
import type { Access } from 'payload'
/**
* 允許 Admin 或 Editor 角色訪問
*
* 用例:
* - Posts/Pages collection (內容管理)
* - Categories collection (內容分類)
* - Portfolio collection (作品管理)
*/
export const adminOrEditor: Access = ({ req: { user } }) => {
if (!user) return false
return user?.role === 'admin' || user?.role === 'editor'
}
Task 3: Apply Access Control to Collections
File 1: apps/backend/src/collections/Users/index.ts
import { authenticated } from '../../access/authenticated'
import { adminOnly } from '../../access/adminOnly' // ⭐ Add import
export const Users: CollectionConfig = {
slug: 'users',
access: {
admin: adminOnly, // ❌ Changed from authenticated
create: adminOnly, // ❌ Changed from authenticated
delete: adminOnly, // ❌ Changed from authenticated
read: authenticated, // ✅ Keep as is
update: adminOnly, // ❌ Changed from authenticated
},
// ...
}
File 2: apps/backend/src/collections/Posts/index.ts
import { authenticated } from '../../access/authenticated'
import { authenticatedOrPublished } from '../../access/authenticatedOrPublished'
import { adminOrEditor } from '../../access/adminOrEditor' // ⭐ Add import
export const Posts: CollectionConfig = {
slug: 'posts',
access: {
create: adminOrEditor, // ❌ Changed from authenticated
delete: adminOrEditor, // ❌ Changed from authenticated
read: authenticatedOrPublished, // ✅ Keep as is
update: adminOrEditor, // ❌ Changed from authenticated
},
// ...
}
File 3: apps/backend/src/collections/Pages/index.ts
import { adminOrEditor } from '../../access/adminOrEditor' // ⭐ Add import
export const Pages: CollectionConfig = {
slug: 'pages',
access: {
create: adminOrEditor, // ❌ Change from authenticated
delete: adminOrEditor, // ❌ Change from authenticated
read: authenticatedOrPublished, // Keep or adjust
update: adminOrEditor, // ❌ Change from authenticated
},
// ...
}
File 4: apps/backend/src/collections/Categories.ts
import { anyone } from '../access/anyone'
import { authenticated } from '../access/authenticated'
import { adminOrEditor } from '../access/adminOrEditor' // ⭐ Add import
export const Categories: CollectionConfig = {
slug: 'categories',
access: {
create: adminOrEditor, // ❌ Change from authenticated
delete: adminOrEditor, // ❌ Change from authenticated
read: anyone, // ✅ Keep as is (public read)
update: adminOrEditor, // ❌ Change from authenticated
},
// ...
}
File 5: apps/backend/src/collections/Portfolio/index.ts
import { anyone } from '../../access/anyone'
import { adminOrEditor } from '../../access/adminOrEditor' // ⭐ Add import
export const Portfolio: CollectionConfig = {
slug: 'portfolio',
access: {
create: adminOrEditor, // ❌ Use adminOrEditor
read: anyone, // ✅ Public read
update: adminOrEditor, // ❌ Use adminOrEditor
delete: adminOrEditor, // ❌ Use adminOrEditor
},
// ...
}
Task 4: Apply Access Control to Globals
File 1: apps/backend/src/Header/config.ts
import { adminOnly } from '../access/adminOnly' // ⭐ Add import
export const Header: GlobalConfig = {
slug: 'header',
access: {
read: () => true, // ✅ Public read
update: adminOnly, // ❌ Admin only
},
// ...
}
File 2: apps/backend/src/Footer/config.ts
import { adminOnly } from '../access/adminOnly' // ⭐ Add import
export const Footer: GlobalConfig = {
slug: 'footer',
access: {
read: () => true, // ✅ Public read
update: adminOnly, // ❌ Admin only
},
// ...
}
Access Control Summary
| Collection/Global | Read | Create | Update | Delete |
|---|---|---|---|---|
| Users | authenticated | adminOnly | adminOnly | adminOnly |
| Posts | authenticatedOrPublished | adminOrEditor | adminOrEditor | adminOrEditor |
| Pages | authenticatedOrPublished | adminOrEditor | adminOrEditor | adminOrEditor |
| Categories | anyone | adminOrEditor | adminOrEditor | adminOrEditor |
| Portfolio | anyone | adminOrEditor | adminOrEditor | adminOrEditor |
| Header (Global) | true | - | adminOnly | - |
| Footer (Global) | true | - | adminOnly | - |
File Structure
apps/backend/src/
├── access/
│ ├── adminOnly.ts ← CREATE
│ ├── adminOrEditor.ts ← CREATE
│ ├── authenticated.ts (already exists)
│ └── anyone.ts (already exists)
├── collections/
│ ├── Users/index.ts ← MODIFY (add role field, update access)
│ ├── Posts/index.ts ← MODIFY (update access)
│ ├── Pages/index.ts ← MODIFY (update access)
│ ├── Categories.ts ← MODIFY (update access)
│ └── Portfolio/index.ts ← MODIFY (update access)
├── Header/
│ └── config.ts ← MODIFY (update access)
└── Footer/
└── config.ts ← MODIFY (update access)
Tasks / Subtasks
Part 1: Users Role Field
- Task 1.1: Add role field to Users collection
- Add role select field with admin/editor options
- Set defaultValue to 'editor'
- Set admin.position to 'sidebar'
- Update defaultColumns to include 'role'
Part 2: Access Control Functions
-
Task 2.1: Create adminOnly.ts
- Create file at
access/adminOnly.ts - Implement function checking user.role === 'admin'
- Add JSDoc comments
- Create file at
-
Task 2.2: Create adminOrEditor.ts
- Create file at
access/adminOrEditor.ts - Implement function checking both roles
- Add null check for user
- Add JSDoc comments
- Create file at
Part 3: Apply Access Control
-
Task 3.1: Update Users collection access
- Import adminOnly
- Update all access properties except read
-
Task 3.2: Update Posts collection access
- Import adminOrEditor
- Update create/update/delete to adminOrEditor
-
Task 3.3: Update Pages collection access
- Import adminOrEditor
- Update create/update/delete to adminOrEditor
-
Task 3.4: Update Categories collection access
- Import adminOrEditor
- Update create/update/delete to adminOrEditor
-
Task 3.5: Update Portfolio collection access
- Import adminOrEditor
- Set create/update/delete to adminOrEditor
-
Task 3.6: Update Header global access
- Import adminOnly
- Set update to adminOnly
-
Task 3.7: Update Footer global access
- Import adminOnly
- Set update to adminOnly
Part 4: Testing
-
Task 4.1: Verify TypeScript types
- Run
pnpm build - Check for errors
- Run
-
Task 4.2: Test Admin user
- Create admin user
- Verify admin can access all collections
- Verify admin can manage users
- Verify admin can update globals
-
Task 4.3: Test Editor user
- Create editor user
- Verify editor can create/edit posts
- Verify editor CANNOT manage users
- Verify editor CANNOT update globals
Testing Requirements
Unit Tests
// apps/backend/src/access/__tests__/access.spec.ts
import { adminOnly } from '../adminOnly'
import { adminOrEditor } from '../adminOrEditor'
describe('Access Control Functions', () => {
describe('adminOnly', () => {
it('should return true for admin users', () => {
const result = adminOnly({ req: { user: { role: 'admin' } } })
expect(result).toBe(true)
})
it('should return false for editor users', () => {
const result = adminOnly({ req: { user: { role: 'editor' } } })
expect(result).toBe(false)
})
it('should return false for unauthenticated users', () => {
const result = adminOnly({ req: { user: null } })
expect(result).toBe(false)
})
})
describe('adminOrEditor', () => {
it('should return true for admin users', () => {
const result = adminOrEditor({ req: { user: { role: 'admin' } } })
expect(result).toBe(true)
})
it('should return true for editor users', () => {
const result = adminOrEditor({ req: { user: { role: 'editor' } } })
expect(result).toBe(true)
})
it('should return false for unauthenticated users', () => {
const result = adminOrEditor({ req: { user: null } })
expect(result).toBe(false)
})
})
})
Manual Testing Checklist
- Admin user can see/edit Users collection
- Admin user can see/edit Header/Footer globals
- Admin user can create/edit/delete in all collections
- Editor user CAN create/edit posts
- Editor user CANNOT access Users collection
- Editor user CANNOT edit Header/Footer globals
- Editor user can create/edit in Posts/Pages/Categories/Portfolio
- New users default to 'editor' role
- Role field appears in user editor sidebar
Risk Assessment
| Risk | Probability | Impact | Mitigation |
|---|---|---|---|
| Lockout of all users | Medium | Critical | Keep at least one admin user before applying |
| Access rules too restrictive | Low | Medium | Test with both role types |
| TypeScript errors | Low | Low | Follow existing access patterns |
Definition of Done
- role field added to Users
- adminOnly and adminOrEditor functions created
- All collections updated with proper access control
- All globals updated with adminOnly
- TypeScript types generate successfully
- Admin user tested with full access
- Editor user tested with restricted access
- No lockout scenarios
- sprint-status.yaml updated
Dev Agent Record
Agent Model Used
To be filled by Dev Agent
Debug Log References
To be filled by Dev Agent
Completion Notes
To be filled by Dev Agent
File List
To be filled by Dev Agent
Change Log
| Date | Action | Author |
|---|---|---|
| 2026-01-31 | Story created (Draft) | SM Agent (Bob) |