1.1.3
This commit is contained in:
127
convex/audit.ts
Normal file
127
convex/audit.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import type {
|
||||
GenericMutationCtx,
|
||||
GenericQueryCtx,
|
||||
} from "convex/server";
|
||||
import type { DataModel, Id } from "./_generated/dataModel";
|
||||
import { hasPermission } from "./roles";
|
||||
import { getPublicStorageUrl } from "./storageUrl";
|
||||
|
||||
/**
|
||||
* Known audit actions. Client-facing label mapping lives in the
|
||||
* settings UI; the backend just stores strings so future actions
|
||||
* don't require a schema change.
|
||||
*/
|
||||
export const AUDIT_ACTIONS = {
|
||||
CHANNEL_CREATE: "channel.create",
|
||||
CHANNEL_DELETE: "channel.delete",
|
||||
CHANNEL_RENAME: "channel.rename",
|
||||
CHANNEL_UPDATE_TOPIC: "channel.update_topic",
|
||||
ROLE_CREATE: "role.create",
|
||||
ROLE_DELETE: "role.delete",
|
||||
ROLE_UPDATE: "role.update",
|
||||
ROLE_ASSIGN: "role.assign",
|
||||
ROLE_UNASSIGN: "role.unassign",
|
||||
SERVER_SETTINGS_UPDATE: "server.settings_update",
|
||||
BAN_ADD: "ban.add",
|
||||
BAN_REMOVE: "ban.remove",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Internal helper — call from mutations that mutate server state.
|
||||
* Silent on failure: an audit-write that throws would roll back the
|
||||
* real mutation, which is worse than a missing log entry.
|
||||
*/
|
||||
export async function logAudit(
|
||||
ctx: GenericMutationCtx<DataModel>,
|
||||
args: {
|
||||
actorId: Id<"userProfiles">;
|
||||
action: string;
|
||||
targetType?: string;
|
||||
targetId?: string;
|
||||
targetName?: string;
|
||||
metadata?: unknown;
|
||||
},
|
||||
): Promise<void> {
|
||||
try {
|
||||
await ctx.db.insert("auditLog", {
|
||||
actorId: args.actorId,
|
||||
action: args.action,
|
||||
targetType: args.targetType,
|
||||
targetId: args.targetId,
|
||||
targetName: args.targetName,
|
||||
metadata: args.metadata,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
// Audit is best-effort. Don't block the caller.
|
||||
}
|
||||
}
|
||||
|
||||
// Any moderator-adjacent permission is enough to view the log. We
|
||||
// don't want to leak the log to @everyone but equally don't want to
|
||||
// hide it behind a narrow permission no role has by default.
|
||||
async function canViewAuditLog(
|
||||
ctx: GenericQueryCtx<DataModel>,
|
||||
userId: Id<"userProfiles">,
|
||||
): Promise<boolean> {
|
||||
return (
|
||||
(await hasPermission(ctx, userId, "ban_members")) ||
|
||||
(await hasPermission(ctx, userId, "manage_channels")) ||
|
||||
(await hasPermission(ctx, userId, "manage_roles")) ||
|
||||
(await hasPermission(ctx, userId, "manage_messages"))
|
||||
);
|
||||
}
|
||||
|
||||
export const list = query({
|
||||
args: {
|
||||
actorId: v.id("userProfiles"),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
returns: v.array(v.any()),
|
||||
handler: async (ctx, args) => {
|
||||
if (!(await canViewAuditLog(ctx, args.actorId))) {
|
||||
throw new Error("Not authorized to view the audit log.");
|
||||
}
|
||||
const limit = Math.min(Math.max(args.limit ?? 200, 1), 500);
|
||||
const rows = await ctx.db
|
||||
.query("auditLog")
|
||||
.withIndex("by_created_at")
|
||||
.order("desc")
|
||||
.take(limit);
|
||||
|
||||
// Walk rows once, de-duping actors via the map itself. Iterating
|
||||
// a Set widens the element type and breaks `ctx.db.get`'s
|
||||
// narrowing — using the map as its own seen-set avoids that.
|
||||
const actors = new Map<
|
||||
string,
|
||||
{ username: string; displayName?: string; avatarUrl: string | null }
|
||||
>();
|
||||
for (const r of rows) {
|
||||
if (actors.has(r.actorId)) continue;
|
||||
const user = await ctx.db.get(r.actorId);
|
||||
if (!user) continue;
|
||||
let avatarUrl: string | null = null;
|
||||
if (user.avatarStorageId) {
|
||||
avatarUrl = await getPublicStorageUrl(ctx, user.avatarStorageId);
|
||||
}
|
||||
actors.set(r.actorId, {
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return rows.map((r) => ({
|
||||
_id: r._id,
|
||||
action: r.action,
|
||||
targetType: r.targetType,
|
||||
targetId: r.targetId,
|
||||
targetName: r.targetName,
|
||||
metadata: r.metadata,
|
||||
createdAt: r.createdAt,
|
||||
actor: actors.get(r.actorId) ?? { username: "unknown", avatarUrl: null },
|
||||
}));
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user