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:
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE `audit_events` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`space_id` text NOT NULL,
|
||||
`actor_id` text,
|
||||
`action` text NOT NULL,
|
||||
`target_type` text,
|
||||
`target_id` text,
|
||||
`metadata` text,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`actor_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_audit_events_space_created` ON `audit_events` (`space_id`,`created_at`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_audit_events_actor` ON `audit_events` (`actor_id`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,13 @@
|
||||
"when": 1788192490711,
|
||||
"tag": "0012_sour_pixie",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "6",
|
||||
"when": 1788193162813,
|
||||
"tag": "0013_fancy_betty_brant",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -587,3 +587,26 @@ export const gifFavorites = sqliteTable('gif_favorites', {
|
||||
pk: primaryKey({ columns: [table.userId, table.gifId] }),
|
||||
userIdx: index('idx_gif_favorites_user_id').on(table.userId),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Append-only record of who changed what in a space.
|
||||
*
|
||||
* Deliberately generic (action + target + JSON metadata) rather than a column
|
||||
* per event type: new actions must not require a migration. Statistics read
|
||||
* this same table — two features, one mechanism, instead of two logs that
|
||||
* drift apart.
|
||||
*/
|
||||
export const auditEvents = sqliteTable('audit_events', {
|
||||
id: text('id').primaryKey(),
|
||||
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||
actorId: text('actor_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
action: text('action').notNull(),
|
||||
targetType: text('target_type'),
|
||||
targetId: text('target_id'),
|
||||
// JSON blob; shape depends on `action`. Never trusted for permissions.
|
||||
metadata: text('metadata'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
}, (table) => ({
|
||||
spaceIdx: index('idx_audit_events_space_created').on(table.spaceId, table.createdAt),
|
||||
actorIdx: index('idx_audit_events_actor').on(table.actorId),
|
||||
}));
|
||||
|
||||
@@ -16,6 +16,7 @@ import { filesRoutes } from './routes/files.js';
|
||||
import { dmRoutes } from './routes/dm.js';
|
||||
import { livekitRoutes } from './routes/livekit.js';
|
||||
import { spotifyRoutes } from './routes/spotify.js';
|
||||
import { auditRoutes } from './routes/audit.js';
|
||||
import { socialRoutes } from './routes/social.js';
|
||||
import { settingsRoutes } from './routes/settings.js';
|
||||
import { utilRoutes } from './routes/utils.js';
|
||||
@@ -128,6 +129,7 @@ async function main(): Promise<void> {
|
||||
await app.register(dmRoutes);
|
||||
await app.register(livekitRoutes);
|
||||
await app.register(spotifyRoutes);
|
||||
await app.register(auditRoutes);
|
||||
await app.register(socialRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(utilRoutes);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { and, desc, eq, lt } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { hasPermission } from '../utils/permissions.js';
|
||||
import { PermissionBits } from '@backspace/shared/src/permissions.js';
|
||||
import { AUDIT_PAGE_SIZE, type AuditAction, type AuditEvent } from '@backspace/shared/src/audit.js';
|
||||
|
||||
export async function auditRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get<{ Params: { id: string }; Querystring: { before?: string; limit?: string } }>(
|
||||
'/api/spaces/:id/audit-log',
|
||||
{ preHandler: authenticate },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
|
||||
// Gated on MANAGE_SPACE rather than a new permission bit: the log names
|
||||
// who did what to whom, which is administrator-shaped information, and a
|
||||
// new bit would silently default to nobody until roles were re-edited.
|
||||
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
|
||||
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||
}
|
||||
|
||||
const limit = Math.min(Math.max(Number(request.query.limit) || AUDIT_PAGE_SIZE, 1), AUDIT_PAGE_SIZE);
|
||||
const db = getDb();
|
||||
|
||||
// Ids are snowflakes, so ordering by id is chronological and gives a
|
||||
// stable cursor even when two events land in the same millisecond.
|
||||
const where = request.query.before
|
||||
? and(eq(schema.auditEvents.spaceId, id), lt(schema.auditEvents.id, request.query.before))
|
||||
: eq(schema.auditEvents.spaceId, id);
|
||||
|
||||
const rows = db.select({
|
||||
id: schema.auditEvents.id,
|
||||
spaceId: schema.auditEvents.spaceId,
|
||||
action: schema.auditEvents.action,
|
||||
targetType: schema.auditEvents.targetType,
|
||||
targetId: schema.auditEvents.targetId,
|
||||
metadata: schema.auditEvents.metadata,
|
||||
createdAt: schema.auditEvents.createdAt,
|
||||
actorId: schema.users.id,
|
||||
actorUsername: schema.users.username,
|
||||
actorDisplayName: schema.users.displayName,
|
||||
actorAvatar: schema.users.avatar,
|
||||
})
|
||||
.from(schema.auditEvents)
|
||||
.leftJoin(schema.users, eq(schema.auditEvents.actorId, schema.users.id))
|
||||
.where(where)
|
||||
.orderBy(desc(schema.auditEvents.id))
|
||||
.limit(limit)
|
||||
.all();
|
||||
|
||||
const events: AuditEvent[] = rows.map((r) => ({
|
||||
id: r.id,
|
||||
spaceId: r.spaceId,
|
||||
action: r.action as AuditAction,
|
||||
// Null when the account was deleted: the event stays, the actor does
|
||||
// not — an audit log that vanished with its actor would be useless.
|
||||
actor: r.actorId
|
||||
? { id: r.actorId, username: r.actorUsername!, displayName: r.actorDisplayName, avatar: r.actorAvatar }
|
||||
: null,
|
||||
targetType: r.targetType,
|
||||
targetId: r.targetId,
|
||||
metadata: parseMetadata(r.metadata),
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
|
||||
return reply.code(200).send({ events, hasMore: events.length === limit });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Metadata is written by us, but a malformed row must not break the whole page. */
|
||||
function parseMetadata(raw: string | null): Record<string, unknown> | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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