Implement Sprint 1 stories: collections, RBAC, audit logging, load testing
Complete 6 Sprint 1 stories for Epic 1 web migration infrastructure. Portfolio Collection: - Add 7 fields: title, slug, url, image, description, websiteType, tags - Configure R2 storage and authenticated access control Categories Collection: - Add nameEn, order, textColor, backgroundColor fields - Add color picker UI configuration Posts Collection: - Add excerpt with 200 char limit and ogImage for social sharing - Add showInFooter checkbox and status select (draft/review/published) Role-Based Access Control: - Add role field to Users collection (admin/editor) - Create adminOnly and authenticated access functions - Apply access rules to Portfolio, Categories, Posts, Users collections Audit Logging System (NFR9): - Create Audit collection with timestamps for 90-day retention - Add auditLogger utility for login/logout/content change tracking - Add auditChange and auditGlobalChange hooks to all collections and globals - Add cleanupAuditLogs job with 90-day retention policy Load Testing Framework (NFR4): - Add k6 load testing with 3 scripts: public-browsing, admin-operations, api-performance - Configure targets: p95 < 500ms, error rate < 1%, 100 concurrent users - Add verification script and comprehensive documentation Other Changes: - Remove unused Form blocks - Add Header/Footer audit hooks - Regenerate Payload TypeScript types
This commit is contained in:
71
apps/backend/src/collections/Audit/hooks/auditHooks.ts
Normal file
71
apps/backend/src/collections/Audit/hooks/auditHooks.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { AfterChangeHook } from 'payload'
|
||||
|
||||
import { logDocumentChange } from '@/utilities/auditLogger'
|
||||
|
||||
/**
|
||||
* 創建稽核日誌 Hook
|
||||
* 用於追蹤文件的創建、更新、刪除操作
|
||||
*/
|
||||
export const auditChange =
|
||||
(collection: string): AfterChangeHook =>
|
||||
async ({ doc, req }) => {
|
||||
// 跳過 audit 集合本身以避免無限循環
|
||||
if (collection === 'audit') return doc
|
||||
|
||||
await logDocumentChange(
|
||||
req,
|
||||
operation as 'create' | 'update' | 'delete',
|
||||
collection,
|
||||
doc.id as string,
|
||||
(doc.title || doc.name || String(doc.id)) as string,
|
||||
)
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除稽核日誌 Hook
|
||||
* 用於追蹤文件刪除操作(需要在刪除前記錄標題)
|
||||
*/
|
||||
export const auditDelete =
|
||||
(collection: string): AfterChangeHook =>
|
||||
async ({ doc, req }) => {
|
||||
if (collection === 'audit') return doc
|
||||
|
||||
await logDocumentChange(
|
||||
req,
|
||||
'delete',
|
||||
collection,
|
||||
doc.id as string,
|
||||
(doc.title || doc.name || String(doc.id)) as string,
|
||||
)
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
/**
|
||||
* Global 稽核日誌 Hook
|
||||
* 用於追蹤 Global 配置的變更
|
||||
*/
|
||||
export const auditGlobalChange =
|
||||
(global: string): AfterChangeHook =>
|
||||
async ({ req, previousDoc, doc }) => {
|
||||
if (!req.user) return doc
|
||||
|
||||
const { auditLogger } = await import('@/utilities/auditLogger')
|
||||
|
||||
await auditLogger(req, {
|
||||
action: 'update',
|
||||
collection: `global_${global}`,
|
||||
userId: req.user.id,
|
||||
userName: req.user.name as string,
|
||||
userEmail: req.user.email as string,
|
||||
userRole: String(req.user.role ?? ''),
|
||||
changes: {
|
||||
previous: previousDoc,
|
||||
current: doc,
|
||||
},
|
||||
})
|
||||
|
||||
return doc
|
||||
}
|
||||
108
apps/backend/src/collections/Audit/index.ts
Normal file
108
apps/backend/src/collections/Audit/index.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { adminOnly } from '../../access/adminOnly'
|
||||
|
||||
export const Audit: CollectionConfig = {
|
||||
slug: 'audit',
|
||||
access: {
|
||||
create: () => false, // 僅透過程式自動創建
|
||||
delete: adminOnly,
|
||||
read: adminOnly,
|
||||
update: () => false, // 不允許修改日誌
|
||||
},
|
||||
admin: {
|
||||
defaultColumns: ['timestamp', 'action', 'collection', 'userId'],
|
||||
useAsTitle: 'action',
|
||||
description: '系統稽核日誌 - 自動記錄所有重要操作',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'action',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: '登入', value: 'login' },
|
||||
{ label: '登出', value: 'logout' },
|
||||
{ label: '創建', value: 'create' },
|
||||
{ label: '更新', value: 'update' },
|
||||
{ label: '刪除', value: 'delete' },
|
||||
{ label: '發布', value: 'publish' },
|
||||
{ label: '取消發布', value: 'unpublish' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'collection',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: '受影響的集合名稱',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'documentId',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: '受影響的文件 ID',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'documentTitle',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: '受影響的文件標題',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'userId',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: '執行操作的使用者 ID',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'userName',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: '執行操作的使用者名稱',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'userEmail',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: '執行操作的使用者信箱',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'userRole',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '管理員', value: 'admin' },
|
||||
{ label: '編輯者', value: 'editor' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ipAddress',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'IP 位址',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'userAgent',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: '瀏覽器 User Agent',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'changes',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: '變更內容詳細資訊',
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamps: true,
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { CollectionConfig } from 'payload'
|
||||
import { anyone } from '../access/anyone'
|
||||
import { authenticated } from '../access/authenticated'
|
||||
import { adminOrEditor } from '../access/adminOrEditor'
|
||||
import { auditChange } from './Audit/hooks/auditHooks'
|
||||
import { slugField } from '@/fields/slug'
|
||||
|
||||
export const Categories: CollectionConfig = {
|
||||
@@ -16,6 +17,10 @@ export const Categories: CollectionConfig = {
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
},
|
||||
hooks: {
|
||||
afterChange: [auditChange('categories')],
|
||||
afterDelete: [auditChange('categories')],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { CollectionConfig } from 'payload'
|
||||
import { authenticated } from '../../access/authenticated'
|
||||
import { authenticatedOrPublished } from '../../access/authenticatedOrPublished'
|
||||
import { adminOrEditor } from '../../access/adminOrEditor'
|
||||
import { auditChange } from '../../collections/Audit/hooks/auditHooks'
|
||||
import { Archive } from '../../blocks/ArchiveBlock/config'
|
||||
import { CallToAction } from '../../blocks/CallToAction/config'
|
||||
import { Content } from '../../blocks/Content/config'
|
||||
@@ -123,9 +124,9 @@ export const Pages: CollectionConfig<'pages'> = {
|
||||
...slugField(),
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [revalidatePage],
|
||||
afterChange: [revalidatePage, auditChange('pages')],
|
||||
beforeChange: [populatePublishedAt],
|
||||
afterDelete: [revalidateDelete],
|
||||
afterDelete: [revalidateDelete, auditChange('pages')],
|
||||
},
|
||||
versions: {
|
||||
drafts: {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { anyone } from '../../access/anyone'
|
||||
import { adminOrEditor } from '../../access/adminOrEditor'
|
||||
import { authenticated } from '../../access/authenticated'
|
||||
import { auditChange } from '../../collections/Audit/hooks/auditHooks'
|
||||
import { slugField } from '@/fields/slug'
|
||||
|
||||
export const Portfolio: CollectionConfig = {
|
||||
slug: 'portfolio',
|
||||
access: {
|
||||
create: adminOrEditor,
|
||||
create: authenticated,
|
||||
read: anyone,
|
||||
update: adminOrEditor,
|
||||
delete: adminOrEditor,
|
||||
update: authenticated,
|
||||
delete: authenticated,
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
@@ -66,6 +67,10 @@ export const Portfolio: CollectionConfig = {
|
||||
},
|
||||
...slugField(),
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [auditChange('portfolio')],
|
||||
afterDelete: [auditChange('portfolio')],
|
||||
},
|
||||
versions: {
|
||||
drafts: {
|
||||
autosave: true,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { authenticated } from '../../access/authenticated'
|
||||
import { authenticatedOrPublished } from '../../access/authenticatedOrPublished'
|
||||
import { adminOrEditor } from '../../access/adminOrEditor'
|
||||
import { auditChange } from '../../collections/Audit/hooks/auditHooks'
|
||||
import { Banner } from '../../blocks/Banner/config'
|
||||
import { Code } from '../../blocks/Code/config'
|
||||
import { MediaBlock } from '../../blocks/MediaBlock/config'
|
||||
@@ -263,9 +264,9 @@ export const Posts: CollectionConfig<'posts'> = {
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [revalidatePost],
|
||||
afterChange: [revalidatePost, auditChange('posts')],
|
||||
afterRead: [populateAuthors],
|
||||
afterDelete: [revalidateDelete],
|
||||
afterDelete: [revalidateDelete, auditChange('posts')],
|
||||
},
|
||||
versions: {
|
||||
drafts: {
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
import type { AfterLoginHook, AfterLogoutHook } from 'payload'
|
||||
|
||||
import { adminOnly } from '../../access/adminOnly'
|
||||
import { authenticated } from '../../access/authenticated'
|
||||
import { logLogin, logLogout } from '../../utilities/auditLogger'
|
||||
|
||||
const afterLogin: AfterLoginHook = async ({ req, user }) => {
|
||||
if (user?.id) {
|
||||
await logLogin(req, user.id)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
const afterLogout: AfterLogoutHook = async ({ req }) => {
|
||||
if (req.user?.id) {
|
||||
await logLogout(req, req.user.id)
|
||||
}
|
||||
}
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
@@ -8,7 +24,7 @@ export const Users: CollectionConfig = {
|
||||
admin: adminOnly,
|
||||
create: adminOnly,
|
||||
delete: adminOnly,
|
||||
read: adminOnly,
|
||||
read: authenticated,
|
||||
update: adminOnly,
|
||||
},
|
||||
admin: {
|
||||
@@ -16,6 +32,10 @@ export const Users: CollectionConfig = {
|
||||
useAsTitle: 'name',
|
||||
},
|
||||
auth: true,
|
||||
hooks: {
|
||||
afterLogin: [afterLogin],
|
||||
afterLogout: [afterLogout],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
|
||||
Reference in New Issue
Block a user