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:
469
frontend/src/pages/AuditPage.tsx
Normal file
469
frontend/src/pages/AuditPage.tsx
Normal 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',
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user