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,
|
"when": 1788192490711,
|
||||||
"tag": "0012_sour_pixie",
|
"tag": "0012_sour_pixie",
|
||||||
"breakpoints": true
|
"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] }),
|
pk: primaryKey({ columns: [table.userId, table.gifId] }),
|
||||||
userIdx: index('idx_gif_favorites_user_id').on(table.userId),
|
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 { dmRoutes } from './routes/dm.js';
|
||||||
import { livekitRoutes } from './routes/livekit.js';
|
import { livekitRoutes } from './routes/livekit.js';
|
||||||
import { spotifyRoutes } from './routes/spotify.js';
|
import { spotifyRoutes } from './routes/spotify.js';
|
||||||
|
import { auditRoutes } from './routes/audit.js';
|
||||||
import { socialRoutes } from './routes/social.js';
|
import { socialRoutes } from './routes/social.js';
|
||||||
import { settingsRoutes } from './routes/settings.js';
|
import { settingsRoutes } from './routes/settings.js';
|
||||||
import { utilRoutes } from './routes/utils.js';
|
import { utilRoutes } from './routes/utils.js';
|
||||||
@@ -128,6 +129,7 @@ async function main(): Promise<void> {
|
|||||||
await app.register(dmRoutes);
|
await app.register(dmRoutes);
|
||||||
await app.register(livekitRoutes);
|
await app.register(livekitRoutes);
|
||||||
await app.register(spotifyRoutes);
|
await app.register(spotifyRoutes);
|
||||||
|
await app.register(auditRoutes);
|
||||||
await app.register(socialRoutes);
|
await app.register(socialRoutes);
|
||||||
await app.register(settingsRoutes);
|
await app.register(settingsRoutes);
|
||||||
await app.register(utilRoutes);
|
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 { eq, and, inArray } from 'drizzle-orm';
|
||||||
import { getDb, schema } from '../db/index.js';
|
import { getDb, schema } from '../db/index.js';
|
||||||
import { authenticate } from '../utils/auth.js';
|
import { authenticate } from '../utils/auth.js';
|
||||||
|
import { recordAuditEvent } from '../utils/auditLog.js';
|
||||||
import { generateSnowflake } from '../utils/snowflake.js';
|
import { generateSnowflake } from '../utils/snowflake.js';
|
||||||
import { isMember, hasPermission, getChannelSpaceId, PermissionBits, computePermissions } from '../utils/permissions.js';
|
import { isMember, hasPermission, getChannelSpaceId, PermissionBits, computePermissions } from '../utils/permissions.js';
|
||||||
import { permissionsToString } from '@backspace/shared/src/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
|
// Return the channel with the creator's computed permissions (same shape as
|
||||||
// the channel_created WS event) so the client can render it immediately
|
// the channel_created WS event) so the client can render it immediately
|
||||||
// without waiting for the broadcast to round-trip.
|
// 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);
|
const creatorPerms = computePermissions(request.userId, id, channelId);
|
||||||
return reply.code(201).send({
|
return reply.code(201).send({
|
||||||
...channelData,
|
...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);
|
return reply.code(200).send(channelData);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -418,6 +437,15 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
connectionManager.sendToUser(uid, deleteEvent);
|
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 });
|
return reply.code(200).send({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import { eq, and, inArray } from 'drizzle-orm';
|
import { eq, and, inArray } from 'drizzle-orm';
|
||||||
import { getDb, getRawDb, schema } from '../db/index.js';
|
import { getDb, getRawDb, schema } from '../db/index.js';
|
||||||
import { authenticate } from '../utils/auth.js';
|
import { authenticate } from '../utils/auth.js';
|
||||||
|
import { recordAuditEvent } from '../utils/auditLog.js';
|
||||||
import { generateSnowflake } from '../utils/snowflake.js';
|
import { generateSnowflake } from '../utils/snowflake.js';
|
||||||
import { isMember, isSpaceOwner, isBanned, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.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';
|
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,
|
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);
|
return reply.code(200).send(spaceData);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1005,6 +1015,16 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
userId: uid,
|
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 });
|
return reply.code(200).send({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1068,6 +1088,17 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
checkVoicePermissions(id);
|
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);
|
return reply.code(201).send(role);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1125,6 +1156,15 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
checkVoicePermissions(id);
|
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);
|
return reply.code(200).send(updated);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1247,6 +1287,14 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
space: spaceData,
|
space: spaceData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
recordAuditEvent({
|
||||||
|
spaceId: id,
|
||||||
|
actorId: request.userId,
|
||||||
|
action: 'space.transfer_ownership',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: newOwnerId,
|
||||||
|
});
|
||||||
|
|
||||||
return reply.code(200).send(spaceData);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
"./src/activities": "./src/activities.ts",
|
"./src/activities": "./src/activities.ts",
|
||||||
"./src/activities.js": "./src/activities.ts",
|
"./src/activities.js": "./src/activities.ts",
|
||||||
"./src/constants": "./src/constants.ts",
|
"./src/constants": "./src/constants.ts",
|
||||||
"./src/constants.js": "./src/constants.ts"
|
"./src/constants.js": "./src/constants.ts",
|
||||||
|
"./src/audit": "./src/audit.ts",
|
||||||
|
"./src/audit.js": "./src/audit.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Audit action vocabulary, shared so the server writes and the client renders
|
||||||
|
* the same strings. Values are stored in the database, so renaming one
|
||||||
|
* rewrites history — add new actions instead.
|
||||||
|
*/
|
||||||
|
export const AUDIT_ACTIONS = [
|
||||||
|
'space.update',
|
||||||
|
'space.transfer_ownership',
|
||||||
|
'channel.create',
|
||||||
|
'channel.update',
|
||||||
|
'channel.delete',
|
||||||
|
'member.kick',
|
||||||
|
'member.leave',
|
||||||
|
'member.ban',
|
||||||
|
'member.unban',
|
||||||
|
'role.create',
|
||||||
|
'role.update',
|
||||||
|
'role.delete',
|
||||||
|
'invite.create',
|
||||||
|
'message.delete',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
||||||
|
|
||||||
|
export interface AuditEventActor {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditEvent {
|
||||||
|
id: string;
|
||||||
|
spaceId: string;
|
||||||
|
action: AuditAction;
|
||||||
|
actor: AuditEventActor | null;
|
||||||
|
targetType: string | null;
|
||||||
|
targetId: string | null;
|
||||||
|
/** Shape depends on `action`; used for display only. */
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AUDIT_PAGE_SIZE = 50;
|
||||||
@@ -73,6 +73,7 @@ import type {
|
|||||||
ReattachResponse,
|
ReattachResponse,
|
||||||
Activity,
|
Activity,
|
||||||
} from '@backspace/shared';
|
} from '@backspace/shared';
|
||||||
|
import type { AuditEvent } from '@backspace/shared/src/audit.js';
|
||||||
import { getApiForOrigin, getOwnerInstanceForDm } from '../utils/crossStoreResolvers';
|
import { getApiForOrigin, getOwnerInstanceForDm } from '../utils/crossStoreResolvers';
|
||||||
|
|
||||||
export type { FederationPeer, FederationOrphanedAccount, FederationResetEvent, FederationResetEventsResponse, ApprovalRequest, PeeringSubscription, PeeringNotification };
|
export type { FederationPeer, FederationOrphanedAccount, FederationResetEvent, FederationResetEventsResponse, ApprovalRequest, PeeringSubscription, PeeringNotification };
|
||||||
@@ -285,6 +286,10 @@ export class BackspaceApiClient {
|
|||||||
removeFavorite: (id: string) => Promise<void>;
|
removeFavorite: (id: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
readonly audit: {
|
||||||
|
log: (spaceId: string, before?: string) => Promise<{ events: AuditEvent[]; hasMore: boolean }>;
|
||||||
|
};
|
||||||
|
|
||||||
readonly spotify: {
|
readonly spotify: {
|
||||||
status: () => Promise<{ configured: boolean; connected: boolean }>;
|
status: () => Promise<{ configured: boolean; connected: boolean }>;
|
||||||
authorizeUrl: () => Promise<{ url: string }>;
|
authorizeUrl: () => Promise<{ url: string }>;
|
||||||
@@ -699,6 +704,16 @@ export class BackspaceApiClient {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.audit = {
|
||||||
|
log: (spaceId: string, before?: string) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (before) params.set('before', before);
|
||||||
|
const qs = params.toString();
|
||||||
|
return request<{ events: AuditEvent[]; hasMore: boolean }>(
|
||||||
|
'GET', `/spaces/${spaceId}/audit-log${qs ? `?${qs}` : ''}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
this.spotify = {
|
this.spotify = {
|
||||||
status: () => request<{ configured: boolean; connected: boolean }>('GET', '/connections/spotify/status'),
|
status: () => request<{ configured: boolean; connected: boolean }>('GET', '/connections/spotify/status'),
|
||||||
authorizeUrl: () => request<{ url: string }>('GET', '/connections/spotify/authorize'),
|
authorizeUrl: () => request<{ url: string }>('GET', '/connections/spotify/authorize'),
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { Avatar } from '../ui/Avatar';
|
|||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||||
import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel';
|
import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel';
|
||||||
|
import { AuditLogPanel } from './spaceSettingsPanels/AuditLogPanel';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
|
import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
|
||||||
import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
|
import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
|
||||||
import { BansPanel } from './spaceSettingsPanels/BansPanel';
|
import { BansPanel } from './spaceSettingsPanels/BansPanel';
|
||||||
@@ -266,7 +268,8 @@ export function SpaceSettingsModal() {
|
|||||||
const spaces = useSpaceStore((s) => s.spaces);
|
const spaces = useSpaceStore((s) => s.spaces);
|
||||||
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
||||||
|
|
||||||
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans'>('overview');
|
const t = useT();
|
||||||
|
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans' | 'audit'>('overview');
|
||||||
const [mobileView, setMobileView] = useState<'tabs' | 'content'>('tabs');
|
const [mobileView, setMobileView] = useState<'tabs' | 'content'>('tabs');
|
||||||
|
|
||||||
const isOpen = activeModal === 'spaceSettings';
|
const isOpen = activeModal === 'spaceSettings';
|
||||||
@@ -331,6 +334,9 @@ export function SpaceSettingsModal() {
|
|||||||
{canBanMembers && (
|
{canBanMembers && (
|
||||||
<button onClick={() => handleTabClick('bans')} className={tabClass('bans')}>Bans</button>
|
<button onClick={() => handleTabClick('bans')} className={tabClass('bans')}>Bans</button>
|
||||||
)}
|
)}
|
||||||
|
{canManageSpace && (
|
||||||
|
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -366,6 +372,9 @@ export function SpaceSettingsModal() {
|
|||||||
{canBanMembers && (
|
{canBanMembers && (
|
||||||
<button onClick={() => handleTabClick('bans')} className={tabClass('bans')}>Bans</button>
|
<button onClick={() => handleTabClick('bans')} className={tabClass('bans')}>Bans</button>
|
||||||
)}
|
)}
|
||||||
|
{canManageSpace && (
|
||||||
|
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -392,6 +401,7 @@ export function SpaceSettingsModal() {
|
|||||||
{tab === 'members' && <MembersPanel spaceId={currentSpaceId} />}
|
{tab === 'members' && <MembersPanel spaceId={currentSpaceId} />}
|
||||||
{tab === 'roles' && canManageRoles && <RolesPanel spaceId={currentSpaceId} />}
|
{tab === 'roles' && canManageRoles && <RolesPanel spaceId={currentSpaceId} />}
|
||||||
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
|
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
|
||||||
|
{tab === 'audit' && canManageSpace && <AuditLogPanel spaceId={currentSpaceId} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import type { AuditEvent } from '@backspace/shared/src/audit.js';
|
||||||
|
import { api } from '../../../api/client';
|
||||||
|
import { Avatar } from '../../ui/Avatar';
|
||||||
|
import { useT, type TranslationKey } from '../../../i18n';
|
||||||
|
|
||||||
|
interface AuditLogPanelProps {
|
||||||
|
spaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Actions carry a `{name}` only when the metadata supplies one. */
|
||||||
|
function actionKey(action: string): TranslationKey {
|
||||||
|
const key = `audit.action.${action}` as TranslationKey;
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KNOWN_ACTIONS = new Set([
|
||||||
|
'space.update', 'space.transfer_ownership',
|
||||||
|
'channel.create', 'channel.update', 'channel.delete',
|
||||||
|
'member.kick', 'member.leave', 'member.ban', 'member.unban',
|
||||||
|
'role.create', 'role.update', 'role.delete',
|
||||||
|
'invite.create', 'message.delete',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function formatTimestamp(ms: number, locale: string): string {
|
||||||
|
return new Date(ms).toLocaleString(locale, {
|
||||||
|
day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuditLogPanel({ spaceId }: AuditLogPanelProps) {
|
||||||
|
const t = useT();
|
||||||
|
const [events, setEvents] = useState<AuditEvent[]>([]);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async (before?: string) => {
|
||||||
|
const page = await api.audit.log(spaceId, before);
|
||||||
|
setEvents((prev) => (before ? [...prev, ...page.events] : page.events));
|
||||||
|
setHasMore(page.hasMore);
|
||||||
|
}, [spaceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
load()
|
||||||
|
.catch(() => { /* an empty log reads the same as an unreachable one here */ })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const handleLoadMore = async () => {
|
||||||
|
const last = events[events.length - 1];
|
||||||
|
if (!last) return;
|
||||||
|
setLoadingMore(true);
|
||||||
|
try {
|
||||||
|
await load(last.id);
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<h2 className="text-lg font-semibold text-txt-primary mb-6">{t('audit.title')}</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-12 rounded-lg bg-surface-elevated animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<h2 className="text-lg font-semibold text-txt-primary mb-6">{t('audit.title')}</h2>
|
||||||
|
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="text-[13px] text-txt-tertiary">{t('audit.empty')}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{events.map((event) => {
|
||||||
|
const actorName = event.actor
|
||||||
|
? (event.actor.displayName ?? event.actor.username)
|
||||||
|
: t('audit.unknownActor');
|
||||||
|
const name = typeof event.metadata?.name === 'string' ? event.metadata.name : '';
|
||||||
|
// An action this build does not know about still gets a row: the
|
||||||
|
// log is a record, and hiding entries would defeat its purpose.
|
||||||
|
const key = KNOWN_ACTIONS.has(event.action) ? actionKey(event.action) : 'audit.action.unknown';
|
||||||
|
return (
|
||||||
|
<li key={event.id} className="flex items-center gap-3 py-2 px-2 rounded-lg hover:bg-interactive-hover">
|
||||||
|
<Avatar
|
||||||
|
src={event.actor?.avatar ?? null}
|
||||||
|
name={actorName}
|
||||||
|
size={28}
|
||||||
|
userId={event.actor?.id}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-[13px] text-txt-secondary truncate">
|
||||||
|
{t(key, { actor: actorName, name })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<time
|
||||||
|
dateTime={new Date(event.createdAt).toISOString()}
|
||||||
|
className="text-[11px] text-txt-tertiary flex-shrink-0 tabular-nums"
|
||||||
|
>
|
||||||
|
{formatTimestamp(event.createdAt, document.documentElement.lang || 'en')}
|
||||||
|
</time>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleLoadMore()}
|
||||||
|
disabled={loadingMore}
|
||||||
|
className="mt-4 px-3 py-1.5 rounded-md text-[13px] font-medium bg-surface-elevated text-txt-secondary hover:text-txt-primary transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('audit.loadMore')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,6 +33,27 @@ export const en = {
|
|||||||
'settings.voice.micTest.idle': 'Test your mic without joining a call.',
|
'settings.voice.micTest.idle': 'Test your mic without joining a call.',
|
||||||
'settings.voice.micTest.failed': 'Could not open the microphone. Check the device and its permission.',
|
'settings.voice.micTest.failed': 'Could not open the microphone. Check the device and its permission.',
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
'audit.title': 'Audit Log',
|
||||||
|
'audit.empty': 'Nothing recorded yet. Changes to the server show up here.',
|
||||||
|
'audit.loadMore': 'Load more',
|
||||||
|
'audit.unknownActor': 'Deleted account',
|
||||||
|
'audit.action.space.update': '{actor} updated the server settings',
|
||||||
|
'audit.action.space.transfer_ownership': '{actor} transferred ownership of the server',
|
||||||
|
'audit.action.channel.create': '{actor} created the channel {name}',
|
||||||
|
'audit.action.channel.update': '{actor} updated the channel {name}',
|
||||||
|
'audit.action.channel.delete': '{actor} deleted the channel {name}',
|
||||||
|
'audit.action.member.kick': '{actor} removed a member',
|
||||||
|
'audit.action.member.leave': '{actor} left the server',
|
||||||
|
'audit.action.member.ban': '{actor} banned a member',
|
||||||
|
'audit.action.member.unban': '{actor} unbanned a member',
|
||||||
|
'audit.action.role.create': '{actor} created the role {name}',
|
||||||
|
'audit.action.role.update': '{actor} updated the role {name}',
|
||||||
|
'audit.action.role.delete': '{actor} deleted a role',
|
||||||
|
'audit.action.invite.create': '{actor} created an invite',
|
||||||
|
'audit.action.message.delete': '{actor} deleted a message',
|
||||||
|
'audit.action.unknown': '{actor} performed an action',
|
||||||
|
|
||||||
// GIF picker
|
// GIF picker
|
||||||
'gif.search': 'Search GIFs',
|
'gif.search': 'Search GIFs',
|
||||||
'gif.tab.favorites': 'Favorites',
|
'gif.tab.favorites': 'Favorites',
|
||||||
|
|||||||
@@ -32,6 +32,27 @@ export const ptBR: Partial<Dictionary> = {
|
|||||||
'settings.voice.micTest.idle': 'Teste seu microfone sem entrar numa call.',
|
'settings.voice.micTest.idle': 'Teste seu microfone sem entrar numa call.',
|
||||||
'settings.voice.micTest.failed': 'Não foi possível abrir o microfone. Verifique o dispositivo e a permissão.',
|
'settings.voice.micTest.failed': 'Não foi possível abrir o microfone. Verifique o dispositivo e a permissão.',
|
||||||
|
|
||||||
|
// Registro de auditoria
|
||||||
|
'audit.title': 'Registro de auditoria',
|
||||||
|
'audit.empty': 'Nada registrado ainda. Mudanças no servidor aparecem aqui.',
|
||||||
|
'audit.loadMore': 'Carregar mais',
|
||||||
|
'audit.unknownActor': 'Conta excluída',
|
||||||
|
'audit.action.space.update': '{actor} alterou as configurações do servidor',
|
||||||
|
'audit.action.space.transfer_ownership': '{actor} transferiu a propriedade do servidor',
|
||||||
|
'audit.action.channel.create': '{actor} criou o canal {name}',
|
||||||
|
'audit.action.channel.update': '{actor} alterou o canal {name}',
|
||||||
|
'audit.action.channel.delete': '{actor} excluiu o canal {name}',
|
||||||
|
'audit.action.member.kick': '{actor} removeu um membro',
|
||||||
|
'audit.action.member.leave': '{actor} saiu do servidor',
|
||||||
|
'audit.action.member.ban': '{actor} baniu um membro',
|
||||||
|
'audit.action.member.unban': '{actor} removeu o banimento de um membro',
|
||||||
|
'audit.action.role.create': '{actor} criou o cargo {name}',
|
||||||
|
'audit.action.role.update': '{actor} alterou o cargo {name}',
|
||||||
|
'audit.action.role.delete': '{actor} excluiu um cargo',
|
||||||
|
'audit.action.invite.create': '{actor} criou um convite',
|
||||||
|
'audit.action.message.delete': '{actor} excluiu uma mensagem',
|
||||||
|
'audit.action.unknown': '{actor} realizou uma ação',
|
||||||
|
|
||||||
// Seletor de GIF
|
// Seletor de GIF
|
||||||
'gif.search': 'Buscar GIFs',
|
'gif.search': 'Buscar GIFs',
|
||||||
'gif.tab.favorites': 'Favoritos',
|
'gif.tab.favorites': 'Favoritos',
|
||||||
|
|||||||
Reference in New Issue
Block a user