feat: implement audit trail module

- Backend (FastAPI):
  - AuditLog and AuditAlert models with Alembic migration
  - AuditService with SHA-256 checksum for log integrity
  - AuditMiddleware for request metadata extraction (IP, user_agent)
  - Integrated audit logging into Task, Project, Blocker APIs
  - Query API with filtering, pagination, CSV export
  - Integrity verification endpoint
  - Sensitive operation alerts with acknowledgement

- Frontend (React + Vite):
  - Admin AuditPage with filters and export
  - ResourceHistory component for change tracking
  - Audit service for API calls

- Testing:
  - 15 tests covering service and API endpoints

- OpenSpec:
  - add-audit-trail change archived

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2025-12-29 21:21:18 +08:00
parent 3470428411
commit 0ef78e13ff
24 changed files with 2431 additions and 7 deletions

View File

@@ -0,0 +1,149 @@
import { useState, useEffect } from 'react'
import { auditService, AuditLog } from '../services/audit'
interface ResourceHistoryProps {
resourceType: string
resourceId: string
title?: string
}
export function ResourceHistory({ resourceType, resourceId, title = 'Change History' }: ResourceHistoryProps) {
const [logs, setLogs] = useState<AuditLog[]>([])
const [loading, setLoading] = useState(true)
const [expanded, setExpanded] = useState(false)
useEffect(() => {
loadHistory()
}, [resourceType, resourceId])
const loadHistory = async () => {
setLoading(true)
try {
const response = await auditService.getResourceHistory(resourceType, resourceId, 10)
setLogs(response.logs)
} catch (error) {
console.error('Failed to load resource history:', error)
} finally {
setLoading(false)
}
}
const formatChanges = (changes: AuditLog['changes']): string => {
if (!changes || changes.length === 0) return ''
return changes.map(c => `${c.field}: ${c.old_value ?? 'null'}${c.new_value ?? 'null'}`).join(', ')
}
if (loading) {
return <div style={styles.loading}>Loading history...</div>
}
if (logs.length === 0) {
return <div style={styles.empty}>No change history available</div>
}
return (
<div style={styles.container}>
<div style={styles.header} onClick={() => setExpanded(!expanded)}>
<span style={styles.title}>{title}</span>
<span style={styles.toggleIcon}>{expanded ? '▼' : '▶'}</span>
</div>
{expanded && (
<div style={styles.content}>
{logs.map((log) => (
<div key={log.id} style={styles.logItem}>
<div style={styles.logHeader}>
<span style={styles.eventType}>{log.event_type}</span>
<span style={styles.time}>
{new Date(log.created_at).toLocaleString()}
</span>
</div>
<div style={styles.logBody}>
<span style={styles.userName}>{log.user_name || 'System'}</span>
{log.changes && log.changes.length > 0 && (
<span style={styles.changes}>{formatChanges(log.changes)}</span>
)}
</div>
</div>
))}
</div>
)}
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
container: {
backgroundColor: '#f8f9fa',
borderRadius: '8px',
border: '1px solid #e9ecef',
marginTop: '16px',
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '12px 16px',
cursor: 'pointer',
userSelect: 'none',
},
title: {
fontWeight: 600,
fontSize: '14px',
color: '#495057',
},
toggleIcon: {
fontSize: '12px',
color: '#6c757d',
},
content: {
borderTop: '1px solid #e9ecef',
padding: '8px',
maxHeight: '300px',
overflowY: 'auto',
},
logItem: {
padding: '8px 12px',
borderBottom: '1px solid #e9ecef',
},
logHeader: {
display: 'flex',
justifyContent: 'space-between',
marginBottom: '4px',
},
eventType: {
fontSize: '12px',
fontWeight: 600,
color: '#007bff',
},
time: {
fontSize: '11px',
color: '#6c757d',
},
logBody: {
display: 'flex',
flexDirection: 'column',
gap: '2px',
},
userName: {
fontSize: '12px',
color: '#495057',
},
changes: {
fontSize: '11px',
color: '#6c757d',
fontStyle: 'italic',
},
loading: {
padding: '16px',
textAlign: 'center',
color: '#6c757d',
},
empty: {
padding: '16px',
textAlign: 'center',
color: '#6c757d',
fontSize: '14px',
},
}
export default ResourceHistory