Audit log retention: a way to purge, off by default

The audit log is append-only and nothing ever removed a row, so it grows for as
long as a site runs. This site reached tens of thousands of pages, which is past
the point of being useful: nobody pages through that to find anything.

Retention is OPT-IN and the default keeps everything. An upgrade that silently
started deleting a site's audit history would be indefensible, so
audit_retentiondays defaults to 0 and nothing is removed until somebody sets a
number. The Site & Facility card shows the log's size and exactly how many
entries a purge would delete BEFORE anything is pressed.

Deleting happens in committed batches. That is not an optimisation: one DELETE
over a million rows builds a single enormous transaction and holds locks across
the table while it runs, which on this server means blocking the collectors
posting inventory. The API caps its batch count so the request cannot outlive a
proxy timeout, and a run that stops early reports how much is left rather than
claiming it finished - a purge reporting success over 900k remaining rows would
be the same silent half-failure this codebase keeps getting bitten by.

`flask audit stats` and `flask audit purge` for the server, the latter reporting
by default and deleting only with --apply, with no batch cap because it is not
behind a proxy and a purge that stops halfway leaves the operator guessing.

audit.purge is a separate permission from audit.view. Reading the log and
destroying it are different acts, and plenty of people need only the first.

Chunking selects a page of ids and deletes those rather than using
DELETE ... LIMIT, which is MySQL-only while the suite runs on SQLite.
This commit is contained in:
cproudlock
2026-08-26 15:05:43 -04:00
parent 3c68ac2850
commit 9d96006eae
8 changed files with 530 additions and 4 deletions

View File

@@ -35,12 +35,55 @@
</div>
</div>
</div>
<!--
Retention is a setting; purging is an ACT. Keeping them apart matters here
more than in most places: saving a number must never silently destroy
history, so the count of what would go is shown before anything happens
and the deletion is a separate, deliberate press.
-->
<div class="card">
<h3 class="group-title">Audit Log Size</h3>
<div v-if="audit.loading" class="muted">Loading...</div>
<div v-else-if="audit.error" class="error">{{ audit.error }}</div>
<template v-else>
<div class="audit-stats">
<span><strong>{{ audit.total.toLocaleString() }}</strong> entries</span>
<span v-if="audit.oldest" class="muted">oldest {{ shortDate(audit.oldest) }}</span>
<span v-if="audit.newest" class="muted">newest {{ shortDate(audit.newest) }}</span>
</div>
<p v-if="!audit.retentiondays" class="muted">
No retention period is set, so nothing is ever deleted. Set
<em>Audit Log Retention</em> above and save to enable purging.
</p>
<p v-else-if="audit.expired" class="muted">
<strong>{{ audit.expired.toLocaleString() }}</strong> entries are older
than {{ audit.retentiondays }} days and would be deleted.
</p>
<p v-else class="muted">
Nothing is older than {{ audit.retentiondays }} days.
</p>
<div class="actions">
<button class="btn btn-secondary" :disabled="audit.busy" @click="loadAudit">
Refresh
</button>
<button class="btn btn-danger"
:disabled="audit.busy || !audit.retentiondays || !audit.expired"
@click="purgeAudit">
{{ audit.busy ? 'Purging...' : 'Purge now' }}
</button>
<span v-if="audit.message" class="muted">{{ audit.message }}</span>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { settingsApi } from '@/api'
import api, { settingsApi } from '@/api'
import { apiError } from '../../utils/apiError'
const items = ref([])
@@ -75,6 +118,7 @@ const LABELS = {
employee_directory_mode: 'Employee Directory Mode',
usb_directory_mode: 'USB Directory Mode',
site_timezone: 'Site Timezone',
audit_retentiondays: 'Audit Log Retention (days)',
setup_complete: 'Setup Complete'
}
function prettyLabel(key) {
@@ -104,6 +148,7 @@ const GROUPS = [
{ title: 'Naming & Patterns', keys: ['pc_access_domain', 'printer_hostname_template', 'contact_email_domain', 'employeeid_pattern'] },
{ title: 'Data Sources', keys: ['employee_directory_mode', 'usb_directory_mode'] },
{ title: 'Localization', keys: ['site_timezone'] },
{ title: 'Audit Log', keys: ['audit_retentiondays'] },
{ title: 'System', keys: ['setup_complete'] }
]
@@ -116,12 +161,68 @@ const HELP = {
printer_hostname_template: 'Template for generating printer hostnames from an IP. Use {ip} where the dash-separated IP goes. Example: Printer-{ip}.printer.geaerospace.net', // ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override.
contact_email_domain: 'Email domain appended to a support contact SSO to build email (sso@domain) and Microsoft Teams chat links. Example: geaerospace.com. Leave blank to hide the contact action buttons.',
dualpath_single_machine: 'Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in lists, counts, and the floor map. Both bay records are always kept; detail pages stay per-bay with a sibling banner. Enter true or false. Default: true.',
audit_retentiondays: 'Delete audit entries older than this many days. 0 keeps everything forever, which is the default - nothing is removed until you set a number here. Saving a retention period does NOT delete anything by itself; use Purge below, or run "flask audit purge --apply" on the server.',
site_timezone: 'IANA timezone for this site. Notification start/end times are shown and entered in this zone, and daily-reset notification expiry is computed here. Default: America/New_York.'
}
function fieldHelp(key) {
return HELP[key] || ''
}
// ---------------------------------------------------------------- audit ---
const audit = ref({ loading: true, busy: false, error: '', message: '',
total: 0, oldest: null, newest: null,
retentiondays: 0, expired: 0 })
function shortDate (iso) {
const d = new Date(iso)
return isNaN(d) ? iso : d.toLocaleDateString()
}
async function loadAudit () {
audit.value.error = ''
try {
const response = await api.get('/auditlogs/retention')
const data = response.data?.data || response.data
Object.assign(audit.value, {
total: data.total || 0,
oldest: data.oldest,
newest: data.newest,
retentiondays: data.retentiondays || 0,
expired: data.expired || 0,
})
} catch (err) {
audit.value.error = apiError(err, 'Could not read the audit log size.')
} finally {
audit.value.loading = false
}
}
async function purgeAudit () {
const days = audit.value.retentiondays
const ok = window.confirm(
'Permanently delete ' + audit.value.expired.toLocaleString() +
' audit entries older than ' + days + ' days?\n\n' +
'Audit history cannot be recovered once deleted.')
if (!ok) return
audit.value.busy = true
audit.value.message = ''
try {
const response = await api.post('/auditlogs/purge', { days })
const result = response.data?.data || response.data
audit.value.message = response.data?.message || 'Purged.'
// A capped run leaves work behind, and says so rather than looking done.
if (result && result.complete === false) {
audit.value.message += ' Press Purge again to continue.'
}
await loadAudit()
} catch (err) {
audit.value.error = apiError(err, 'Could not purge the audit log.')
} finally {
audit.value.busy = false
}
}
async function load() {
loading.value = true
try {
@@ -152,7 +253,7 @@ async function save() {
}
}
onMounted(load)
onMounted(() => { load(); loadAudit() })
</script>
<style scoped>
@@ -214,4 +315,6 @@ onMounted(load)
gap: 12px;
margin-top: 8px;
}
.audit-stats { display: flex; gap: 1.25rem; align-items: baseline;
flex-wrap: wrap; margin-bottom: 0.5rem; }
</style>