feat(audit): append-only audit log for spaces
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Records who changed what, and is the mechanism statistics will read — one event table rather than two logs that drift apart. The table is deliberately generic (action + target + JSON metadata) so a new action needs no migration. Writes never throw: a kick must not fail because its log entry could not be written, since the kick already happened. Leaving is recorded as a different action from being removed. The same route serves both, and a log that conflates them misleads exactly when it matters. Actor is nullable with ON DELETE SET NULL: the event outlives the account, and a log that vanished with its actor would be worthless. Reads are gated on MANAGE_SPACE rather than a new permission bit, which would default to nobody until every role was re-edited. Paging uses the snowflake id, stable even for two events in the same millisecond, and an action this build does not know still renders a row.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { generateSnowflake } from './snowflake.js';
|
||||
import type { AuditAction } from '@backspace/shared/src/audit.js';
|
||||
|
||||
interface RecordAuditEventInput {
|
||||
spaceId: string;
|
||||
actorId: string | null;
|
||||
action: AuditAction;
|
||||
targetType?: string | null;
|
||||
targetId?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends one entry to a space's audit log.
|
||||
*
|
||||
* Never throws: an audit write must not be able to fail the action it is
|
||||
* describing. A moderator kicking someone must not see the kick fail because
|
||||
* the log could not be written — the kick already happened.
|
||||
*/
|
||||
export function recordAuditEvent(input: RecordAuditEventInput): void {
|
||||
try {
|
||||
getDb().insert(schema.auditEvents).values({
|
||||
id: generateSnowflake(),
|
||||
spaceId: input.spaceId,
|
||||
actorId: input.actorId,
|
||||
action: input.action,
|
||||
targetType: input.targetType ?? null,
|
||||
targetId: input.targetId ?? null,
|
||||
metadata: input.metadata ? JSON.stringify(input.metadata) : null,
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
} catch (err) {
|
||||
console.warn('[audit] failed to record event', input.action, err);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user