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

@@ -5,6 +5,7 @@ import Dashboard from './pages/Dashboard'
import Spaces from './pages/Spaces'
import Projects from './pages/Projects'
import Tasks from './pages/Tasks'
import AuditPage from './pages/AuditPage'
import ProtectedRoute from './components/ProtectedRoute'
import Layout from './components/Layout'
@@ -61,6 +62,16 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/audit"
element={
<ProtectedRoute>
<Layout>
<AuditPage />
</Layout>
</ProtectedRoute>
}
/>
</Routes>
)
}

View File

@@ -19,6 +19,7 @@ export default function Layout({ children }: LayoutProps) {
const navItems = [
{ path: '/', label: 'Dashboard' },
{ path: '/spaces', label: 'Spaces' },
...(user?.is_system_admin ? [{ path: '/audit', label: 'Audit' }] : []),
]
return (

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

View File

@@ -0,0 +1,469 @@
import { useState, useEffect } from 'react'
import { useAuth } from '../contexts/AuthContext'
import { auditService, AuditLog, AuditLogFilters } from '../services/audit'
interface AuditLogDetailProps {
log: AuditLog
onClose: () => void
}
function AuditLogDetail({ log, onClose }: AuditLogDetailProps) {
return (
<div style={styles.modal}>
<div style={styles.modalContent}>
<div style={styles.modalHeader}>
<h3>Audit Log Details</h3>
<button onClick={onClose} style={styles.closeButton}>×</button>
</div>
<div style={styles.modalBody}>
<div style={styles.detailRow}>
<span style={styles.label}>Event Type:</span>
<span>{log.event_type}</span>
</div>
<div style={styles.detailRow}>
<span style={styles.label}>Action:</span>
<span>{log.action}</span>
</div>
<div style={styles.detailRow}>
<span style={styles.label}>Resource:</span>
<span>{log.resource_type} / {log.resource_id || 'N/A'}</span>
</div>
<div style={styles.detailRow}>
<span style={styles.label}>User:</span>
<span>{log.user_name || log.user_id || 'System'}</span>
</div>
<div style={styles.detailRow}>
<span style={styles.label}>IP Address:</span>
<span>{log.request_metadata?.ip_address || 'N/A'}</span>
</div>
<div style={styles.detailRow}>
<span style={styles.label}>Sensitivity:</span>
<span style={getSensitivityStyle(log.sensitivity_level)}>
{log.sensitivity_level}
</span>
</div>
<div style={styles.detailRow}>
<span style={styles.label}>Time:</span>
<span>{new Date(log.created_at).toLocaleString()}</span>
</div>
{log.changes && log.changes.length > 0 && (
<div style={styles.changesSection}>
<h4>Changes</h4>
<table style={styles.changesTable}>
<thead>
<tr>
<th>Field</th>
<th>Old Value</th>
<th>New Value</th>
</tr>
</thead>
<tbody>
{log.changes.map((change, idx) => (
<tr key={idx}>
<td>{change.field}</td>
<td style={styles.oldValue}>{String(change.old_value ?? 'null')}</td>
<td style={styles.newValue}>{String(change.new_value ?? 'null')}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div style={styles.detailRow}>
<span style={styles.label}>Checksum:</span>
<span style={styles.checksum}>{log.checksum}</span>
</div>
</div>
</div>
</div>
)
}
function getSensitivityStyle(level: string): React.CSSProperties {
const colors: Record<string, string> = {
low: '#28a745',
medium: '#ffc107',
high: '#fd7e14',
critical: '#dc3545',
}
return {
padding: '2px 8px',
borderRadius: '4px',
backgroundColor: colors[level] || '#6c757d',
color: level === 'medium' ? '#000' : '#fff',
fontSize: '12px',
fontWeight: 'bold',
}
}
export default function AuditPage() {
const { user } = useAuth()
const [logs, setLogs] = useState<AuditLog[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null)
const [filters, setFilters] = useState<AuditLogFilters>({
limit: 50,
offset: 0,
})
const [tempFilters, setTempFilters] = useState({
start_date: '',
end_date: '',
resource_type: '',
sensitivity_level: '',
})
useEffect(() => {
if (user?.is_system_admin) {
loadLogs()
}
}, [filters, user])
const loadLogs = async () => {
setLoading(true)
try {
const response = await auditService.getAuditLogs(filters)
setLogs(response.logs)
setTotal(response.total)
} catch (error) {
console.error('Failed to load audit logs:', error)
} finally {
setLoading(false)
}
}
const handleApplyFilters = () => {
setFilters({
...filters,
...tempFilters,
offset: 0,
})
}
const handleExport = async () => {
try {
const blob = await auditService.exportAuditLogs(filters)
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `audit_logs_${new Date().toISOString().split('T')[0]}.csv`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (error) {
console.error('Failed to export audit logs:', error)
}
}
const handlePageChange = (newOffset: number) => {
setFilters({ ...filters, offset: newOffset })
}
if (!user?.is_system_admin) {
return (
<div style={styles.container}>
<h2>Access Denied</h2>
<p>You need administrator privileges to view audit logs.</p>
</div>
)
}
return (
<div style={styles.container}>
<h2>Audit Logs</h2>
{/* Filters */}
<div style={styles.filtersContainer}>
<div style={styles.filterRow}>
<div style={styles.filterGroup}>
<label>Start Date:</label>
<input
type="datetime-local"
value={tempFilters.start_date}
onChange={(e) => setTempFilters({ ...tempFilters, start_date: e.target.value })}
style={styles.input}
/>
</div>
<div style={styles.filterGroup}>
<label>End Date:</label>
<input
type="datetime-local"
value={tempFilters.end_date}
onChange={(e) => setTempFilters({ ...tempFilters, end_date: e.target.value })}
style={styles.input}
/>
</div>
<div style={styles.filterGroup}>
<label>Resource Type:</label>
<select
value={tempFilters.resource_type}
onChange={(e) => setTempFilters({ ...tempFilters, resource_type: e.target.value })}
style={styles.select}
>
<option value="">All</option>
<option value="task">Task</option>
<option value="project">Project</option>
<option value="user">User</option>
</select>
</div>
<div style={styles.filterGroup}>
<label>Sensitivity:</label>
<select
value={tempFilters.sensitivity_level}
onChange={(e) => setTempFilters({ ...tempFilters, sensitivity_level: e.target.value })}
style={styles.select}
>
<option value="">All</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
<button onClick={handleApplyFilters} style={styles.filterButton}>
Apply Filters
</button>
<button onClick={handleExport} style={styles.exportButton}>
Export CSV
</button>
</div>
</div>
{/* Results */}
{loading ? (
<div>Loading...</div>
) : (
<>
<div style={styles.summary}>
Showing {logs.length} of {total} records
</div>
<table style={styles.table}>
<thead>
<tr>
<th>Time</th>
<th>Event</th>
<th>Resource</th>
<th>User</th>
<th>Sensitivity</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr key={log.id}>
<td>{new Date(log.created_at).toLocaleString()}</td>
<td>{log.event_type}</td>
<td>{log.resource_type} / {log.resource_id?.substring(0, 8) || '-'}</td>
<td>{log.user_name || 'System'}</td>
<td>
<span style={getSensitivityStyle(log.sensitivity_level)}>
{log.sensitivity_level}
</span>
</td>
<td>
<button
onClick={() => setSelectedLog(log)}
style={styles.viewButton}
>
View
</button>
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination */}
<div style={styles.pagination}>
<button
onClick={() => handlePageChange(Math.max(0, (filters.offset || 0) - 50))}
disabled={(filters.offset || 0) === 0}
style={styles.pageButton}
>
Previous
</button>
<span>
Page {Math.floor((filters.offset || 0) / 50) + 1} of {Math.ceil(total / 50)}
</span>
<button
onClick={() => handlePageChange((filters.offset || 0) + 50)}
disabled={(filters.offset || 0) + 50 >= total}
style={styles.pageButton}
>
Next
</button>
</div>
</>
)}
{/* Detail Modal */}
{selectedLog && (
<AuditLogDetail log={selectedLog} onClose={() => setSelectedLog(null)} />
)}
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
container: {
padding: '20px',
maxWidth: '1400px',
margin: '0 auto',
},
filtersContainer: {
backgroundColor: '#f8f9fa',
padding: '16px',
borderRadius: '8px',
marginBottom: '20px',
},
filterRow: {
display: 'flex',
gap: '16px',
alignItems: 'flex-end',
flexWrap: 'wrap',
},
filterGroup: {
display: 'flex',
flexDirection: 'column',
gap: '4px',
},
input: {
padding: '8px',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '14px',
},
select: {
padding: '8px',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '14px',
minWidth: '120px',
},
filterButton: {
padding: '8px 16px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
},
exportButton: {
padding: '8px 16px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
},
summary: {
marginBottom: '16px',
color: '#666',
},
table: {
width: '100%',
borderCollapse: 'collapse',
backgroundColor: 'white',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
},
viewButton: {
padding: '4px 12px',
backgroundColor: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
},
pagination: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: '16px',
marginTop: '20px',
},
pageButton: {
padding: '8px 16px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
},
modal: {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000,
},
modalContent: {
backgroundColor: 'white',
borderRadius: '8px',
width: '600px',
maxWidth: '90%',
maxHeight: '80vh',
overflow: 'auto',
},
modalHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '16px',
borderBottom: '1px solid #ddd',
},
closeButton: {
background: 'none',
border: 'none',
fontSize: '24px',
cursor: 'pointer',
color: '#666',
},
modalBody: {
padding: '16px',
},
detailRow: {
display: 'flex',
marginBottom: '12px',
gap: '12px',
},
label: {
fontWeight: 'bold',
minWidth: '120px',
color: '#555',
},
changesSection: {
marginTop: '20px',
paddingTop: '20px',
borderTop: '1px solid #ddd',
},
changesTable: {
width: '100%',
borderCollapse: 'collapse',
marginTop: '8px',
fontSize: '14px',
},
oldValue: {
color: '#dc3545',
backgroundColor: '#ffe6e6',
padding: '4px 8px',
},
newValue: {
color: '#28a745',
backgroundColor: '#e6ffe6',
padding: '4px 8px',
},
checksum: {
fontFamily: 'monospace',
fontSize: '12px',
color: '#666',
wordBreak: 'break-all',
},
}

View File

@@ -0,0 +1,151 @@
import api from './api'
export interface AuditLog {
id: string
event_type: string
resource_type: string
resource_id: string | null
user_id: string | null
action: string
changes: Array<{
field: string
old_value: any
new_value: any
}> | null
request_metadata: {
ip_address?: string
user_agent?: string
method?: string
path?: string
} | null
sensitivity_level: string
checksum: string
created_at: string
user_name: string | null
user_email: string | null
}
export interface AuditLogListResponse {
logs: AuditLog[]
total: number
offset: number
limit: number
}
export interface AuditAlert {
id: string
audit_log_id: string
alert_type: string
recipients: string[]
message: string | null
is_acknowledged: boolean
acknowledged_by: string | null
acknowledged_at: string | null
created_at: string
}
export interface AuditAlertListResponse {
alerts: AuditAlert[]
total: number
}
export interface IntegrityCheckResponse {
total_checked: number
valid_count: number
invalid_count: number
invalid_records: string[]
}
export interface AuditLogFilters {
start_date?: string
end_date?: string
user_id?: string
resource_type?: string
resource_id?: string
sensitivity_level?: string
limit?: number
offset?: number
}
export const auditService = {
// Get audit logs with filters
getAuditLogs: async (filters: AuditLogFilters = {}): Promise<AuditLogListResponse> => {
const params = new URLSearchParams()
if (filters.start_date) params.append('start_date', filters.start_date)
if (filters.end_date) params.append('end_date', filters.end_date)
if (filters.user_id) params.append('user_id', filters.user_id)
if (filters.resource_type) params.append('resource_type', filters.resource_type)
if (filters.resource_id) params.append('resource_id', filters.resource_id)
if (filters.sensitivity_level) params.append('sensitivity_level', filters.sensitivity_level)
if (filters.limit) params.append('limit', filters.limit.toString())
if (filters.offset) params.append('offset', filters.offset.toString())
const response = await api.get(`/audit-logs?${params.toString()}`)
return response.data
},
// Get resource history
getResourceHistory: async (
resourceType: string,
resourceId: string,
limit: number = 50,
offset: number = 0
): Promise<AuditLogListResponse> => {
const response = await api.get(
`/audit-logs/resource/${resourceType}/${resourceId}?limit=${limit}&offset=${offset}`
)
return response.data
},
// Export audit logs as CSV
exportAuditLogs: async (filters: AuditLogFilters = {}): Promise<Blob> => {
const params = new URLSearchParams()
if (filters.start_date) params.append('start_date', filters.start_date)
if (filters.end_date) params.append('end_date', filters.end_date)
if (filters.user_id) params.append('user_id', filters.user_id)
if (filters.resource_type) params.append('resource_type', filters.resource_type)
if (filters.sensitivity_level) params.append('sensitivity_level', filters.sensitivity_level)
const response = await api.get(`/audit-logs/export?${params.toString()}`, {
responseType: 'blob',
})
return response.data
},
// Verify integrity
verifyIntegrity: async (
startDate: string,
endDate: string
): Promise<IntegrityCheckResponse> => {
const response = await api.post('/audit-logs/verify-integrity', {
start_date: startDate,
end_date: endDate,
})
return response.data
},
// Get audit alerts
getAuditAlerts: async (
isAcknowledged?: boolean,
limit: number = 50,
offset: number = 0
): Promise<AuditAlertListResponse> => {
const params = new URLSearchParams()
if (isAcknowledged !== undefined) {
params.append('is_acknowledged', isAcknowledged.toString())
}
params.append('limit', limit.toString())
params.append('offset', offset.toString())
const response = await api.get(`/audit-alerts?${params.toString()}`)
return response.data
},
// Acknowledge an alert
acknowledgeAlert: async (alertId: string): Promise<AuditAlert> => {
const response = await api.put(`/audit-alerts/${alertId}/acknowledge`)
return response.data
},
}
export default auditService