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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
149
frontend/src/components/ResourceHistory.tsx
Normal file
149
frontend/src/components/ResourceHistory.tsx
Normal 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
|
||||
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',
|
||||
},
|
||||
}
|
||||
151
frontend/src/services/audit.ts
Normal file
151
frontend/src/services/audit.ts
Normal 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
|
||||
Reference in New Issue
Block a user