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:
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user