diff --git a/artifacts/api-server/src/routes/apps.ts b/artifacts/api-server/src/routes/apps.ts index 96ad1532..69b48013 100644 --- a/artifacts/api-server/src/routes/apps.ts +++ b/artifacts/api-server/src/routes/apps.ts @@ -175,6 +175,19 @@ router.post("/apps", requireAdmin, async (req, res): Promise => { } const [app] = await db.insert(appsTable).values(parsed.data).returning(); + await db.insert(auditLogsTable).values({ + actorUserId: req.session.userId ?? null, + action: "app.create", + targetType: "app", + targetId: app.id, + metadata: { + slug: app.slug, + nameAr: app.nameAr, + nameEn: app.nameEn, + route: app.route, + isActive: app.isActive, + }, + }); res.status(201).json(app); }); @@ -211,6 +224,11 @@ router.patch("/apps/:id", requireAdmin, async (req, res): Promise => { return; } + const [previous] = await db + .select() + .from(appsTable) + .where(eq(appsTable.id, params.data.id)); + const [app] = await db .update(appsTable) .set(parsed.data) @@ -222,6 +240,29 @@ router.patch("/apps/:id", requireAdmin, async (req, res): Promise => { return; } + if (previous) { + const changes: Record = {}; + for (const [key, value] of Object.entries(parsed.data)) { + const prevValue = (previous as Record)[key]; + if (prevValue !== value) { + changes[key] = { from: prevValue ?? null, to: value ?? null }; + } + } + if (Object.keys(changes).length > 0) { + await db.insert(auditLogsTable).values({ + actorUserId: req.session.userId ?? null, + action: "app.update", + targetType: "app", + targetId: app.id, + metadata: { + slug: app.slug, + nameEn: app.nameEn, + changes, + }, + }); + } + } + res.json(app); }); @@ -297,21 +338,21 @@ router.delete("/apps/:id", requireAdmin, async (req, res): Promise => { await db.delete(appsTable).where(eq(appsTable.id, appId)); - if (hasDeps && force) { - await db.insert(auditLogsTable).values({ - actorUserId: req.session.userId ?? null, - action: "app.force_delete", - targetType: "app", - targetId: appId, - metadata: { - slug: existing.slug, - nameEn: existing.nameEn, - groupCount, - restrictionCount, - openCount, - }, - }); - } + await db.insert(auditLogsTable).values({ + actorUserId: req.session.userId ?? null, + action: "app.delete", + targetType: "app", + targetId: appId, + metadata: { + slug: existing.slug, + nameAr: existing.nameAr, + nameEn: existing.nameEn, + force, + groupCount, + restrictionCount, + openCount, + }, + }); res.sendStatus(204); }); diff --git a/artifacts/api-server/src/routes/auth.ts b/artifacts/api-server/src/routes/auth.ts index bc145bbe..89d0d192 100644 --- a/artifacts/api-server/src/routes/auth.ts +++ b/artifacts/api-server/src/routes/auth.ts @@ -11,6 +11,7 @@ import { passwordResetTokensTable, groupsTable, userGroupsTable, + auditLogsTable, } from "@workspace/db"; import { requireAuth, requireAdmin, getUserRoles } from "../middlewares/auth"; import { saveSession, destroySession } from "../lib/session"; @@ -319,6 +320,18 @@ router.post( `${req.protocol}://${req.get("host")}`; const resetUrl = `${origin.replace(/\/$/, "")}/reset-password?token=${rawToken}`; + await db.insert(auditLogsTable).values({ + actorUserId: req.session.userId ?? null, + action: "auth.issue_reset_link", + targetType: "user", + targetId: user.id, + metadata: { + username: user.username, + email: user.email, + expiresAt: expiresAt.toISOString(), + }, + }); + res.json({ resetUrl, expiresAt: expiresAt.toISOString() }); }, ); diff --git a/artifacts/api-server/src/routes/groups.ts b/artifacts/api-server/src/routes/groups.ts index 9e1915dd..7ff4ad71 100644 --- a/artifacts/api-server/src/routes/groups.ts +++ b/artifacts/api-server/src/routes/groups.ts @@ -240,6 +240,20 @@ router.post("/groups", requireAdmin, async (req, res): Promise => { ); return g; }); + await db.insert(auditLogsTable).values({ + actorUserId: req.session.userId ?? null, + action: "group.create", + targetType: "group", + targetId: group.id, + metadata: { + name: group.name, + descriptionAr: group.descriptionAr, + descriptionEn: group.descriptionEn, + memberCount: parsed.data.userIds?.length ?? 0, + appCount: parsed.data.appIds?.length ?? 0, + roleCount: parsed.data.roleIds?.length ?? 0, + }, + }); res.status(201).json({ ...serializeGroup(group), memberCount: parsed.data.userIds?.length ?? 0, @@ -274,6 +288,18 @@ router.patch("/groups/:id", requireAdmin, async (req, res): Promise => { return; } const previousMemberIds = await getGroupMemberIds(id); + const previousAppIds = ( + await db + .select({ appId: groupAppsTable.appId }) + .from(groupAppsTable) + .where(eq(groupAppsTable.groupId, id)) + ).map((r) => r.appId); + const previousRoleIds = ( + await db + .select({ roleId: groupRolesTable.roleId }) + .from(groupRolesTable) + .where(eq(groupRolesTable.groupId, id)) + ).map((r) => r.roleId); const updates: Partial = {}; if (parsed.data.name !== undefined) updates.name = parsed.data.name; if (parsed.data.descriptionAr !== undefined) updates.descriptionAr = parsed.data.descriptionAr; @@ -306,6 +332,8 @@ router.patch("/groups/:id", requireAdmin, async (req, res): Promise => { .where(eq(userGroupsTable.groupId, id)); const currentMemberIds = users.map((u) => u.userId); + const currentAppIds = apps.map((a) => a.appId); + const currentRoleIds = roles.map((r) => r.roleId); const membershipChanged = parsed.data.userIds !== undefined; const grantsChanged = parsed.data.appIds !== undefined || parsed.data.roleIds !== undefined; @@ -320,6 +348,42 @@ router.patch("/groups/:id", requireAdmin, async (req, res): Promise => { await emitAppsChangedToUsers(Array.from(affected)); } + function diff(prev: number[], next: number[]) { + const prevSet = new Set(prev); + const nextSet = new Set(next); + return { + added: next.filter((n) => !prevSet.has(n)), + removed: prev.filter((n) => !nextSet.has(n)), + }; + } + + const fieldsChanged = Object.keys(updates).length > 0; + if (fieldsChanged || membershipChanged || grantsChanged) { + const meta: Record = { + name: group.name, + }; + if (fieldsChanged) { + meta.previousName = existing.name; + meta.changes = updates; + } + if (membershipChanged) { + meta.members = diff(previousMemberIds, currentMemberIds); + } + if (parsed.data.appIds !== undefined) { + meta.apps = diff(previousAppIds, currentAppIds); + } + if (parsed.data.roleIds !== undefined) { + meta.roles = diff(previousRoleIds, currentRoleIds); + } + await db.insert(auditLogsTable).values({ + actorUserId: req.session.userId ?? null, + action: "group.update", + targetType: "group", + targetId: id, + metadata: meta, + }); + } + res.json({ ...serializeGroup(group), appIds: apps.map((a) => a.appId), @@ -365,25 +429,42 @@ router.post("/groups/:id/:kind/:targetId", requireAdmin, async (req, res): Promi res.status(404).json({ error: `${kind.slice(0, -1)} not found` }); return; } + let inserted: { groupId: number }[] = []; if (kind === "users") { - await db + inserted = await db .insert(userGroupsTable) .values({ groupId: id, userId: targetId }) - .onConflictDoNothing(); + .onConflictDoNothing() + .returning({ groupId: userGroupsTable.groupId }); await emitAppsChangedToUsers([targetId]); } else if (kind === "apps") { - await db + inserted = await db .insert(groupAppsTable) .values({ groupId: id, appId: targetId }) - .onConflictDoNothing(); + .onConflictDoNothing() + .returning({ groupId: groupAppsTable.groupId }); await emitAppsChangedToUsers(await getGroupMemberIds(id)); } else { - await db + inserted = await db .insert(groupRolesTable) .values({ groupId: id, roleId: targetId }) - .onConflictDoNothing(); + .onConflictDoNothing() + .returning({ groupId: groupRolesTable.groupId }); await emitAppsChangedToUsers(await getGroupMemberIds(id)); } + if (inserted.length > 0) { + const subject = kind === "users" ? "user" : kind === "apps" ? "app" : "role"; + await db.insert(auditLogsTable).values({ + actorUserId: req.session.userId ?? null, + action: `group.${subject}.add`, + targetType: "group", + targetId: id, + metadata: { + groupName: group.name, + [`${subject}Id`]: targetId, + }, + }); + } res.sendStatus(204); }); @@ -400,24 +481,41 @@ router.delete("/groups/:id/:kind/:targetId", requireAdmin, async (req, res): Pro res.status(404).json({ error: "Group not found" }); return; } + let removed: { groupId: number }[] = []; if (kind === "users") { - await db + removed = await db .delete(userGroupsTable) - .where(sql`${userGroupsTable.groupId} = ${id} and ${userGroupsTable.userId} = ${targetId}`); + .where(sql`${userGroupsTable.groupId} = ${id} and ${userGroupsTable.userId} = ${targetId}`) + .returning({ groupId: userGroupsTable.groupId }); await emitAppsChangedToUsers([targetId]); } else if (kind === "apps") { const memberIds = await getGroupMemberIds(id); - await db + removed = await db .delete(groupAppsTable) - .where(sql`${groupAppsTable.groupId} = ${id} and ${groupAppsTable.appId} = ${targetId}`); + .where(sql`${groupAppsTable.groupId} = ${id} and ${groupAppsTable.appId} = ${targetId}`) + .returning({ groupId: groupAppsTable.groupId }); await emitAppsChangedToUsers(memberIds); } else { const memberIds = await getGroupMemberIds(id); - await db + removed = await db .delete(groupRolesTable) - .where(sql`${groupRolesTable.groupId} = ${id} and ${groupRolesTable.roleId} = ${targetId}`); + .where(sql`${groupRolesTable.groupId} = ${id} and ${groupRolesTable.roleId} = ${targetId}`) + .returning({ groupId: groupRolesTable.groupId }); await emitAppsChangedToUsers(memberIds); } + if (removed.length > 0) { + const subject = kind === "users" ? "user" : kind === "apps" ? "app" : "role"; + await db.insert(auditLogsTable).values({ + actorUserId: req.session.userId ?? null, + action: `group.${subject}.remove`, + targetType: "group", + targetId: id, + metadata: { + groupName: group.name, + [`${subject}Id`]: targetId, + }, + }); + } res.sendStatus(204); }); @@ -460,20 +558,19 @@ router.delete("/groups/:id", requireAdmin, async (req, res): Promise => { return; } await db.delete(groupsTable).where(eq(groupsTable.id, id)); - if (isNonEmpty && force) { - await db.insert(auditLogsTable).values({ - actorUserId: req.session.userId ?? null, - action: "group.force_delete", - targetType: "group", - targetId: id, - metadata: { - name: existing.name, - memberCount, - appCount, - roleCount, - }, - }); - } + await db.insert(auditLogsTable).values({ + actorUserId: req.session.userId ?? null, + action: "group.delete", + targetType: "group", + targetId: id, + metadata: { + name: existing.name, + force, + memberCount, + appCount, + roleCount, + }, + }); await emitAppsChangedToUsers(memberIds); res.sendStatus(204); }); diff --git a/artifacts/api-server/src/routes/roles.ts b/artifacts/api-server/src/routes/roles.ts index e6119cab..a83d0277 100644 --- a/artifacts/api-server/src/routes/roles.ts +++ b/artifacts/api-server/src/routes/roles.ts @@ -7,6 +7,7 @@ import { groupRolesTable, rolePermissionsTable, permissionsTable, + auditLogsTable, } from "@workspace/db"; import { requireAdmin } from "../middlewares/auth"; import { @@ -81,6 +82,17 @@ router.post("/roles", requireAdmin, async (req, res): Promise => { descriptionEn: parsed.data.descriptionEn ?? null, }) .returning(); + await db.insert(auditLogsTable).values({ + actorUserId: req.session.userId ?? null, + action: "role.create", + targetType: "role", + targetId: role.id, + metadata: { + name: role.name, + descriptionAr: role.descriptionAr, + descriptionEn: role.descriptionEn, + }, + }); res.status(201).json(serializeRole(role)); }); @@ -129,6 +141,17 @@ router.patch("/roles/:id", requireAdmin, async (req, res): Promise => { if (parsed.data.descriptionEn !== undefined) updates.descriptionEn = parsed.data.descriptionEn; if (Object.keys(updates).length > 0) { await db.update(rolesTable).set(updates).where(eq(rolesTable.id, id)); + await db.insert(auditLogsTable).values({ + actorUserId: req.session.userId ?? null, + action: "role.update", + targetType: "role", + targetId: id, + metadata: { + previousName: existing.name, + name: updates.name ?? existing.name, + changes: updates, + }, + }); } const [role] = await db.select().from(rolesTable).where(eq(rolesTable.id, id)); res.json(serializeRole(role)); @@ -171,6 +194,17 @@ router.delete("/roles/:id", requireAdmin, async (req, res): Promise => { await tx.delete(rolePermissionsTable).where(eq(rolePermissionsTable.roleId, id)); await tx.delete(rolesTable).where(eq(rolesTable.id, id)); }); + await db.insert(auditLogsTable).values({ + actorUserId: req.session.userId ?? null, + action: "role.delete", + targetType: "role", + targetId: id, + metadata: { + name: existing.name, + descriptionAr: existing.descriptionAr, + descriptionEn: existing.descriptionEn, + }, + }); res.sendStatus(204); }); @@ -236,6 +270,12 @@ router.put("/roles/:id/permissions", requireAdmin, async (req, res): Promise r.permissionId); await db.transaction(async (tx) => { await tx.delete(rolePermissionsTable).where(eq(rolePermissionsTable.roleId, id)); if (requestedIds.length > 0) { @@ -245,6 +285,23 @@ router.put("/roles/:id/permissions", requireAdmin, async (req, res): Promise !previousSet.has(p)); + const removed = previousIds.filter((p) => !requestedSet.has(p)); + if (added.length > 0 || removed.length > 0) { + await db.insert(auditLogsTable).values({ + actorUserId: req.session.userId ?? null, + action: "role.permissions.replace", + targetType: "role", + targetId: id, + metadata: { + roleName: role.name, + added, + removed, + }, + }); + } const rows = await db .select({ id: permissionsTable.id, @@ -281,7 +338,10 @@ router.post("/roles/:id/permissions", requireAdmin, async (req, res): Promise 0) { await emitAppsChangedToRoleHolders(id); + await db.insert(auditLogsTable).values({ + actorUserId: req.session.userId ?? null, + action: "role.permission.remove", + targetType: "role", + targetId: id, + metadata: { + roleName: role.name, + permissionId, + permissionName: perm?.name ?? null, + }, + }); } res.sendStatus(204); }); diff --git a/artifacts/api-server/src/routes/settings.ts b/artifacts/api-server/src/routes/settings.ts index 35ea4d95..cb107149 100644 --- a/artifacts/api-server/src/routes/settings.ts +++ b/artifacts/api-server/src/routes/settings.ts @@ -1,7 +1,7 @@ import { Router, type IRouter } from "express"; import { eq } from "drizzle-orm"; import { db } from "@workspace/db"; -import { appSettingsTable } from "@workspace/db"; +import { appSettingsTable, auditLogsTable } from "@workspace/db"; import { requireAdmin } from "../middlewares/auth"; import { UpdateAppSettingsBody } from "@workspace/api-zod"; @@ -31,11 +31,35 @@ router.patch("/settings", requireAdmin, async (req, res): Promise => { return; } await ensureSettingsRow(); + const [previous] = await db + .select() + .from(appSettingsTable) + .where(eq(appSettingsTable.id, 1)); const [updated] = await db .update(appSettingsTable) .set(parsed.data) .where(eq(appSettingsTable.id, 1)) .returning(); + + if (previous) { + const changes: Record = {}; + for (const [key, value] of Object.entries(parsed.data)) { + const prevValue = (previous as Record)[key]; + if (prevValue !== value) { + changes[key] = { from: prevValue ?? null, to: value ?? null }; + } + } + if (Object.keys(changes).length > 0) { + await db.insert(auditLogsTable).values({ + actorUserId: req.session.userId ?? null, + action: "settings.update", + targetType: "settings", + targetId: 1, + metadata: { changes }, + }); + } + } + res.json(updated); }); diff --git a/artifacts/api-server/src/routes/users.ts b/artifacts/api-server/src/routes/users.ts index 531b8ea7..30c83da6 100644 --- a/artifacts/api-server/src/routes/users.ts +++ b/artifacts/api-server/src/routes/users.ts @@ -531,21 +531,20 @@ router.delete("/users/:id", requireAdmin, async (req, res): Promise => { await tx.delete(usersTable).where(eq(usersTable.id, userId)); }); - if (hasDeps && force) { - await db.insert(auditLogsTable).values({ - actorUserId: req.session.userId ?? null, - action: "user.force_delete", - targetType: "user", - targetId: userId, - metadata: { - username: existing.username, - noteCount, - orderCount, - conversationCount, - messageCount, - }, - }); - } + await db.insert(auditLogsTable).values({ + actorUserId: req.session.userId ?? null, + action: "user.delete", + targetType: "user", + targetId: userId, + metadata: { + username: existing.username, + email: existing.email, + force, + ...(hasDeps + ? { noteCount, orderCount, conversationCount, messageCount } + : {}), + }, + }); res.sendStatus(204); });