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

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:
2026-08-31 13:22:52 -03:00
parent fb662bfe12
commit bbb190cbda
16 changed files with 4604 additions and 2 deletions
+28
View File
@@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify';
import { eq, and, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { recordAuditEvent } from '../utils/auditLog.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isMember, hasPermission, getChannelSpaceId, PermissionBits, computePermissions } from '../utils/permissions.js';
import { permissionsToString } from '@backspace/shared/src/permissions.js';
@@ -257,6 +258,15 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
// Return the channel with the creator's computed permissions (same shape as
// the channel_created WS event) so the client can render it immediately
// without waiting for the broadcast to round-trip.
recordAuditEvent({
spaceId: id,
actorId: request.userId,
action: 'channel.create',
targetType: 'channel',
targetId: channelId,
metadata: { name: channelData.name, type: channelData.type },
});
const creatorPerms = computePermissions(request.userId, id, channelId);
return reply.code(201).send({
...channelData,
@@ -346,6 +356,15 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
});
}
recordAuditEvent({
spaceId,
actorId: request.userId,
action: 'channel.update',
targetType: 'channel',
targetId: id,
metadata: { name: channelData.name },
});
return reply.code(200).send(channelData);
});
@@ -418,6 +437,15 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
connectionManager.sendToUser(uid, deleteEvent);
}
recordAuditEvent({
spaceId,
actorId: request.userId,
action: 'channel.delete',
targetType: 'channel',
targetId: id,
metadata: { name: channel.name },
});
return reply.code(200).send({ success: true });
});