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,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',
},
}