Files
gh-christianlouis-docuelevate/frontend/templates/audit_logs.html
T
copilot-swe-agent[bot] 35db9f88de feat(audit): add comprehensive audit logging with SIEM integration
- Add AuditLog model with append-only design (timestamp, user, action, resource, IP, details, severity)
- Add audit_service.py with record/query helpers and SIEM forwarding (Syslog RFC 5424, HTTP/webhook)
- Add /api/audit-logs REST endpoints with filtering and pagination
- Add /admin/audit-logs viewer UI with real-time filters
- Add SIEM config settings (syslog, HTTP for Splunk HEC/Logstash/Grafana Loki)
- Add Alembic migration 027_add_audit_logs
- Add navigation link in admin menu
- Add comprehensive tests (20 tests covering model, service, SIEM, API, view)

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-09 23:18:18 +00:00

223 lines
11 KiB
HTML

{% extends "base.html" %}
{% block title %}Audit Logs - DocuElevate{% endblock %}
{% block content %}
<div x-data="auditLogs()" x-init="fetchLogs()" class="container mx-auto px-4 py-8 max-w-7xl">
<!-- Header -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
<i class="fas fa-shield-halved mr-2 text-indigo-600" aria-hidden="true"></i>Audit Logs
</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Comprehensive, append-only record of all significant actions.
</p>
</div>
<div class="mt-3 md:mt-0 flex items-center gap-3">
{% if siem_enabled %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100"
title="Events are being forwarded to {{ siem_transport|upper }}">
<i class="fas fa-tower-broadcast mr-1" aria-hidden="true"></i>SIEM: {{ siem_transport|upper }}
</span>
{% else %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
title="SIEM forwarding is disabled">
<i class="fas fa-tower-broadcast mr-1" aria-hidden="true"></i>SIEM: Off
</span>
{% endif %}
<button @click="fetchLogs()" class="inline-flex items-center px-3 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600 dark:hover:bg-gray-600 min-h-[44px] min-w-[44px]"
aria-label="Refresh audit logs">
<i class="fas fa-arrows-rotate mr-1" aria-hidden="true"></i>Refresh
</button>
</div>
</div>
<!-- Filters -->
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-4 mb-6" aria-label="Audit log filters">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label for="filter-action" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Action</label>
<select id="filter-action" x-model="filters.action" @change="fetchLogs()"
class="w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white text-sm">
<option value="">All actions</option>
<template x-for="a in distinctActions" :key="a">
<option :value="a" x-text="a"></option>
</template>
</select>
</div>
<div>
<label for="filter-user" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">User</label>
<select id="filter-user" x-model="filters.user" @change="fetchLogs()"
class="w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white text-sm">
<option value="">All users</option>
<template x-for="u in distinctUsers" :key="u">
<option :value="u" x-text="u"></option>
</template>
</select>
</div>
<div>
<label for="filter-severity" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Severity</label>
<select id="filter-severity" x-model="filters.severity" @change="fetchLogs()"
class="w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white text-sm">
<option value="">All</option>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
<option value="critical">Critical</option>
</select>
</div>
<div>
<label for="filter-resource" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Resource Type</label>
<input id="filter-resource" type="text" x-model.debounce.300ms="filters.resource_type" @input="fetchLogs()"
placeholder="e.g. document, user"
class="w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white text-sm">
</div>
</div>
</section>
<!-- Results summary -->
<div class="flex items-center justify-between mb-3 text-sm text-gray-500 dark:text-gray-400" aria-live="polite">
<span x-show="!loading" x-text="total + ' event' + (total !== 1 ? 's' : '') + ' found'"></span>
<span x-show="loading"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i>Loading…</span>
</div>
<!-- Table -->
<div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700" aria-label="Audit log events">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Timestamp</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Severity</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">User</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Action</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Resource</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">IP</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Details</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="e in entries" :key="e.id">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td class="px-4 py-2 whitespace-nowrap text-xs text-gray-600 dark:text-gray-300" x-text="formatTs(e.timestamp)"></td>
<td class="px-4 py-2 whitespace-nowrap text-xs">
<span :class="severityClass(e.severity)" class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium" x-text="e.severity"></span>
</td>
<td class="px-4 py-2 whitespace-nowrap text-xs text-gray-700 dark:text-gray-200" x-text="e.user"></td>
<td class="px-4 py-2 whitespace-nowrap text-xs font-mono text-indigo-700 dark:text-indigo-300" x-text="e.action"></td>
<td class="px-4 py-2 whitespace-nowrap text-xs text-gray-600 dark:text-gray-300">
<span x-text="e.resource_type || '—'"></span>
<span x-show="e.resource_id" class="text-gray-400" x-text="' #' + e.resource_id"></span>
</td>
<td class="px-4 py-2 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400" x-text="e.ip_address || '—'"></td>
<td class="px-4 py-2 text-xs text-gray-500 dark:text-gray-400 max-w-xs truncate" :title="e.details ? JSON.stringify(e.details) : ''" x-text="e.details ? JSON.stringify(e.details) : '—'"></td>
</tr>
</template>
<tr x-show="!loading && entries.length === 0">
<td colspan="7" class="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
<i class="fas fa-inbox text-3xl mb-2" aria-hidden="true"></i>
<p class="text-lg">No audit events recorded yet.</p>
<p class="text-sm mt-1">Significant actions (logins, document operations, settings changes) will appear here.</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
<nav class="flex items-center justify-between mt-4" aria-label="Audit log pagination" x-show="total > filters.limit">
<button @click="prevPage()" :disabled="filters.offset === 0"
class="px-3 py-1.5 rounded-md text-sm border border-gray-300 dark:border-gray-600 disabled:opacity-50 min-h-[44px] min-w-[44px]"
aria-label="Previous page">
<i class="fas fa-chevron-left mr-1" aria-hidden="true"></i>Prev
</button>
<span class="text-sm text-gray-500 dark:text-gray-400" x-text="'Page ' + currentPage + ' of ' + totalPages"></span>
<button @click="nextPage()" :disabled="filters.offset + filters.limit >= total"
class="px-3 py-1.5 rounded-md text-sm border border-gray-300 dark:border-gray-600 disabled:opacity-50 min-h-[44px] min-w-[44px]"
aria-label="Next page">
Next<i class="fas fa-chevron-right ml-1" aria-hidden="true"></i>
</button>
</nav>
</div>
<script>
function auditLogs() {
return {
entries: [],
total: 0,
loading: false,
distinctActions: [],
distinctUsers: [],
filters: {
action: '',
user: '',
severity: '',
resource_type: '',
limit: 50,
offset: 0,
},
get currentPage() { return Math.floor(this.filters.offset / this.filters.limit) + 1; },
get totalPages() { return Math.max(1, Math.ceil(this.total / this.filters.limit)); },
async fetchLogs() {
this.loading = true;
try {
const params = new URLSearchParams();
if (this.filters.action) params.set('action', this.filters.action);
if (this.filters.user) params.set('user', this.filters.user);
if (this.filters.severity) params.set('severity', this.filters.severity);
if (this.filters.resource_type) params.set('resource_type', this.filters.resource_type);
params.set('limit', this.filters.limit);
params.set('offset', this.filters.offset);
const [logsResp, actionsResp, usersResp] = await Promise.all([
fetch('/api/audit-logs?' + params.toString()),
fetch('/api/audit-logs/actions'),
fetch('/api/audit-logs/users'),
]);
if (logsResp.ok) {
const data = await logsResp.json();
this.entries = data.items;
this.total = data.total;
}
if (actionsResp.ok) this.distinctActions = await actionsResp.json();
if (usersResp.ok) this.distinctUsers = await usersResp.json();
} finally {
this.loading = false;
}
},
prevPage() {
if (this.filters.offset > 0) {
this.filters.offset = Math.max(0, this.filters.offset - this.filters.limit);
this.fetchLogs();
}
},
nextPage() {
if (this.filters.offset + this.filters.limit < this.total) {
this.filters.offset += this.filters.limit;
this.fetchLogs();
}
},
formatTs(iso) {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleString();
},
severityClass(sev) {
const map = {
info: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
warning: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
error: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
critical: 'bg-red-200 text-red-900 dark:bg-red-800 dark:text-red-100',
};
return map[sev] || 'bg-gray-100 text-gray-800';
},
};
}
</script>
{% endblock %}