Task #147: Add structured permission-change audit (users, groups, apps)

Mirrors the existing role-permission audit pattern with a unified
`permission_audit` table capturing actor, target, prev/new id sets, and
timestamp written in the same transaction as the change.

Schema & API
- New `permission_audit` table (target_kind, target_id, change_kind,
  actor_user_id, previous_ids[], new_ids[], created_at) with index on
  (target_kind, target_id, created_at).
- Transactional audit writes in routes/users.ts (POST/DELETE roles,
  PATCH groupIds), routes/groups.ts (PATCH + add/remove members for
  users/roles/apps), routes/apps.ts (POST/DELETE permissions).
- Cross-entity mirroring: when group membership changes via a group
  endpoint, a user.groups row is also written for each affected user
  (and vice versa via PATCH /users), so each entity's history is
  exhaustive regardless of which editor was used.
- Admin-only GET /users/:id/audit, /groups/:id/audit, /apps/:id/audit
  with limit/offset/actorUserId/from/to filters and the same response
  shape as role audit.
- OpenAPI types + codegen regenerated.

UI
- Reusable PermissionAuditHistory component in admin.tsx wired into
  UserGroupsEditor, GroupDetailEditor (new "history" tab), and the
  editing-app dialog. App history correctly resolves permission ids
  (NOT roles) via useListPermissions.
- Bilingual i18n keys added under admin.{users,groups,apps}.history*
  in en.json + ar.json.

Tests
- New backend tests: user-permission-audit, group-permission-audit,
  app-permission-audit (14 cases — transactional capture, GET filters
  & pagination, admin-only, 404 on unknown id, plus 2 new mirror
  tests covering cross-entity audit visibility). All pass; 35
  adjacent role/groups/users/audit-coverage tests still pass.

Notes
- replit.md updated to list `permission_audit` table.
- Restored opengraph.jpg (an unrelated stray binary diff).
- Code-review comments addressed: cross-entity asymmetry fixed via
  mirroring; opengraph.jpg restored.
- Follow-ups proposed: timeline UI improvements, cascade audit on
  delete/bulk paths, e2e UI test for History sections.
This commit is contained in:
Riyadh
2026-04-30 11:58:18 +00:00
parent 27f52ed84b
commit e816f136bd
16 changed files with 3452 additions and 78 deletions
@@ -0,0 +1,235 @@
import { and, desc, eq, gte, lt, sql, type SQL } from "drizzle-orm";
import type { Request, Response } from "express";
import { db, permissionAuditTable, usersTable } from "@workspace/db";
import type {
PermissionAuditChangeKind,
PermissionAuditTargetKind,
} from "@workspace/db";
// Shared building blocks for the permission_audit table. Mirrors the role
// permission audit pattern in routes/roles.ts so the per-entity history
// endpoints stay consistent in shape, filters and pagination semantics.
export const PERMISSION_AUDIT_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
function parseUtcDate(s: string): Date | null {
if (!PERMISSION_AUDIT_DATE_RE.test(s)) return null;
const d = new Date(`${s}T00:00:00.000Z`);
if (Number.isNaN(d.getTime())) return null;
const [y, m, day] = s.split("-").map(Number);
if (
d.getUTCFullYear() !== y ||
d.getUTCMonth() + 1 !== m ||
d.getUTCDate() !== day
) {
return null;
}
return d;
}
export type PermissionAuditFilters = {
actorUserId: number | null;
fromDate: Date | null;
toDateExclusive: Date | null;
};
export function parsePermissionAuditFilters(
req: Request,
res: Response,
): PermissionAuditFilters | null {
const actorUserIdStr =
typeof req.query.actorUserId === "string"
? req.query.actorUserId.trim()
: "";
const fromStr = typeof req.query.from === "string" ? req.query.from.trim() : "";
const toStr = typeof req.query.to === "string" ? req.query.to.trim() : "";
let actorUserId: number | null = null;
if (actorUserIdStr) {
const parsed = Number(actorUserIdStr);
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) {
res
.status(400)
.json({ error: "Invalid 'actorUserId' (expected non-negative integer)" });
return null;
}
// 0 = explicit "no filter" so admins can clear the dropdown without
// dropping the param entirely from the URL.
actorUserId = parsed > 0 ? parsed : null;
}
let fromDate: Date | null = null;
let toDateExclusive: Date | null = null;
if (fromStr) {
fromDate = parseUtcDate(fromStr);
if (!fromDate) {
res.status(400).json({ error: "Invalid 'from' date (expected YYYY-MM-DD)" });
return null;
}
}
if (toStr) {
const toDate = parseUtcDate(toStr);
if (!toDate) {
res.status(400).json({ error: "Invalid 'to' date (expected YYYY-MM-DD)" });
return null;
}
toDateExclusive = new Date(toDate.getTime() + 24 * 60 * 60 * 1000);
}
if (fromDate && toDateExclusive && fromDate >= toDateExclusive) {
res.status(400).json({ error: "'from' must be on or before 'to'" });
return null;
}
return { actorUserId, fromDate, toDateExclusive };
}
function buildWhere(
targetKind: PermissionAuditTargetKind,
targetId: number,
filters: PermissionAuditFilters,
): SQL {
const conditions: SQL[] = [
eq(permissionAuditTable.targetKind, targetKind),
eq(permissionAuditTable.targetId, targetId),
];
if (filters.actorUserId != null)
conditions.push(eq(permissionAuditTable.actorUserId, filters.actorUserId));
if (filters.fromDate)
conditions.push(gte(permissionAuditTable.createdAt, filters.fromDate));
if (filters.toDateExclusive)
conditions.push(lt(permissionAuditTable.createdAt, filters.toDateExclusive));
return and(...conditions) as SQL;
}
type SerializedActor = {
id: number;
username: string;
displayNameAr: string | null;
displayNameEn: string | null;
avatarUrl: string | null;
} | null;
export type SerializedPermissionAuditEntry = {
id: number;
targetKind: PermissionAuditTargetKind;
targetId: number;
changeKind: PermissionAuditChangeKind;
previousIds: number[];
newIds: number[];
addedIds: number[];
removedIds: number[];
createdAt: Date;
actor: SerializedActor;
};
export async function listPermissionAudit(opts: {
targetKind: PermissionAuditTargetKind;
targetId: number;
limit: number;
offset: number;
filters: PermissionAuditFilters;
}): Promise<{
entries: SerializedPermissionAuditEntry[];
totalCount: number;
limit: number;
offset: number;
nextOffset: number | null;
}> {
const where = buildWhere(opts.targetKind, opts.targetId, opts.filters);
const rows = await db
.select({
id: permissionAuditTable.id,
targetKind: permissionAuditTable.targetKind,
targetId: permissionAuditTable.targetId,
changeKind: permissionAuditTable.changeKind,
previousIds: permissionAuditTable.previousIds,
newIds: permissionAuditTable.newIds,
createdAt: permissionAuditTable.createdAt,
actorUserId: permissionAuditTable.actorUserId,
actorUsername: usersTable.username,
actorDisplayNameAr: usersTable.displayNameAr,
actorDisplayNameEn: usersTable.displayNameEn,
actorAvatarUrl: usersTable.avatarUrl,
})
.from(permissionAuditTable)
.leftJoin(usersTable, eq(usersTable.id, permissionAuditTable.actorUserId))
.where(where)
.orderBy(desc(permissionAuditTable.createdAt), desc(permissionAuditTable.id))
.limit(opts.limit)
.offset(opts.offset);
const [{ count: totalCount } = { count: 0 }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(permissionAuditTable)
.where(where);
const entries: SerializedPermissionAuditEntry[] = rows.map((r) => {
const prev = (r.previousIds ?? []) as number[];
const next = (r.newIds ?? []) as number[];
const prevSet = new Set(prev);
const nextSet = new Set(next);
return {
id: r.id,
targetKind: r.targetKind as PermissionAuditTargetKind,
targetId: r.targetId,
changeKind: r.changeKind as PermissionAuditChangeKind,
previousIds: prev,
newIds: next,
addedIds: next.filter((p) => !prevSet.has(p)),
removedIds: prev.filter((p) => !nextSet.has(p)),
createdAt: r.createdAt,
actor:
r.actorUserId != null && r.actorUsername != null
? {
id: r.actorUserId,
username: r.actorUsername,
displayNameAr: r.actorDisplayNameAr,
displayNameEn: r.actorDisplayNameEn,
avatarUrl: r.actorAvatarUrl,
}
: null,
};
});
const nextOffset =
opts.offset + rows.length < totalCount
? opts.offset + rows.length
: null;
return {
entries,
totalCount,
limit: opts.limit,
offset: opts.offset,
nextOffset,
};
}
// Helper used by route handlers to serialize the prev/new id arrays into
// the shape the audit table expects. We always sort to keep entries
// canonical and easy to diff.
export function sortedIds(ids: Iterable<number>): number[] {
return Array.from(new Set(ids)).sort((a, b) => a - b);
}
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];
export async function recordPermissionAudit(
tx: Tx,
args: {
targetKind: PermissionAuditTargetKind;
targetId: number;
changeKind: PermissionAuditChangeKind;
actorUserId: number | null;
previousIds: number[];
newIds: number[];
},
) {
await tx.insert(permissionAuditTable).values({
targetKind: args.targetKind,
targetId: args.targetId,
changeKind: args.changeKind,
actorUserId: args.actorUserId ?? null,
previousIds: sortedIds(args.previousIds),
newIds: sortedIds(args.newIds),
});
}
+97 -14
View File
@@ -15,6 +15,11 @@ import {
permissionsTable,
} from "@workspace/db";
import { requireAuth, requireAdmin, getEffectiveRoleIds } from "../middlewares/auth";
import {
listPermissionAudit,
parsePermissionAuditFilters,
recordPermissionAudit,
} from "../lib/permission-audit";
import {
CreateAppBody,
UpdateAppBody,
@@ -389,11 +394,32 @@ router.post("/apps/:id/permissions", requireAdmin, async (req, res): Promise<voi
// endpoint idempotent so the UI can safely re-add an existing pair.
// .returning() lets us tell a real insert from a no-op so the audit log
// only records actual permission tightening, not retry clicks.
const inserted = await db
.insert(appPermissionsTable)
.values({ appId: app.id, permissionId })
.onConflictDoNothing()
.returning({ appId: appPermissionsTable.appId });
const inserted = await db.transaction(async (tx) => {
const previousIds = (
await tx
.select({ permissionId: appPermissionsTable.permissionId })
.from(appPermissionsTable)
.where(eq(appPermissionsTable.appId, app.id))
).map((r) => r.permissionId);
const ins = await tx
.insert(appPermissionsTable)
.values({ appId: app.id, permissionId })
.onConflictDoNothing()
.returning({ appId: appPermissionsTable.appId });
if (ins.length > 0) {
await recordPermissionAudit(tx, {
targetKind: "app",
targetId: app.id,
changeKind: "app.permissions",
actorUserId: req.session.userId ?? null,
previousIds,
newIds: previousIds.includes(permissionId)
? previousIds
: [...previousIds, permissionId],
});
}
return ins;
});
if (inserted.length > 0) {
await db.insert(auditLogsTable).values({
@@ -446,15 +472,34 @@ router.delete(
.from(permissionsTable)
.where(eq(permissionsTable.id, permissionId));
const deleted = await db
.delete(appPermissionsTable)
.where(
and(
eq(appPermissionsTable.appId, appId),
eq(appPermissionsTable.permissionId, permissionId),
),
)
.returning({ appId: appPermissionsTable.appId });
const deleted = await db.transaction(async (tx) => {
const previousIds = (
await tx
.select({ permissionId: appPermissionsTable.permissionId })
.from(appPermissionsTable)
.where(eq(appPermissionsTable.appId, appId))
).map((r) => r.permissionId);
const del = await tx
.delete(appPermissionsTable)
.where(
and(
eq(appPermissionsTable.appId, appId),
eq(appPermissionsTable.permissionId, permissionId),
),
)
.returning({ appId: appPermissionsTable.appId });
if (del.length > 0) {
await recordPermissionAudit(tx, {
targetKind: "app",
targetId: app.id,
changeKind: "app.permissions",
actorUserId: req.session.userId ?? null,
previousIds,
newIds: previousIds.filter((pid) => pid !== permissionId),
});
}
return del;
});
if (deleted.length > 0) {
await db.insert(auditLogsTable).values({
@@ -475,6 +520,44 @@ router.delete(
},
);
router.get(
"/apps/:id/audit",
requireAdmin,
async (req, res): Promise<void> => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) {
res.status(400).json({ error: "Invalid id" });
return;
}
const limitRaw = Number(req.query.limit);
const offsetRaw = Number(req.query.offset);
const limit =
Number.isFinite(limitRaw) && limitRaw > 0
? Math.min(Math.floor(limitRaw), 200)
: 10;
const offset =
Number.isFinite(offsetRaw) && offsetRaw >= 0 ? Math.floor(offsetRaw) : 0;
const [app] = await db
.select({ id: appsTable.id })
.from(appsTable)
.where(eq(appsTable.id, id));
if (!app) {
res.status(404).json({ error: "App not found" });
return;
}
const filters = parsePermissionAuditFilters(req, res);
if (!filters) return;
const result = await listPermissionAudit({
targetKind: "app",
targetId: id,
limit,
offset,
filters,
});
res.json(result);
},
);
router.post("/apps/:id/open", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
const params = GetAppParams.safeParse(req.params);
+275 -43
View File
@@ -13,6 +13,12 @@ import {
} from "@workspace/db";
import { requireAdmin } from "../middlewares/auth";
import { emitAppsChangedToUsers } from "../lib/realtime";
import {
listPermissionAudit,
parsePermissionAuditFilters,
recordPermissionAudit,
} from "../lib/permission-audit";
import type { PermissionAuditChangeKind } from "@workspace/db";
import {
CreateGroupBody,
UpdateGroupBody,
@@ -95,6 +101,44 @@ router.get("/groups", requireAdmin, async (req, res): Promise<void> => {
);
});
router.get(
"/groups/:id/audit",
requireAdmin,
async (req, res): Promise<void> => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) {
res.status(400).json({ error: "Invalid id" });
return;
}
const limitRaw = Number(req.query.limit);
const offsetRaw = Number(req.query.offset);
const limit =
Number.isFinite(limitRaw) && limitRaw > 0
? Math.min(Math.floor(limitRaw), 200)
: 10;
const offset =
Number.isFinite(offsetRaw) && offsetRaw >= 0 ? Math.floor(offsetRaw) : 0;
const [group] = await db
.select({ id: groupsTable.id })
.from(groupsTable)
.where(eq(groupsTable.id, id));
if (!group) {
res.status(404).json({ error: "Group not found" });
return;
}
const filters = parsePermissionAuditFilters(req, res);
if (!filters) return;
const result = await listPermissionAudit({
targetKind: "group",
targetId: id,
limit,
offset,
filters,
});
res.json(result);
},
);
router.get("/groups/:id", requireAdmin, async (req, res): Promise<void> => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) {
@@ -169,8 +213,15 @@ async function applyGroupAssignmentsTx(
appIds: number[] | undefined,
roleIds: number[] | undefined,
userIds: number[] | undefined,
actorUserId: number | null,
) {
if (appIds !== undefined) {
const previousIds = (
await tx
.select({ appId: groupAppsTable.appId })
.from(groupAppsTable)
.where(eq(groupAppsTable.groupId, groupId))
).map((r) => r.appId);
await tx.delete(groupAppsTable).where(eq(groupAppsTable.groupId, groupId));
if (appIds.length > 0) {
await tx
@@ -178,8 +229,22 @@ async function applyGroupAssignmentsTx(
.values(appIds.map((appId) => ({ groupId, appId })))
.onConflictDoNothing();
}
await recordPermissionAudit(tx, {
targetKind: "group",
targetId: groupId,
changeKind: "group.apps",
actorUserId,
previousIds,
newIds: appIds,
});
}
if (roleIds !== undefined) {
const previousIds = (
await tx
.select({ roleId: groupRolesTable.roleId })
.from(groupRolesTable)
.where(eq(groupRolesTable.groupId, groupId))
).map((r) => r.roleId);
await tx.delete(groupRolesTable).where(eq(groupRolesTable.groupId, groupId));
if (roleIds.length > 0) {
await tx
@@ -187,8 +252,22 @@ async function applyGroupAssignmentsTx(
.values(roleIds.map((roleId) => ({ groupId, roleId })))
.onConflictDoNothing();
}
await recordPermissionAudit(tx, {
targetKind: "group",
targetId: groupId,
changeKind: "group.roles",
actorUserId,
previousIds,
newIds: roleIds,
});
}
if (userIds !== undefined) {
const previousIds = (
await tx
.select({ userId: userGroupsTable.userId })
.from(userGroupsTable)
.where(eq(userGroupsTable.groupId, groupId))
).map((r) => r.userId);
await tx.delete(userGroupsTable).where(eq(userGroupsTable.groupId, groupId));
if (userIds.length > 0) {
await tx
@@ -196,6 +275,37 @@ async function applyGroupAssignmentsTx(
.values(userIds.map((userId) => ({ userId, groupId })))
.onConflictDoNothing();
}
await recordPermissionAudit(tx, {
targetKind: "group",
targetId: groupId,
changeKind: "group.users",
actorUserId,
previousIds,
newIds: userIds,
});
// Mirror onto each affected user so their personal history shows the
// group-membership change regardless of which editor was used.
const added = userIds.filter((uid) => !previousIds.includes(uid));
const removed = previousIds.filter((uid) => !userIds.includes(uid));
for (const uid of [...added, ...removed]) {
const newGroupIds = (
await tx
.select({ groupId: userGroupsTable.groupId })
.from(userGroupsTable)
.where(eq(userGroupsTable.userId, uid))
).map((r) => r.groupId);
const previousGroupIds = added.includes(uid)
? newGroupIds.filter((g) => g !== groupId)
: [...newGroupIds, groupId];
await recordPermissionAudit(tx, {
targetKind: "user",
targetId: uid,
changeKind: "user.groups",
actorUserId,
previousIds: previousGroupIds,
newIds: newGroupIds,
});
}
}
}
@@ -237,6 +347,7 @@ router.post("/groups", requireAdmin, async (req, res): Promise<void> => {
parsed.data.appIds,
parsed.data.roleIds,
parsed.data.userIds,
req.session.userId ?? null,
);
return g;
});
@@ -314,6 +425,7 @@ router.patch("/groups/:id", requireAdmin, async (req, res): Promise<void> => {
parsed.data.appIds,
parsed.data.roleIds,
parsed.data.userIds,
req.session.userId ?? null,
);
});
@@ -458,28 +570,89 @@ 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") {
inserted = await db
.insert(userGroupsTable)
.values({ groupId: id, userId: targetId })
.onConflictDoNothing()
.returning({ groupId: userGroupsTable.groupId });
await emitAppsChangedToUsers([targetId]);
} else if (kind === "apps") {
inserted = await db
.insert(groupAppsTable)
.values({ groupId: id, appId: targetId })
.onConflictDoNothing()
.returning({ groupId: groupAppsTable.groupId });
await emitAppsChangedToUsers(await getGroupMemberIds(id));
} else {
inserted = await db
.insert(groupRolesTable)
.values({ groupId: id, roleId: targetId })
.onConflictDoNothing()
.returning({ groupId: groupRolesTable.groupId });
await emitAppsChangedToUsers(await getGroupMemberIds(id));
const auditChangeKind: PermissionAuditChangeKind =
kind === "users"
? "group.users"
: kind === "apps"
? "group.apps"
: "group.roles";
const inserted = await db.transaction(async (tx) => {
let previousIds: number[] = [];
let ins: { groupId: number }[] = [];
if (kind === "users") {
previousIds = (
await tx
.select({ userId: userGroupsTable.userId })
.from(userGroupsTable)
.where(eq(userGroupsTable.groupId, id))
).map((r) => r.userId);
ins = await tx
.insert(userGroupsTable)
.values({ groupId: id, userId: targetId })
.onConflictDoNothing()
.returning({ groupId: userGroupsTable.groupId });
} else if (kind === "apps") {
previousIds = (
await tx
.select({ appId: groupAppsTable.appId })
.from(groupAppsTable)
.where(eq(groupAppsTable.groupId, id))
).map((r) => r.appId);
ins = await tx
.insert(groupAppsTable)
.values({ groupId: id, appId: targetId })
.onConflictDoNothing()
.returning({ groupId: groupAppsTable.groupId });
} else {
previousIds = (
await tx
.select({ roleId: groupRolesTable.roleId })
.from(groupRolesTable)
.where(eq(groupRolesTable.groupId, id))
).map((r) => r.roleId);
ins = await tx
.insert(groupRolesTable)
.values({ groupId: id, roleId: targetId })
.onConflictDoNothing()
.returning({ groupId: groupRolesTable.groupId });
}
if (ins.length > 0) {
await recordPermissionAudit(tx, {
targetKind: "group",
targetId: id,
changeKind: auditChangeKind,
actorUserId: req.session.userId ?? null,
previousIds,
newIds: previousIds.includes(targetId)
? previousIds
: [...previousIds, targetId],
});
if (kind === "users") {
// Mirror onto the affected user's history.
const newGroupIds = (
await tx
.select({ groupId: userGroupsTable.groupId })
.from(userGroupsTable)
.where(eq(userGroupsTable.userId, targetId))
).map((r) => r.groupId);
await recordPermissionAudit(tx, {
targetKind: "user",
targetId,
changeKind: "user.groups",
actorUserId: req.session.userId ?? null,
previousIds: newGroupIds.filter((g) => g !== id),
newIds: newGroupIds,
});
}
}
return ins;
});
if (inserted.length > 0) {
if (kind === "users") {
await emitAppsChangedToUsers([targetId]);
} else {
await emitAppsChangedToUsers(await getGroupMemberIds(id));
}
}
if (inserted.length > 0) {
const subject = kind === "users" ? "user" : kind === "apps" ? "app" : "role";
@@ -511,27 +684,86 @@ 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") {
removed = await db
.delete(userGroupsTable)
.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);
removed = await db
.delete(groupAppsTable)
.where(sql`${groupAppsTable.groupId} = ${id} and ${groupAppsTable.appId} = ${targetId}`)
.returning({ groupId: groupAppsTable.groupId });
await emitAppsChangedToUsers(memberIds);
} else {
const memberIds = await getGroupMemberIds(id);
removed = await db
.delete(groupRolesTable)
.where(sql`${groupRolesTable.groupId} = ${id} and ${groupRolesTable.roleId} = ${targetId}`)
.returning({ groupId: groupRolesTable.groupId });
await emitAppsChangedToUsers(memberIds);
const auditChangeKind: PermissionAuditChangeKind =
kind === "users"
? "group.users"
: kind === "apps"
? "group.apps"
: "group.roles";
const memberIdsBefore =
kind === "users" ? null : await getGroupMemberIds(id);
const removed = await db.transaction(async (tx) => {
let previousIds: number[] = [];
let del: { groupId: number }[] = [];
if (kind === "users") {
previousIds = (
await tx
.select({ userId: userGroupsTable.userId })
.from(userGroupsTable)
.where(eq(userGroupsTable.groupId, id))
).map((r) => r.userId);
del = await tx
.delete(userGroupsTable)
.where(sql`${userGroupsTable.groupId} = ${id} and ${userGroupsTable.userId} = ${targetId}`)
.returning({ groupId: userGroupsTable.groupId });
} else if (kind === "apps") {
previousIds = (
await tx
.select({ appId: groupAppsTable.appId })
.from(groupAppsTable)
.where(eq(groupAppsTable.groupId, id))
).map((r) => r.appId);
del = await tx
.delete(groupAppsTable)
.where(sql`${groupAppsTable.groupId} = ${id} and ${groupAppsTable.appId} = ${targetId}`)
.returning({ groupId: groupAppsTable.groupId });
} else {
previousIds = (
await tx
.select({ roleId: groupRolesTable.roleId })
.from(groupRolesTable)
.where(eq(groupRolesTable.groupId, id))
).map((r) => r.roleId);
del = await tx
.delete(groupRolesTable)
.where(sql`${groupRolesTable.groupId} = ${id} and ${groupRolesTable.roleId} = ${targetId}`)
.returning({ groupId: groupRolesTable.groupId });
}
if (del.length > 0) {
await recordPermissionAudit(tx, {
targetKind: "group",
targetId: id,
changeKind: auditChangeKind,
actorUserId: req.session.userId ?? null,
previousIds,
newIds: previousIds.filter((pid) => pid !== targetId),
});
if (kind === "users") {
// Mirror onto the affected user's history.
const newGroupIds = (
await tx
.select({ groupId: userGroupsTable.groupId })
.from(userGroupsTable)
.where(eq(userGroupsTable.userId, targetId))
).map((r) => r.groupId);
await recordPermissionAudit(tx, {
targetKind: "user",
targetId,
changeKind: "user.groups",
actorUserId: req.session.userId ?? null,
previousIds: [...newGroupIds, id],
newIds: newGroupIds,
});
}
}
return del;
});
if (removed.length > 0) {
if (kind === "users") {
await emitAppsChangedToUsers([targetId]);
} else {
await emitAppsChangedToUsers(memberIdsBefore!);
}
}
if (removed.length > 0) {
const subject = kind === "users" ? "user" : kind === "apps" ? "app" : "role";
+136 -14
View File
@@ -15,6 +15,11 @@ import {
} from "@workspace/db";
import { requireAuth, requireAdmin, getUserRoles } from "../middlewares/auth";
import { emitAppsChangedToUsers } from "../lib/realtime";
import {
listPermissionAudit,
parsePermissionAuditFilters,
recordPermissionAudit,
} from "../lib/permission-audit";
import {
RegisterBody,
CreateUserBody,
@@ -326,6 +331,44 @@ router.get("/users", requireAdmin, async (req, res): Promise<void> => {
);
});
router.get(
"/users/:id/audit",
requireAdmin,
async (req, res): Promise<void> => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) {
res.status(400).json({ error: "Invalid id" });
return;
}
const limitRaw = Number(req.query.limit);
const offsetRaw = Number(req.query.offset);
const limit =
Number.isFinite(limitRaw) && limitRaw > 0
? Math.min(Math.floor(limitRaw), 200)
: 10;
const offset =
Number.isFinite(offsetRaw) && offsetRaw >= 0 ? Math.floor(offsetRaw) : 0;
const [user] = await db
.select({ id: usersTable.id })
.from(usersTable)
.where(eq(usersTable.id, id));
if (!user) {
res.status(404).json({ error: "User not found" });
return;
}
const filters = parsePermissionAuditFilters(req, res);
if (!filters) return;
const result = await listPermissionAudit({
targetKind: "user",
targetId: id,
limit,
offset,
filters,
});
res.json(result);
},
);
router.get("/users/:id", requireAdmin, async (req, res): Promise<void> => {
const params = GetUserParams.safeParse(req.params);
if (!params.success) {
@@ -399,7 +442,16 @@ router.patch("/users/:id", requireAdmin, async (req, res): Promise<void> => {
}
if (parsed.data.groupIds !== undefined) {
// Same-transaction audit: read previous group ids, apply the change, and
// write the permission_audit row atomically so the history can never
// disagree with reality.
await db.transaction(async (tx) => {
const previousIds = (
await tx
.select({ groupId: userGroupsTable.groupId })
.from(userGroupsTable)
.where(eq(userGroupsTable.userId, userId))
).map((r) => r.groupId);
await tx.delete(userGroupsTable).where(eq(userGroupsTable.userId, userId));
if (parsed.data.groupIds && parsed.data.groupIds.length > 0) {
await tx
@@ -407,6 +459,38 @@ router.patch("/users/:id", requireAdmin, async (req, res): Promise<void> => {
.values(parsed.data.groupIds.map((groupId) => ({ userId, groupId })))
.onConflictDoNothing();
}
await recordPermissionAudit(tx, {
targetKind: "user",
targetId: userId,
changeKind: "user.groups",
actorUserId: req.session.userId ?? null,
previousIds,
newIds: parsed.data.groupIds ?? [],
});
// Mirror onto each affected group so its history shows the
// membership change regardless of which editor was used.
const newIds = parsed.data.groupIds ?? [];
const added = newIds.filter((gid) => !previousIds.includes(gid));
const removed = previousIds.filter((gid) => !newIds.includes(gid));
for (const gid of [...added, ...removed]) {
const newUserIds = (
await tx
.select({ userId: userGroupsTable.userId })
.from(userGroupsTable)
.where(eq(userGroupsTable.groupId, gid))
).map((r) => r.userId);
const previousUserIds = added.includes(gid)
? newUserIds.filter((u) => u !== userId)
: [...newUserIds, userId];
await recordPermissionAudit(tx, {
targetKind: "group",
targetId: gid,
changeKind: "group.users",
actorUserId: req.session.userId ?? null,
previousIds: previousUserIds,
newIds: newUserIds,
});
}
});
await emitAppsChangedToUsers([userId]);
}
@@ -446,11 +530,30 @@ router.post("/users/:id/roles", requireAdmin, async (req, res): Promise<void> =>
return;
}
const inserted = await db
.insert(userRolesTable)
.values({ userId: user.id, roleId: role.id })
.onConflictDoNothing()
.returning();
const inserted = await db.transaction(async (tx) => {
const previousRoleIds = (
await tx
.select({ roleId: userRolesTable.roleId })
.from(userRolesTable)
.where(eq(userRolesTable.userId, user.id))
).map((r) => r.roleId);
const ins = await tx
.insert(userRolesTable)
.values({ userId: user.id, roleId: role.id })
.onConflictDoNothing()
.returning();
if (ins.length > 0) {
await recordPermissionAudit(tx, {
targetKind: "user",
targetId: user.id,
changeKind: "user.roles",
actorUserId: req.session.userId ?? null,
previousIds: previousRoleIds,
newIds: [...previousRoleIds, role.id],
});
}
return ins;
});
if (inserted.length > 0) {
await emitAppsChangedToUsers([user.id]);
}
@@ -490,15 +593,34 @@ router.delete(
.where(eq(rolesTable.name, roleName));
if (role) {
const deleted = await db
.delete(userRolesTable)
.where(
and(
eq(userRolesTable.userId, user.id),
eq(userRolesTable.roleId, role.id),
),
)
.returning();
const deleted = await db.transaction(async (tx) => {
const previousRoleIds = (
await tx
.select({ roleId: userRolesTable.roleId })
.from(userRolesTable)
.where(eq(userRolesTable.userId, user.id))
).map((r) => r.roleId);
const del = await tx
.delete(userRolesTable)
.where(
and(
eq(userRolesTable.userId, user.id),
eq(userRolesTable.roleId, role.id),
),
)
.returning();
if (del.length > 0) {
await recordPermissionAudit(tx, {
targetKind: "user",
targetId: user.id,
changeKind: "user.roles",
actorUserId: req.session.userId ?? null,
previousIds: previousRoleIds,
newIds: previousRoleIds.filter((rid) => rid !== role.id),
});
}
return del;
});
if (deleted.length > 0) {
await emitAppsChangedToUsers([user.id]);
}
@@ -0,0 +1,289 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL must be set to run these tests");
}
const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
const pool = new pg.Pool({ connectionString: DATABASE_URL });
let adminId;
let adminUsername;
let adminCookie;
let nonAdminCookie;
const createdUserIds = [];
const createdAppIds = [];
const createdPermissionIds = [];
async function loginAndGetCookie(username, password) {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200, `login expected 200, got ${res.status}`);
const setCookie = res.headers.get("set-cookie");
return setCookie
.split(",")
.map((c) => c.split(";")[0].trim())
.find((c) => c.startsWith("connect.sid="));
}
function uniqueName(prefix) {
return `${prefix}_${Date.now().toString(36)}${Math.random()
.toString(36)
.slice(2, 6)}`;
}
async function createApp() {
const slug = uniqueName("app_audit");
const r = await pool.query(
`INSERT INTO apps (slug, name_ar, name_en, icon_name, color, route)
VALUES ($1, 'تطبيق', 'App', 'Grid', '#6366f1', '/x') RETURNING id`,
[slug],
);
const id = r.rows[0].id;
createdAppIds.push(id);
return { id, slug };
}
async function createPermission() {
const name = uniqueName("app_audit_perm");
const r = await pool.query(
`INSERT INTO permissions (name, description_en) VALUES ($1::varchar, $1::text) RETURNING id`,
[name],
);
const id = r.rows[0].id;
createdPermissionIds.push(id);
return { id, name };
}
before(async () => {
adminUsername = uniqueName("app_audit_admin");
const a = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'App Audit Admin', 'en', true) RETURNING id`,
[adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
);
adminId = a.rows[0].id;
createdUserIds.push(adminId);
await pool.query(
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
[adminId],
);
await pool.query(
`INSERT INTO user_groups (user_id, group_id)
SELECT $1, id FROM groups WHERE name IN ('Everyone', 'Admins')
ON CONFLICT DO NOTHING`,
[adminId],
);
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
const naUsername = uniqueName("app_audit_nonadmin");
const na = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Non Admin', 'en', true) RETURNING id`,
[naUsername, `${naUsername}@example.com`, TEST_PASSWORD_HASH],
);
createdUserIds.push(na.rows[0].id);
nonAdminCookie = await loginAndGetCookie(naUsername, TEST_PASSWORD);
});
after(async () => {
if (createdAppIds.length) {
await pool.query(
`DELETE FROM permission_audit WHERE target_kind = 'app' AND target_id = ANY($1::int[])`,
[createdAppIds],
);
await pool.query(
`DELETE FROM app_permissions WHERE app_id = ANY($1::int[])`,
[createdAppIds],
);
await pool.query(`DELETE FROM apps WHERE id = ANY($1::int[])`, [
createdAppIds,
]);
}
if (createdPermissionIds.length) {
await pool.query(
`DELETE FROM permissions WHERE id = ANY($1::int[])`,
[createdPermissionIds],
);
}
for (const uid of createdUserIds) {
await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [uid]);
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [uid]);
await pool.query(`DELETE FROM users WHERE id = $1`, [uid]);
}
await pool.end();
});
test("POST /api/apps/:id/permissions writes an app.permissions audit row transactionally", async () => {
const app = await createApp();
const p1 = await createPermission();
const before = await pool.query(
`SELECT COUNT(*)::int AS c FROM permission_audit
WHERE target_kind = 'app' AND target_id = $1`,
[app.id],
);
assert.equal(before.rows[0].c, 0);
const res = await fetch(`${API_BASE}/api/apps/${app.id}/permissions`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ permissionId: p1.id }),
});
assert.equal(res.status, 201);
const rows = await pool.query(
`SELECT actor_user_id, change_kind, previous_ids, new_ids
FROM permission_audit
WHERE target_kind = 'app' AND target_id = $1`,
[app.id],
);
assert.equal(rows.rowCount, 1);
const row = rows.rows[0];
assert.equal(row.actor_user_id, adminId);
assert.equal(row.change_kind, "app.permissions");
assert.deepEqual(row.previous_ids, []);
assert.deepEqual(row.new_ids, [p1.id]);
// Idempotent re-add (same permissionId) → no new audit row.
const dup = await fetch(`${API_BASE}/api/apps/${app.id}/permissions`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ permissionId: p1.id }),
});
assert.equal(dup.status, 201);
const after = await pool.query(
`SELECT COUNT(*)::int AS c FROM permission_audit
WHERE target_kind = 'app' AND target_id = $1`,
[app.id],
);
assert.equal(after.rows[0].c, 1);
});
test("DELETE /api/apps/:id/permissions/:permissionId records previous/new ids", async () => {
const app = await createApp();
const p1 = await createPermission();
const p2 = await createPermission();
for (const p of [p1, p2]) {
const r = await fetch(`${API_BASE}/api/apps/${app.id}/permissions`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ permissionId: p.id }),
});
assert.equal(r.status, 201);
await new Promise((r) => setTimeout(r, 5));
}
const del = await fetch(
`${API_BASE}/api/apps/${app.id}/permissions/${p1.id}`,
{
method: "DELETE",
headers: { Cookie: adminCookie },
},
);
assert.equal(del.status, 204);
const rows = await pool.query(
`SELECT change_kind, previous_ids, new_ids
FROM permission_audit
WHERE target_kind = 'app' AND target_id = $1
ORDER BY id DESC LIMIT 1`,
[app.id],
);
assert.equal(rows.rowCount, 1);
const sortedIds = (a) => [...a].sort((x, y) => x - y);
assert.equal(rows.rows[0].change_kind, "app.permissions");
assert.deepEqual(sortedIds(rows.rows[0].previous_ids), sortedIds([p1.id, p2.id]));
assert.deepEqual(rows.rows[0].new_ids, [p2.id]);
});
test("GET /api/apps/:id/audit returns entries with diff fields, actor, pagination and date filters", async () => {
const app = await createApp();
const p1 = await createPermission();
const p2 = await createPermission();
const p3 = await createPermission();
for (const p of [p1, p2, p3]) {
const r = await fetch(`${API_BASE}/api/apps/${app.id}/permissions`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ permissionId: p.id }),
});
assert.equal(r.status, 201);
await new Promise((r) => setTimeout(r, 10));
}
const allRes = await fetch(`${API_BASE}/api/apps/${app.id}/audit`, {
headers: { Cookie: adminCookie },
});
assert.equal(allRes.status, 200);
const all = await allRes.json();
assert.equal(all.totalCount, 3);
assert.equal(all.entries.length, 3);
// Newest first: p3 added.
const latest = all.entries[0];
assert.equal(latest.changeKind, "app.permissions");
assert.deepEqual(latest.addedIds, [p3.id]);
assert.deepEqual(latest.removedIds, []);
assert.ok(latest.actor);
assert.equal(latest.actor.id, adminId);
assert.equal(latest.actor.username, adminUsername);
const pageRes = await fetch(
`${API_BASE}/api/apps/${app.id}/audit?limit=2&offset=0`,
{ headers: { Cookie: adminCookie } },
);
assert.equal(pageRes.status, 200);
const page = await pageRes.json();
assert.equal(page.entries.length, 2);
assert.equal(page.totalCount, 3);
assert.equal(page.nextOffset, 2);
// actorUserId filter — admin authored every row.
const filteredRes = await fetch(
`${API_BASE}/api/apps/${app.id}/audit?actorUserId=${adminId}`,
{ headers: { Cookie: adminCookie } },
);
const filtered = await filteredRes.json();
assert.equal(filtered.totalCount, 3);
const today = new Date().toISOString().slice(0, 10);
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
const past = await fetch(
`${API_BASE}/api/apps/${app.id}/audit?from=${yesterday}&to=${yesterday}`,
{ headers: { Cookie: adminCookie } },
);
const pastBody = await past.json();
assert.equal(pastBody.totalCount, 0);
const inRange = await fetch(
`${API_BASE}/api/apps/${app.id}/audit?from=${today}&to=${today}`,
{ headers: { Cookie: adminCookie } },
);
const inRangeBody = await inRange.json();
assert.equal(inRangeBody.totalCount, 3);
});
test("GET /api/apps/:id/audit is admin-only and 404s on unknown app", async () => {
const app = await createApp();
const res = await fetch(`${API_BASE}/api/apps/${app.id}/audit`, {
headers: { Cookie: nonAdminCookie },
});
assert.equal(res.status, 403);
const missing = await fetch(`${API_BASE}/api/apps/99999999/audit`, {
headers: { Cookie: adminCookie },
});
assert.equal(missing.status, 404);
});
@@ -0,0 +1,352 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL must be set to run these tests");
}
const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
const pool = new pg.Pool({ connectionString: DATABASE_URL });
let adminId;
let adminUsername;
let adminCookie;
let nonAdminCookie;
const createdUserIds = [];
const createdGroupIds = [];
const createdRoleIds = [];
const createdAppIds = [];
async function loginAndGetCookie(username, password) {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200, `login expected 200, got ${res.status}`);
const setCookie = res.headers.get("set-cookie");
return setCookie
.split(",")
.map((c) => c.split(";")[0].trim())
.find((c) => c.startsWith("connect.sid="));
}
function uniqueName(prefix) {
return `${prefix}_${Date.now().toString(36)}${Math.random()
.toString(36)
.slice(2, 6)}`;
}
async function createGroup() {
const res = await fetch(`${API_BASE}/api/groups`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ name: uniqueName("grp_audit") }),
});
assert.equal(res.status, 201);
const body = await res.json();
createdGroupIds.push(body.id);
return body;
}
async function createRole() {
const res = await fetch(`${API_BASE}/api/roles`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ name: uniqueName("grp_audit_role") }),
});
assert.equal(res.status, 201);
const body = await res.json();
createdRoleIds.push(body.id);
return body;
}
async function createUser() {
const username = uniqueName("grp_audit_user");
const r = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Member', 'en', true) RETURNING id`,
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
);
const id = r.rows[0].id;
createdUserIds.push(id);
return { id, username };
}
async function createApp() {
const slug = uniqueName("grp_audit_app");
const r = await pool.query(
`INSERT INTO apps (slug, name_ar, name_en, icon_name, color, route)
VALUES ($1, 'تطبيق', 'App', 'Grid', '#6366f1', '/x') RETURNING id`,
[slug],
);
const id = r.rows[0].id;
createdAppIds.push(id);
return { id, slug };
}
before(async () => {
adminUsername = uniqueName("grp_audit_admin");
const a = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Group Audit Admin', 'en', true) RETURNING id`,
[adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
);
adminId = a.rows[0].id;
createdUserIds.push(adminId);
await pool.query(
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
[adminId],
);
await pool.query(
`INSERT INTO user_groups (user_id, group_id)
SELECT $1, id FROM groups WHERE name IN ('Everyone', 'Admins')
ON CONFLICT DO NOTHING`,
[adminId],
);
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
const naUsername = uniqueName("grp_audit_nonadmin");
const na = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Non Admin', 'en', true) RETURNING id`,
[naUsername, `${naUsername}@example.com`, TEST_PASSWORD_HASH],
);
createdUserIds.push(na.rows[0].id);
nonAdminCookie = await loginAndGetCookie(naUsername, TEST_PASSWORD);
});
after(async () => {
if (createdGroupIds.length) {
await pool.query(
`DELETE FROM permission_audit WHERE target_kind = 'group' AND target_id = ANY($1::int[])`,
[createdGroupIds],
);
await pool.query(
`DELETE FROM user_groups WHERE group_id = ANY($1::int[])`,
[createdGroupIds],
);
await pool.query(
`DELETE FROM group_roles WHERE group_id = ANY($1::int[])`,
[createdGroupIds],
);
await pool.query(
`DELETE FROM group_apps WHERE group_id = ANY($1::int[])`,
[createdGroupIds],
);
await pool.query(`DELETE FROM groups WHERE id = ANY($1::int[])`, [
createdGroupIds,
]);
}
if (createdAppIds.length) {
await pool.query(`DELETE FROM apps WHERE id = ANY($1::int[])`, [
createdAppIds,
]);
}
if (createdRoleIds.length) {
await pool.query(`DELETE FROM roles WHERE id = ANY($1::int[])`, [
createdRoleIds,
]);
}
for (const uid of createdUserIds) {
await pool.query(
`DELETE FROM permission_audit WHERE target_kind = 'user' AND target_id = $1`,
[uid],
);
await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [uid]);
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [uid]);
await pool.query(`DELETE FROM users WHERE id = $1`, [uid]);
}
await pool.end();
});
test("POST /api/groups/:id/users/:targetId writes a group.users permission_audit row transactionally", async () => {
const grp = await createGroup();
const u = await createUser();
const before = await pool.query(
`SELECT COUNT(*)::int AS c FROM permission_audit
WHERE target_kind = 'group' AND target_id = $1`,
[grp.id],
);
assert.equal(before.rows[0].c, 0);
const res = await fetch(`${API_BASE}/api/groups/${grp.id}/users/${u.id}`, {
method: "POST",
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 204);
const rows = await pool.query(
`SELECT actor_user_id, change_kind, previous_ids, new_ids
FROM permission_audit
WHERE target_kind = 'group' AND target_id = $1`,
[grp.id],
);
assert.equal(rows.rowCount, 1);
const row = rows.rows[0];
assert.equal(row.actor_user_id, adminId);
assert.equal(row.change_kind, "group.users");
assert.deepEqual(row.previous_ids, []);
assert.deepEqual(row.new_ids, [u.id]);
});
test("Group user-membership changes mirror onto the affected user's history", async () => {
const grp = await createGroup();
const u = await createUser();
// Add user via group endpoint → expect group.users AND mirrored user.groups.
let res = await fetch(`${API_BASE}/api/groups/${grp.id}/users/${u.id}`, {
method: "POST",
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 204);
let userRows = await pool.query(
`SELECT change_kind, previous_ids, new_ids FROM permission_audit
WHERE target_kind = 'user' AND target_id = $1 ORDER BY id`,
[u.id],
);
assert.equal(userRows.rowCount, 1);
assert.equal(userRows.rows[0].change_kind, "user.groups");
assert.deepEqual(userRows.rows[0].previous_ids, []);
assert.deepEqual(userRows.rows[0].new_ids, [grp.id]);
// Remove via group endpoint → another mirrored user.groups row.
res = await fetch(`${API_BASE}/api/groups/${grp.id}/users/${u.id}`, {
method: "DELETE",
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 204);
userRows = await pool.query(
`SELECT change_kind, previous_ids, new_ids FROM permission_audit
WHERE target_kind = 'user' AND target_id = $1 ORDER BY id`,
[u.id],
);
assert.equal(userRows.rowCount, 2);
assert.equal(userRows.rows[1].change_kind, "user.groups");
assert.deepEqual(userRows.rows[1].previous_ids, [grp.id]);
assert.deepEqual(userRows.rows[1].new_ids, []);
});
test("DELETE /api/groups/:id/roles/:targetId records previous/new ids", async () => {
const grp = await createGroup();
const r1 = await createRole();
const r2 = await createRole();
// Add two roles → audit rows captured by POST.
for (const r of [r1, r2]) {
const res = await fetch(`${API_BASE}/api/groups/${grp.id}/roles/${r.id}`, {
method: "POST",
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 204);
await new Promise((r) => setTimeout(r, 5));
}
// Remove r1 → expect a delete audit row with prev=[r1,r2], new=[r2].
const del = await fetch(`${API_BASE}/api/groups/${grp.id}/roles/${r1.id}`, {
method: "DELETE",
headers: { Cookie: adminCookie },
});
assert.equal(del.status, 204);
const rows = await pool.query(
`SELECT change_kind, previous_ids, new_ids
FROM permission_audit
WHERE target_kind = 'group' AND target_id = $1
ORDER BY id DESC LIMIT 1`,
[grp.id],
);
assert.equal(rows.rowCount, 1);
const sortedIds = (a) => [...a].sort((x, y) => x - y);
assert.equal(rows.rows[0].change_kind, "group.roles");
assert.deepEqual(sortedIds(rows.rows[0].previous_ids), sortedIds([r1.id, r2.id]));
assert.deepEqual(rows.rows[0].new_ids, [r2.id]);
});
test("GET /api/groups/:id/audit returns mixed-kind history with pagination and date filters", async () => {
const grp = await createGroup();
const u = await createUser();
const role = await createRole();
const app = await createApp();
// 1: add user
let r = await fetch(`${API_BASE}/api/groups/${grp.id}/users/${u.id}`, {
method: "POST",
headers: { Cookie: adminCookie },
});
assert.equal(r.status, 204);
await new Promise((r) => setTimeout(r, 10));
// 2: add role
r = await fetch(`${API_BASE}/api/groups/${grp.id}/roles/${role.id}`, {
method: "POST",
headers: { Cookie: adminCookie },
});
assert.equal(r.status, 204);
await new Promise((r) => setTimeout(r, 10));
// 3: add app
r = await fetch(`${API_BASE}/api/groups/${grp.id}/apps/${app.id}`, {
method: "POST",
headers: { Cookie: adminCookie },
});
assert.equal(r.status, 204);
const allRes = await fetch(`${API_BASE}/api/groups/${grp.id}/audit`, {
headers: { Cookie: adminCookie },
});
assert.equal(allRes.status, 200);
const all = await allRes.json();
assert.equal(all.totalCount, 3);
assert.equal(all.entries.length, 3);
// Newest first → app, role, user.
assert.equal(all.entries[0].changeKind, "group.apps");
assert.deepEqual(all.entries[0].addedIds, [app.id]);
assert.equal(all.entries[1].changeKind, "group.roles");
assert.equal(all.entries[2].changeKind, "group.users");
assert.ok(all.entries[0].actor);
assert.equal(all.entries[0].actor.id, adminId);
const pageRes = await fetch(
`${API_BASE}/api/groups/${grp.id}/audit?limit=2&offset=0`,
{ headers: { Cookie: adminCookie } },
);
assert.equal(pageRes.status, 200);
const page = await pageRes.json();
assert.equal(page.entries.length, 2);
assert.equal(page.nextOffset, 2);
const today = new Date().toISOString().slice(0, 10);
const inRange = await fetch(
`${API_BASE}/api/groups/${grp.id}/audit?from=${today}&to=${today}`,
{ headers: { Cookie: adminCookie } },
);
const inRangeBody = await inRange.json();
assert.equal(inRangeBody.totalCount, 3);
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
const past = await fetch(
`${API_BASE}/api/groups/${grp.id}/audit?from=${yesterday}&to=${yesterday}`,
{ headers: { Cookie: adminCookie } },
);
const pastBody = await past.json();
assert.equal(pastBody.totalCount, 0);
});
test("GET /api/groups/:id/audit is admin-only and 404s on unknown group", async () => {
const grp = await createGroup();
const res = await fetch(`${API_BASE}/api/groups/${grp.id}/audit`, {
headers: { Cookie: nonAdminCookie },
});
assert.equal(res.status, 403);
const missing = await fetch(`${API_BASE}/api/groups/99999999/audit`, {
headers: { Cookie: adminCookie },
});
assert.equal(missing.status, 404);
});
@@ -0,0 +1,361 @@
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL must be set to run these tests");
}
const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
const pool = new pg.Pool({ connectionString: DATABASE_URL });
let adminId;
let adminUsername;
let adminCookie;
let nonAdminCookie;
let nonAdminId;
const createdUserIds = [];
const createdGroupIds = [];
const createdRoleIds = [];
async function loginAndGetCookie(username, password) {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200, `login expected 200, got ${res.status}`);
const setCookie = res.headers.get("set-cookie");
return setCookie
.split(",")
.map((c) => c.split(";")[0].trim())
.find((c) => c.startsWith("connect.sid="));
}
function uniqueName(prefix) {
return `${prefix}_${Date.now().toString(36)}${Math.random()
.toString(36)
.slice(2, 6)}`;
}
async function createSubjectUser() {
const username = uniqueName("user_audit_subj");
const r = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Subject', 'en', true) RETURNING id`,
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
);
const id = r.rows[0].id;
createdUserIds.push(id);
return { id, username };
}
async function createGroup() {
const name = uniqueName("user_audit_grp");
const res = await fetch(`${API_BASE}/api/groups`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ name }),
});
assert.equal(res.status, 201);
const body = await res.json();
createdGroupIds.push(body.id);
return body;
}
async function createRole() {
const name = uniqueName("user_audit_role");
const res = await fetch(`${API_BASE}/api/roles`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ name }),
});
assert.equal(res.status, 201);
const body = await res.json();
createdRoleIds.push(body.id);
return body;
}
before(async () => {
adminUsername = uniqueName("user_audit_admin");
const a = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'User Audit Admin', 'en', true) RETURNING id`,
[adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
);
adminId = a.rows[0].id;
createdUserIds.push(adminId);
await pool.query(
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
[adminId],
);
await pool.query(
`INSERT INTO user_groups (user_id, group_id)
SELECT $1, id FROM groups WHERE name IN ('Everyone', 'Admins')
ON CONFLICT DO NOTHING`,
[adminId],
);
adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
// A non-admin user used to assert /audit is admin-only.
const naUsername = uniqueName("user_audit_nonadmin");
const na = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Non Admin', 'en', true) RETURNING id`,
[naUsername, `${naUsername}@example.com`, TEST_PASSWORD_HASH],
);
nonAdminId = na.rows[0].id;
createdUserIds.push(nonAdminId);
nonAdminCookie = await loginAndGetCookie(naUsername, TEST_PASSWORD);
});
after(async () => {
if (createdUserIds.length) {
await pool.query(
`DELETE FROM permission_audit WHERE target_kind = 'user' AND target_id = ANY($1::int[])`,
[createdUserIds],
);
}
if (createdGroupIds.length) {
await pool.query(
`DELETE FROM permission_audit WHERE target_kind = 'group' AND target_id = ANY($1::int[])`,
[createdGroupIds],
);
await pool.query(
`DELETE FROM user_groups WHERE group_id = ANY($1::int[])`,
[createdGroupIds],
);
await pool.query(`DELETE FROM groups WHERE id = ANY($1::int[])`, [
createdGroupIds,
]);
}
if (createdRoleIds.length) {
await pool.query(
`DELETE FROM user_roles WHERE role_id = ANY($1::int[])`,
[createdRoleIds],
);
await pool.query(`DELETE FROM roles WHERE id = ANY($1::int[])`, [
createdRoleIds,
]);
}
for (const uid of createdUserIds) {
await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [uid]);
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [uid]);
await pool.query(`DELETE FROM users WHERE id = $1`, [uid]);
}
await pool.end();
});
test("POST /api/users/:id/roles writes a user.roles permission_audit row transactionally", async () => {
const subject = await createSubjectUser();
const role = await createRole();
const before = await pool.query(
`SELECT COUNT(*)::int AS c FROM permission_audit
WHERE target_kind = 'user' AND target_id = $1`,
[subject.id],
);
assert.equal(before.rows[0].c, 0);
const res = await fetch(`${API_BASE}/api/users/${subject.id}/roles`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ roleName: role.name }),
});
assert.equal(res.status, 200);
const rows = await pool.query(
`SELECT actor_user_id, change_kind, previous_ids, new_ids
FROM permission_audit
WHERE target_kind = 'user' AND target_id = $1`,
[subject.id],
);
assert.equal(rows.rowCount, 1);
const row = rows.rows[0];
assert.equal(row.actor_user_id, adminId);
assert.equal(row.change_kind, "user.roles");
assert.deepEqual(row.previous_ids, []);
assert.deepEqual(row.new_ids, [role.id]);
});
test("PATCH /api/users/:id with groupIds writes a user.groups permission_audit row", async () => {
const subject = await createSubjectUser();
const g1 = await createGroup();
const g2 = await createGroup();
// First write: empty → [g1, g2]
const r1 = await fetch(`${API_BASE}/api/users/${subject.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ groupIds: [g1.id, g2.id] }),
});
assert.equal(r1.status, 200);
await new Promise((r) => setTimeout(r, 25));
// Second write: [g1, g2] → [g2]
const r2 = await fetch(`${API_BASE}/api/users/${subject.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ groupIds: [g2.id] }),
});
assert.equal(r2.status, 200);
const rows = await pool.query(
`SELECT change_kind, previous_ids, new_ids
FROM permission_audit
WHERE target_kind = 'user' AND target_id = $1
ORDER BY id`,
[subject.id],
);
assert.equal(rows.rowCount, 2);
for (const row of rows.rows) {
assert.equal(row.change_kind, "user.groups");
}
const sortedIds = (a) => [...a].sort((x, y) => x - y);
assert.deepEqual(rows.rows[0].previous_ids, []);
assert.deepEqual(sortedIds(rows.rows[0].new_ids), sortedIds([g1.id, g2.id]));
assert.deepEqual(sortedIds(rows.rows[1].previous_ids), sortedIds([g1.id, g2.id]));
assert.deepEqual(rows.rows[1].new_ids, [g2.id]);
});
test("PATCH /api/users/:id with groupIds mirrors onto each affected group's history", async () => {
const subject = await createSubjectUser();
const g1 = await createGroup();
const g2 = await createGroup();
// Add user to [g1, g2] → expect mirrored group.users rows on g1 AND g2.
let res = await fetch(`${API_BASE}/api/users/${subject.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ groupIds: [g1.id, g2.id] }),
});
assert.equal(res.status, 200);
for (const g of [g1, g2]) {
const rows = await pool.query(
`SELECT change_kind, previous_ids, new_ids FROM permission_audit
WHERE target_kind = 'group' AND target_id = $1 ORDER BY id`,
[g.id],
);
assert.equal(rows.rowCount, 1);
assert.equal(rows.rows[0].change_kind, "group.users");
assert.deepEqual(rows.rows[0].previous_ids, []);
assert.deepEqual(rows.rows[0].new_ids, [subject.id]);
}
// Now drop g1 → expect a mirrored group.users row only on g1.
res = await fetch(`${API_BASE}/api/users/${subject.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ groupIds: [g2.id] }),
});
assert.equal(res.status, 200);
const g1Rows = await pool.query(
`SELECT change_kind, previous_ids, new_ids FROM permission_audit
WHERE target_kind = 'group' AND target_id = $1 ORDER BY id`,
[g1.id],
);
assert.equal(g1Rows.rowCount, 2);
assert.deepEqual(g1Rows.rows[1].previous_ids, [subject.id]);
assert.deepEqual(g1Rows.rows[1].new_ids, []);
const g2Rows = await pool.query(
`SELECT COUNT(*)::int AS c FROM permission_audit
WHERE target_kind = 'group' AND target_id = $1`,
[g2.id],
);
assert.equal(g2Rows.rows[0].c, 1);
});
test("GET /api/users/:id/audit returns entries newest first with diff fields, actor and pagination", async () => {
const subject = await createSubjectUser();
const g1 = await createGroup();
const g2 = await createGroup();
const g3 = await createGroup();
const role = await createRole();
const sets = [[g1.id], [g1.id, g2.id], [g3.id]];
for (const s of sets) {
const r = await fetch(`${API_BASE}/api/users/${subject.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ groupIds: s }),
});
assert.equal(r.status, 200);
await new Promise((r) => setTimeout(r, 10));
}
// One role mutation too — to exercise filtering by changeKind via the
// cross-kind history.
const ar = await fetch(`${API_BASE}/api/users/${subject.id}/roles`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: adminCookie },
body: JSON.stringify({ roleName: role.name }),
});
assert.equal(ar.status, 200);
const allRes = await fetch(`${API_BASE}/api/users/${subject.id}/audit`, {
headers: { Cookie: adminCookie },
});
assert.equal(allRes.status, 200);
const all = await allRes.json();
assert.equal(all.totalCount, 4);
assert.equal(all.entries.length, 4);
// Latest entry is the role addition.
const latest = all.entries[0];
assert.equal(latest.changeKind, "user.roles");
assert.deepEqual(latest.addedIds, [role.id]);
assert.deepEqual(latest.removedIds, []);
assert.ok(latest.actor);
assert.equal(latest.actor.id, adminId);
assert.equal(latest.actor.username, adminUsername);
// Pagination: limit=2 should yield nextOffset=2.
const pageRes = await fetch(
`${API_BASE}/api/users/${subject.id}/audit?limit=2`,
{ headers: { Cookie: adminCookie } },
);
assert.equal(pageRes.status, 200);
const page = await pageRes.json();
assert.equal(page.entries.length, 2);
assert.equal(page.totalCount, 4);
assert.equal(page.nextOffset, 2);
// Date range: today must include all 4.
const today = new Date().toISOString().slice(0, 10);
const rangeRes = await fetch(
`${API_BASE}/api/users/${subject.id}/audit?from=${today}&to=${today}`,
{ headers: { Cookie: adminCookie } },
);
assert.equal(rangeRes.status, 200);
const range = await rangeRes.json();
assert.equal(range.totalCount, 4);
// Inverted range → 400.
const tomorrow = new Date(Date.now() + 86400000).toISOString().slice(0, 10);
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
const badRes = await fetch(
`${API_BASE}/api/users/${subject.id}/audit?from=${tomorrow}&to=${yesterday}`,
{ headers: { Cookie: adminCookie } },
);
assert.equal(badRes.status, 400);
});
test("GET /api/users/:id/audit is admin-only and 404s on unknown user", async () => {
const subject = await createSubjectUser();
const res = await fetch(`${API_BASE}/api/users/${subject.id}/audit`, {
headers: { Cookie: nonAdminCookie },
});
assert.equal(res.status, 403);
const missing = await fetch(`${API_BASE}/api/users/99999999/audit`, {
headers: { Cookie: adminCookie },
});
assert.equal(missing.status, 404);
});