Files
website-enchun-mgr/_bmad-output/implementation-artifacts/1-12-audit-logging.story.md
pkupuk e9897388dc docs: separate documentation and specs into initial commit
Establish baseline for project documentation including BMAD specs, PRD, and system architecture notes.
2026-02-11 11:49:49 +08:00

13 KiB

Story 1.12-a: Add Audit Logging System (NFR9)

Status: Draft Epic: Epic 1 - Webflow to Payload CMS + Astro Migration Priority: P1 (High - NFR9 Compliance Requirement) Estimated Time: 2 hours

Story

As a System Administrator, I want an audit logging system that records all critical operations, So that I can track user actions for compliance and security auditing (NFR9 requirement).

Context

This is a Sprint 1 addition story. NFR9 requires logging all critical operations (login, content changes, settings modifications) for audit purposes. This was identified as missing from the original plan.

Story Source:

  • NFR9 from docs/prd/02-requirements.md
  • Sprint 1 adjustments in sprint-status.yaml

NFR9 Requirement:

"The system must log all critical operations (login, content changes, settings modifications) for audit purposes."

Acceptance Criteria

Part 1: Audit Collection

  1. AC1 - Audit Collection Created: New Audit collection with fields for action, user, timestamp, collection, documentId, before/after data
  2. AC2 - Indexes Configured: Indexes on userId, action, timestamp for efficient querying

Part 2: Logging Hooks

  1. AC3 - Login/Logout Logging: Authentication events logged with user and timestamp
  2. AC4 - Content Changes Logging: create/update/delete operations logged with before/after values
  3. AC5 - Settings Logging: Global changes (Header/Footer) logged with user attribution

Part 3: Admin Interface

  1. AC6 - Audit Log Viewer: Admin-only view to browse and filter audit logs
  2. AC7 - 90-Day Retention: Auto-delete logs older than 90 days

Part 4: Testing

  1. AC8 - TypeScript Types: Running pnpm build regenerates payload-types.ts without errors
  2. AC9 - Functionality Tested: All logging scenarios tested and working

Dev Technical Guidance

Task 1: Create Audit Collection

File: apps/backend/src/collections/Audit/index.ts

import type { CollectionConfig } from 'payload'
import { adminOnly } from '../../access/adminOnly'

export const Audit: CollectionConfig = {
  slug: 'audit',
  access: {
    create: () => false,  // Only system can create
    delete: adminOnly,    // Only admins can delete
    read: adminOnly,      // Only admins can read
    update: () => false,  // Logs are immutable
  },
  admin: {
    defaultColumns: ['timestamp', 'action', 'user', 'collection'],
    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: 'settings' },
      ],
    },
    {
      name: 'user',
      type: 'relationship',
      relationTo: 'users',
      admin: {
        position: 'sidebar',
      },
    },
    {
      name: 'collection',
      type: 'text',
      admin: {
        description: '操作的 collection 名稱',
      },
    },
    {
      name: 'documentId',
      type: 'text',
      admin: {
        description: '受影響文件的 ID',
      },
    },
    {
      name: 'before',
      type: 'json',
      admin: {
        description: '操作前的數據',
      },
    },
    {
      name: 'after',
      type: 'json',
      admin: {
        description: '操作後的數據',
      },
    },
    {
      name: 'ipAddress',
      type: 'text',
      admin: {
        description: '使用者 IP 地址',
      },
    },
    {
      name: 'userAgent',
      type: 'text',
      admin: {
        description: '瀏覽器 User Agent',
      },
    },
    {
      name: 'timestamp',
      type: 'date',
      required: true,
      defaultValue: () => new Date(),
      admin: {
        position: 'sidebar',
      },
    },
  ],
  timestamps: false,
}

Register in payload.config.ts:

import { Audit } from './collections/Audit'

collections: [Pages, Posts, Media, Categories, Users, Audit],

Task 2: Create Logging Utility

File: apps/backend/src/utilities/auditLogger.ts

import type { PayloadRequest } from 'payload'
import { payload } from '@/payload'

export interface AuditLogOptions {
  action: 'login' | 'logout' | 'create' | 'update' | 'delete' | 'settings'
  collection?: string
  documentId?: string
  before?: Record<string, unknown>
  after?: Record<string, unknown>
  req: PayloadRequest
}

/**
 * 創建審計日誌記錄
 */
export async function createAuditLog(options: AuditLogOptions): Promise<void> {
  const { action, collection, documentId, before, after, req } = options

  try {
    await payload.create({
      collection: 'audit',
      data: {
        action,
        user: req.user?.id || null,
        collection,
        documentId,
        before,
        after,
        ipAddress: req.headers.get('x-forwarded-for') || req.headers.get('cf-connecting-ip') || 'unknown',
        userAgent: req.headers.get('user-agent') || 'unknown',
        timestamp: new Date(),
      },
    })
  } catch (error) {
    console.error('Failed to create audit log:', error)
    // Don't throw - audit logging failure shouldn't break the main operation
  }
}

Task 3: Add Login/Logout Hooks

File: apps/backend/src/collections/Users/index.ts

import { createAuditLog } from '../../utilities/auditLogger'

export const Users: CollectionConfig = {
  // ... existing config ...
  hooks: {
    afterLogin: [
      async ({ req }) => {
        await createAuditLog({
          action: 'login',
          req,
        })
      },
    ],
    afterLogout: [
      async ({ req }) => {
        await createAuditLog({
          action: 'logout',
          req,
        })
      },
    ],
    // ... existing hooks ...
  },
}

Task 4: Add Content Change Hooks

For each collection (Posts, Pages, Categories, Portfolio):

File: apps/backend/src/collections/Posts/index.ts

import { createAuditLog } from '../../utilities/auditLogger'

export const Posts: CollectionConfig = {
  // ... existing config ...
  hooks: {
    afterChange: [
      async ({ doc, previousDoc, req, operation }) => {
        // Only log for authenticated users, not system operations
        if (!req.user) return

        const action = operation === 'create' ? 'create' : 'update'

        await createAuditLog({
          action,
          collection: 'posts',
          documentId: doc.id,
          before: operation === 'update' ? previousDoc : undefined,
          after: doc,
          req,
        })

        // ... existing revalidatePost hook ...
      },
    ],
    afterDelete: [
      async ({ doc, req }) => {
        if (!req.user) return

        await createAuditLog({
          action: 'delete',
          collection: 'posts',
          documentId: doc.id,
          before: doc,
          req,
        })

        // ... existing revalidateDelete hook ...
      },
    ],
  },
}

Task 5: Add Settings Change Hooks

File: apps/backend/src/Header/config.ts and Footer/config.ts

import { createAuditLog } from '../utilities/auditLogger'

export const Header: GlobalConfig = {
  slug: 'header',
  hooks: {
    afterChange: [
      async ({ doc, previousDoc, req }) => {
        if (!req.user) return

        await createAuditLog({
          action: 'settings',
          collection: 'header',
          documentId: doc.id,
          before: previousDoc,
          after: doc,
          req,
        })
      },
    ],
  },
  // ... rest of config ...
}

Task 6: Implement Log Retention (90 Days)

File: apps/backend/src/cron/cleanupAuditLogs.ts

import { payload } from '@/payload'

/**
 * 定期清理 90 天前的審計日誌
 * 應該通過 cron job 每天執行
 */
export async function cleanupOldAuditLogs(): Promise<void> {
  const ninetyDaysAgo = new Date()
  ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90)

  try {
    const result = await payload.delete({
      collection: 'audit',
      where: {
        timestamp: {
          less_than: ninetyDaysAgo,
        },
      },
    })
    console.log(`Cleaned up ${result.deleted} old audit logs`)
  } catch (error) {
    console.error('Failed to cleanup audit logs:', error)
  }
}

Configure in payload.config.ts jobs:

jobs: {
  tasks: [
    {
      cron: '0 2 * * *', // Run daily at 2 AM
      handler: async () => {
        const { cleanupOldAuditLogs } = await import('./src/cron/cleanupAuditLogs')
        await cleanupOldAuditLogs()
      },
    },
  ],
}

File Structure

apps/backend/src/
├── collections/
│   └── Audit/
│       └── index.ts              ← CREATE
├── utilities/
│   └── auditLogger.ts            ← CREATE
├── cron/
│   └── cleanupAuditLogs.ts       ← CREATE
├── collections/
│   ├── Users/index.ts            ← MODIFY (add login/logout hooks)
│   ├── Posts/index.ts            ← MODIFY (add audit hooks)
│   ├── Pages/index.ts            ← MODIFY (add audit hooks)
│   ├── Categories.ts             ← MODIFY (add audit hooks)
│   └── Portfolio/index.ts        ← MODIFY (add audit hooks)
├── Header/
│   └── config.ts                 ← MODIFY (add audit hooks)
├── Footer/
│   └── config.ts                 ← MODIFY (add audit hooks)
└── payload.config.ts             ← MODIFY (register Audit, add cron)

Tasks / Subtasks

Part 1: Audit Collection

  • Task 1.1: Create Audit collection
    • Create Audit/index.ts with all fields
    • Configure access control (admin only)
    • Register in payload.config.ts

Part 2: Logging Utility

  • Task 2.1: Create auditLogger utility
    • Create auditLogger.ts
    • Implement createAuditLog function
    • Add error handling

Part 3: Authentication Logging

  • Task 3.1: Add login hook to Users
  • Task 3.2: Add logout hook to Users

Part 4: Content Change Logging

  • Task 4.1: Add audit hooks to Posts
  • Task 4.2: Add audit hooks to Pages
  • Task 4.3: Add audit hooks to Categories
  • Task 4.4: Add audit hooks to Portfolio

Part 5: Settings Logging

  • Task 5.1: Add audit hooks to Header
  • Task 5.2: Add audit hooks to Footer

Part 6: Log Retention

  • Task 6.1: Create cleanupAuditLogs function
  • Task 6.2: Configure cron job in payload.config.ts

Part 7: Testing

  • Task 7.1: Verify TypeScript types
  • Task 7.2: Test login/logout logging
  • Task 7.3: Test content change logging
  • Task 7.4: Test settings change logging
  • Task 7.5: Test admin-only access
  • Task 7.6: Test 90-day cleanup

Testing Requirements

Unit Tests

// apps/backend/src/utilities/__tests__/auditLogger.spec.ts
import { createAuditLog } from '../auditLogger'

describe('Audit Logger', () => {
  it('should create audit log entry', async () => {
    // Test implementation
  })

  it('should handle errors gracefully', async () => {
    // Test implementation
  })
})

Manual Testing Checklist

  • Login creates audit log
  • Logout creates audit log
  • Creating post creates audit log
  • Updating post creates audit log with before/after
  • Deleting post creates audit log with before data
  • Updating Header creates audit log
  • Non-admin users cannot access Audit collection
  • Admin users can view Audit collection
  • Audit logs show correct user attribution
  • IP addresses are captured
  • User agents are captured
  • Old logs are cleaned up (manual test of cleanup function)

Risk Assessment

Risk Probability Impact Mitigation
Performance impact Medium Medium Async logging, don't block main operations
Data growth High Low 90-day retention policy
Missing events Low Medium Comprehensive hook coverage
Privacy concerns Low Medium Admin-only access, no sensitive data in logs

Definition of Done

  • Audit collection created and registered
  • Audit logger utility created
  • Login/logout logging implemented
  • Content change logging implemented
  • Settings change logging implemented
  • 90-day retention cron job configured
  • TypeScript types generate successfully
  • All logging scenarios tested
  • Admin-only access verified
  • 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)