feat(audit): append-only audit log for spaces
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
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) Waiting to run
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
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:
@@ -3,6 +3,7 @@ import type { FastifyInstance } from 'fastify';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { getDb, getRawDb, 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, isSpaceOwner, isBanned, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js';
|
||||
import { DEFAULT_EVERYONE_PERMISSIONS, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js';
|
||||
@@ -489,6 +490,15 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
||||
space: spaceData,
|
||||
});
|
||||
|
||||
recordAuditEvent({
|
||||
spaceId: id,
|
||||
actorId: request.userId,
|
||||
action: 'space.update',
|
||||
targetType: 'space',
|
||||
targetId: id,
|
||||
metadata: { fields: Object.keys(updates) },
|
||||
});
|
||||
|
||||
return reply.code(200).send(spaceData);
|
||||
});
|
||||
|
||||
@@ -1005,6 +1015,16 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
||||
userId: uid,
|
||||
});
|
||||
|
||||
recordAuditEvent({
|
||||
spaceId: id,
|
||||
actorId: request.userId,
|
||||
// Leaving on your own is not the same event as being removed by someone
|
||||
// else, and a log that conflates the two misleads exactly when it matters.
|
||||
action: request.userId === uid ? 'member.leave' : 'member.kick',
|
||||
targetType: 'user',
|
||||
targetId: uid,
|
||||
});
|
||||
|
||||
return reply.code(200).send({ success: true });
|
||||
});
|
||||
|
||||
@@ -1068,6 +1088,17 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
checkVoicePermissions(id);
|
||||
|
||||
recordAuditEvent({
|
||||
spaceId: id,
|
||||
actorId: request.userId,
|
||||
action: 'role.create',
|
||||
targetType: 'role',
|
||||
// roleId is the value just inserted; `role` is a read-back the compiler
|
||||
// cannot prove returned a row.
|
||||
targetId: roleId,
|
||||
metadata: { name: role?.name ?? null },
|
||||
});
|
||||
|
||||
return reply.code(201).send(role);
|
||||
});
|
||||
|
||||
@@ -1125,6 +1156,15 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
checkVoicePermissions(id);
|
||||
|
||||
recordAuditEvent({
|
||||
spaceId: id,
|
||||
actorId: request.userId,
|
||||
action: 'role.update',
|
||||
targetType: 'role',
|
||||
targetId: roleId,
|
||||
metadata: { name: updated?.name ?? null },
|
||||
});
|
||||
|
||||
return reply.code(200).send(updated);
|
||||
});
|
||||
|
||||
@@ -1247,6 +1287,14 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
||||
space: spaceData,
|
||||
});
|
||||
|
||||
recordAuditEvent({
|
||||
spaceId: id,
|
||||
actorId: request.userId,
|
||||
action: 'space.transfer_ownership',
|
||||
targetType: 'user',
|
||||
targetId: newOwnerId,
|
||||
});
|
||||
|
||||
return reply.code(200).send(spaceData);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user