mirror of https://github.com/requarks/wiki
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
351 lines
12 KiB
351 lines
12 KiB
import { Readable } from 'node:stream'
|
|
import { validate as uuidValidate } from 'uuid'
|
|
import type { FastifyInstance, FastifyReply } from 'fastify'
|
|
import { audit } from '../helpers/audit.ts'
|
|
import { AUDIT_ACTIONS, AUDIT_KINDS, MIN_RETENTION_DAYS } from '../models/auditLog.ts'
|
|
|
|
/** Most rows one request may ask for, so that a filter matching everything cannot be a denial of service. */
|
|
const MAX_PAGE_SIZE = 100
|
|
|
|
/** Most accounts one request may name, so the `userId` list cannot be an unbounded `IN`. */
|
|
const MAX_USER_FILTER = 50
|
|
|
|
/** Comma-separated query lists, which is how the browser sends a multi-valued filter here. */
|
|
function splitList(value?: string): string[] {
|
|
return (
|
|
value
|
|
?.split(',')
|
|
.map((v) => v.trim())
|
|
.filter(Boolean) ?? []
|
|
)
|
|
}
|
|
|
|
/** The filter half of the querystring, shared by the list and the export. */
|
|
interface AuditQuery {
|
|
userId?: string
|
|
kind?: string
|
|
action?: string
|
|
from?: string
|
|
to?: string
|
|
}
|
|
|
|
/**
|
|
* The filters as the model takes them, or the refusal to send back.
|
|
*
|
|
* The user IDs are validated rather than passed through: they go into an `IN` against a uuid column,
|
|
* so one malformed value makes postgres raise and the request fail as a 500 rather than as the bad
|
|
* request it is.
|
|
*/
|
|
function filtersFrom(
|
|
query: AuditQuery,
|
|
reply: FastifyReply
|
|
): { userIds: string[]; kind?: string; action?: string; from?: string; to?: string } | null {
|
|
const userIds = splitList(query.userId)
|
|
if (userIds.length > MAX_USER_FILTER) {
|
|
reply.badRequest(`At most ${MAX_USER_FILTER} users can be filtered for at once.`)
|
|
return null
|
|
}
|
|
if (userIds.some((id) => !uuidValidate(id))) {
|
|
reply.badRequest('The userId filter must be one user ID, or several separated by commas.')
|
|
return null
|
|
}
|
|
return { userIds, kind: query.kind, action: query.action, from: query.from, to: query.to }
|
|
}
|
|
|
|
/** The filter querystring both routes declare. */
|
|
const filterProperties = {
|
|
userId: {
|
|
type: 'string',
|
|
description:
|
|
'Only entries these accounts made — one ID, or several comma-separated, OR-ed against each other. At most 50.\n\nAn account that has since been deleted can no longer be filtered for: its entries have no `userId` left. The name and email it had are still on `meta.actor`.'
|
|
},
|
|
kind: { $ref: 'AuditKind#' },
|
|
action: { $ref: 'AuditAction#' },
|
|
from: {
|
|
type: 'string',
|
|
format: 'date-time',
|
|
description: 'Inclusive lower bound on the timestamp, RFC 3339.'
|
|
},
|
|
to: {
|
|
type: 'string',
|
|
format: 'date-time',
|
|
description: 'Inclusive upper bound on the timestamp, RFC 3339.'
|
|
}
|
|
} as const
|
|
|
|
/**
|
|
* Audit Log API Routes
|
|
*
|
|
* Read-only from the outside: nothing here writes an entry. Entries are written by `helpers/audit.ts`
|
|
* as a side effect of the routes that do the work, which is what keeps the log a record of what
|
|
* happened rather than a table anybody can post to.
|
|
*/
|
|
async function routes(app: FastifyInstance) {
|
|
/**
|
|
* LIST AUDIT ENTRIES
|
|
*/
|
|
app.get<{
|
|
Querystring: {
|
|
userId?: string
|
|
kind?: string
|
|
action?: string
|
|
from?: string
|
|
to?: string
|
|
page?: number
|
|
limit?: number
|
|
}
|
|
}>(
|
|
'/',
|
|
{
|
|
config: {
|
|
permissions: ['read:audit']
|
|
},
|
|
schema: {
|
|
summary: 'List audit log entries',
|
|
description:
|
|
'Newest first, one page at a time. Every filter is optional and they are AND-ed together.\n\nWhat is recorded: every action that CHANGES something, plus successful logins. Reads are not — page views would outnumber everything else and bury the log — and neither are failed login attempts, which would otherwise let anybody outside fill this table on demand. Actions taken by the scheduler are absent by construction: an entry is only ever written from a request.',
|
|
tags: ['Audit Log'],
|
|
querystring: {
|
|
type: 'object',
|
|
properties: {
|
|
...filterProperties,
|
|
page: { type: 'integer', minimum: 1, default: 1 },
|
|
limit: { type: 'integer', minimum: 1, maximum: MAX_PAGE_SIZE, default: 25 }
|
|
}
|
|
},
|
|
response: {
|
|
200: {
|
|
description: 'One page of audit entries',
|
|
type: 'object',
|
|
properties: {
|
|
total: {
|
|
type: 'integer',
|
|
description: 'How many entries match the filters, across every page.'
|
|
},
|
|
entries: {
|
|
type: 'array',
|
|
items: { $ref: 'AuditEntry#' }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
async (req, reply) => {
|
|
reply.preventCache()
|
|
const filters = filtersFrom(req.query, reply)
|
|
if (!filters) {
|
|
return reply
|
|
}
|
|
return WIKI.models.auditLog.list(filters, req.query.page ?? 1, req.query.limit ?? 25)
|
|
}
|
|
)
|
|
|
|
/**
|
|
* EXPORT AUDIT ENTRIES
|
|
*/
|
|
app.get<{ Querystring: AuditQuery }>(
|
|
'/export',
|
|
{
|
|
config: {
|
|
permissions: ['read:audit']
|
|
},
|
|
schema: {
|
|
summary: 'Download the audit log as JSONL',
|
|
description:
|
|
'Every entry matching the filters, newest first — not just the page on screen — as newline-delimited JSON: one entry per line, the same shape the list returns. A format that streams, so a year of history is a download rather than a request that has to be held in memory at both ends, and that `jq` and every log pipeline read without unwrapping an envelope first.\n\nThe export itself is recorded in the log, which is the one read that is: taking a copy of who did what is worth knowing about.',
|
|
tags: ['Audit Log'],
|
|
querystring: {
|
|
type: 'object',
|
|
properties: filterProperties
|
|
},
|
|
response: {
|
|
200: {
|
|
description: 'The matching entries, one JSON object per line',
|
|
content: {
|
|
'application/x-ndjson': {
|
|
schema: { type: 'string' }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
async (req, reply) => {
|
|
const filters = filtersFrom(req.query, reply)
|
|
if (!filters) {
|
|
return reply
|
|
}
|
|
|
|
/*
|
|
Recorded BEFORE the stream, deliberately. Everywhere else an entry is written once the work
|
|
succeeded, but a download has no moment of success the server sees — the connection can drop
|
|
halfway and the rows already sent are still gone. What is worth recording is that the export
|
|
was asked for and authorized.
|
|
*/
|
|
await audit(req, 'admin', 'exportAuditLog', {
|
|
filters: {
|
|
userIds: filters.userIds,
|
|
kind: filters.kind ?? null,
|
|
action: filters.action ?? null,
|
|
from: filters.from ?? null,
|
|
to: filters.to ?? null
|
|
}
|
|
})
|
|
|
|
const stamp = Temporal.Now.instant().toString({ smallestUnit: 'second' }).replace(/[:]/g, '-')
|
|
reply.header('Content-Type', 'application/x-ndjson; charset=utf-8')
|
|
reply.header('Content-Disposition', `attachment; filename="audit-log-${stamp}.jsonl"`)
|
|
reply.preventCache()
|
|
|
|
// -> The entries this request just wrote are in the export too, which is correct: it is the log
|
|
// as it stands at the moment it was asked for
|
|
const entries = WIKI.models.auditLog.stream(filters)
|
|
return reply.send(
|
|
Readable.from(
|
|
(async function* () {
|
|
for await (const entry of entries) {
|
|
yield `${JSON.stringify(entry)}\n`
|
|
}
|
|
})()
|
|
)
|
|
)
|
|
}
|
|
)
|
|
|
|
/**
|
|
* LIST AUDIT ACTIONS
|
|
*/
|
|
app.get(
|
|
'/actions',
|
|
{
|
|
config: {
|
|
permissions: ['read:audit']
|
|
},
|
|
schema: {
|
|
summary: 'List every action the wiki records, by area',
|
|
description:
|
|
'What the filter offers, and the full set of translation keys: each action is shown as `admin.audit.actions.<action>`. Served from the code rather than from the table, so an action nothing has done yet is still offered.',
|
|
tags: ['Audit Log'],
|
|
response: {
|
|
200: {
|
|
description: 'Action keys grouped by area',
|
|
type: 'array',
|
|
items: {
|
|
type: 'object',
|
|
properties: {
|
|
kind: { $ref: 'AuditKind#' },
|
|
actions: {
|
|
type: 'array',
|
|
items: { type: 'string' }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
async () => {
|
|
return AUDIT_KINDS.map((kind) => ({ kind, actions: [...AUDIT_ACTIONS[kind]] }))
|
|
}
|
|
)
|
|
|
|
/**
|
|
* GET AUDIT CONFIG
|
|
*/
|
|
app.get(
|
|
'/config',
|
|
{
|
|
config: {
|
|
permissions: ['manage:system']
|
|
},
|
|
schema: {
|
|
summary: 'Get the audit log retention settings',
|
|
description:
|
|
'Reading the log needs `read:audit`; changing how long it is kept is a system setting and needs `manage:system` — shortening the retention destroys evidence, which is not the same authority as looking at it.',
|
|
tags: ['Audit Log'],
|
|
response: {
|
|
200: { $ref: 'AuditConfig#' }
|
|
}
|
|
}
|
|
},
|
|
async (req, reply) => {
|
|
reply.preventCache()
|
|
const [total, oldestEntry] = await Promise.all([
|
|
WIKI.models.auditLog.total(),
|
|
WIKI.models.auditLog.oldestEntry()
|
|
])
|
|
return {
|
|
retentionDays: WIKI.models.auditLog.retentionDays(),
|
|
total,
|
|
oldestEntry
|
|
}
|
|
}
|
|
)
|
|
|
|
/**
|
|
* UPDATE AUDIT CONFIG
|
|
*/
|
|
app.put<{ Body: { retentionDays: number } }>(
|
|
'/config',
|
|
{
|
|
config: {
|
|
permissions: ['manage:system']
|
|
},
|
|
schema: {
|
|
summary: 'Set how long audit log entries are kept',
|
|
description: `Takes effect the next time the daily \`purgeAuditLog\` task runs; nothing is deleted by saving. The change is itself recorded in the log.
|
|
|
|
Either zero — keep everything for ever — or at least ${MIN_RETENTION_DAYS} days. Nothing between the two is accepted: retention is the one setting whose effect is to destroy this table, and the person who can change it is the person it exists to record, so a value short enough to outrun discovery is refused rather than offered.`,
|
|
tags: ['Audit Log'],
|
|
body: {
|
|
type: 'object',
|
|
properties: {
|
|
retentionDays: {
|
|
type: 'integer',
|
|
minimum: 0,
|
|
maximum: 36500,
|
|
description: `Days to keep. Zero keeps everything for ever; anything else is at least ${MIN_RETENTION_DAYS}.`
|
|
}
|
|
},
|
|
required: ['retentionDays']
|
|
},
|
|
response: {
|
|
200: {
|
|
description: 'Retention updated',
|
|
type: 'object',
|
|
properties: {
|
|
retentionDays: { type: 'integer' }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
async (req, reply) => {
|
|
/*
|
|
Checked here rather than in the JSON Schema: "zero or at least thirty" is expressible as an
|
|
`anyOf`, but what comes back from one is a validation error naming neither branch, and this
|
|
is a refusal whose reason is the whole point of it.
|
|
*/
|
|
if (req.body.retentionDays > 0 && req.body.retentionDays < MIN_RETENTION_DAYS) {
|
|
return reply.badRequest(
|
|
`Audit log retention must be at least ${MIN_RETENTION_DAYS} days, or zero to keep entries for ever.`
|
|
)
|
|
}
|
|
|
|
const previous = WIKI.models.auditLog.retentionDays()
|
|
await WIKI.models.settings.updateConfig('audit', { retentionDays: req.body.retentionDays })
|
|
WIKI.config.audit = { ...WIKI.config.audit, retentionDays: req.body.retentionDays }
|
|
|
|
await audit(req, 'admin', 'updateAuditConfig', {
|
|
retentionDays: req.body.retentionDays,
|
|
previousRetentionDays: previous
|
|
})
|
|
|
|
return { retentionDays: req.body.retentionDays }
|
|
}
|
|
)
|
|
}
|
|
|
|
export default routes
|