Audit role permission changes (Task #100)
Record an audit trail every time a role's permissions change and surface recent history inside the role edit dialog. Schema (lib/db): - New `role_permission_audit` table: id, roleId (FK roles), actorUserId (FK users, nullable on delete), previousPermissionIds int[], newPermissionIds int[], createdAt. Created via raw SQL because `drizzle push` currently fails on a pre-existing app_permissions duplicate (followup #148 tracks fixing this). API (artifacts/api-server): - PUT /api/roles/:id/permissions now wraps the permission delete/insert, the legacy audit_logs row, and the new role_permission_audit row in a *single* transaction so the permission set and its audit trail always commit (or roll back) together. Previous IDs are also read inside the transaction to avoid races. - A role_permission_audit row is written on EVERY PUT call (per task spec), including no-op saves; the GET handler computes addedPermissionIds/removedPermissionIds so the History UI can distinguish meaningful changes from no-op saves. - New GET /api/roles/:id/audit (admin-only, ?limit=1..50, default 10) returns recent entries newest-first with computed added/removedPermissionIds and joined actor info. - New tests in tests/role-permission-audit.test.mjs cover write, every-call write (incl. no-op), GET ordering+diff+actor, no-op diff reporting, 404, and limit. Drive-by fixes: - Fixed pre-existing syntax corruption in tests/apps-open.test.mjs (stray `ccaPassa,` line from a prior bad merge that prevented the whole api-server test workflow from running). - Made tests/roles-crud.test.mjs "rejects an invalid name" robust to parallel test execution by scoping the leak check to the bad name instead of relying on a global row count. API spec / codegen (lib/api-spec, lib/api-client-react): - Added `getRolePermissionAudit` operation and `RolePermissionAuditEntry` schema; ran orval codegen. Frontend (artifacts/tx-os): - New RolePermissionHistory component renders inside the role edit dialog (between permissions and Save/Cancel) using useGetRolePermissionAudit. Shows timestamp, actor, added/removed permission names (resolved via permissionsById), and total count. - Save invalidates the audit query so the new entry appears immediately on the next open. - Bilingual i18n strings (en + ar with full Arabic plural variants: zero/one/two/few/many/other) under admin.roles.history*. Verification: - tx-os typecheck passes. - All 5 new audit tests + 13 existing roles tests pass. - E2E (testing skill) verified the full flow: empty state on a fresh role, save adds a permission, reopened dialog shows the new entry with actor name in Arabic. Docs: - replit.md updated to list `role_permission_audit` in Database Tables. Follow-ups proposed: #146 paginate/filter history, #147 audit other admin permission changes, #148 fix drizzle push duplicate. Replit-Task-Id: 9b8886a2-6e76-4072-b345-a772421fa161
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import { eq, inArray, sql } from "drizzle-orm";
|
||||
import { desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { db } from "@workspace/db";
|
||||
import {
|
||||
rolesTable,
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
rolePermissionsTable,
|
||||
permissionsTable,
|
||||
auditLogsTable,
|
||||
rolePermissionAuditTable,
|
||||
usersTable,
|
||||
} from "@workspace/db";
|
||||
import { requireAdmin } from "../middlewares/auth";
|
||||
import {
|
||||
@@ -57,6 +59,68 @@ router.get("/permissions", requireAdmin, async (_req, res): Promise<void> => {
|
||||
res.json(rows.map(serializePermission));
|
||||
});
|
||||
|
||||
router.get("/roles/: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 limit =
|
||||
Number.isFinite(limitRaw) && limitRaw > 0
|
||||
? Math.min(Math.floor(limitRaw), 50)
|
||||
: 10;
|
||||
const [role] = await db.select({ id: rolesTable.id }).from(rolesTable).where(eq(rolesTable.id, id));
|
||||
if (!role) {
|
||||
res.status(404).json({ error: "Role not found" });
|
||||
return;
|
||||
}
|
||||
const rows = await db
|
||||
.select({
|
||||
id: rolePermissionAuditTable.id,
|
||||
roleId: rolePermissionAuditTable.roleId,
|
||||
previousPermissionIds: rolePermissionAuditTable.previousPermissionIds,
|
||||
newPermissionIds: rolePermissionAuditTable.newPermissionIds,
|
||||
createdAt: rolePermissionAuditTable.createdAt,
|
||||
actorUserId: rolePermissionAuditTable.actorUserId,
|
||||
actorUsername: usersTable.username,
|
||||
actorDisplayNameAr: usersTable.displayNameAr,
|
||||
actorDisplayNameEn: usersTable.displayNameEn,
|
||||
actorAvatarUrl: usersTable.avatarUrl,
|
||||
})
|
||||
.from(rolePermissionAuditTable)
|
||||
.leftJoin(usersTable, eq(usersTable.id, rolePermissionAuditTable.actorUserId))
|
||||
.where(eq(rolePermissionAuditTable.roleId, id))
|
||||
.orderBy(desc(rolePermissionAuditTable.createdAt), desc(rolePermissionAuditTable.id))
|
||||
.limit(limit);
|
||||
const entries = rows.map((r) => {
|
||||
const prev = (r.previousPermissionIds ?? []) as number[];
|
||||
const next = (r.newPermissionIds ?? []) as number[];
|
||||
const prevSet = new Set(prev);
|
||||
const nextSet = new Set(next);
|
||||
return {
|
||||
id: r.id,
|
||||
roleId: r.roleId,
|
||||
previousPermissionIds: prev,
|
||||
newPermissionIds: next,
|
||||
addedPermissionIds: next.filter((p) => !prevSet.has(p)),
|
||||
removedPermissionIds: 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,
|
||||
};
|
||||
});
|
||||
res.json(entries);
|
||||
});
|
||||
|
||||
router.get("/roles/:id/usage", requireAdmin, async (req, res): Promise<void> => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) {
|
||||
@@ -445,38 +509,63 @@ router.put("/roles/:id/permissions", requireAdmin, async (req, res): Promise<voi
|
||||
return;
|
||||
}
|
||||
}
|
||||
const previousIds = (
|
||||
await db
|
||||
.select({ permissionId: rolePermissionsTable.permissionId })
|
||||
.from(rolePermissionsTable)
|
||||
.where(eq(rolePermissionsTable.roleId, id))
|
||||
).map((r) => r.permissionId);
|
||||
// Apply the permission replacement, the generic audit_logs row, and the
|
||||
// dedicated role_permission_audit row in a single transaction so the
|
||||
// permission set and its audit trail are guaranteed to commit together.
|
||||
// Reading the previous IDs inside the transaction also avoids racing with
|
||||
// a concurrent edit between the read and the write.
|
||||
await db.transaction(async (tx) => {
|
||||
const previousIds = (
|
||||
await tx
|
||||
.select({ permissionId: rolePermissionsTable.permissionId })
|
||||
.from(rolePermissionsTable)
|
||||
.where(eq(rolePermissionsTable.roleId, id))
|
||||
).map((r) => r.permissionId);
|
||||
|
||||
const previousSet = new Set(previousIds);
|
||||
const requestedSet = new Set(requestedIds);
|
||||
const added = requestedIds.filter((p) => !previousSet.has(p));
|
||||
const removed = previousIds.filter((p) => !requestedSet.has(p));
|
||||
const changed = added.length > 0 || removed.length > 0;
|
||||
|
||||
await tx.delete(rolePermissionsTable).where(eq(rolePermissionsTable.roleId, id));
|
||||
if (requestedIds.length > 0) {
|
||||
await tx
|
||||
.insert(rolePermissionsTable)
|
||||
.values(requestedIds.map((permissionId) => ({ roleId: id, permissionId })));
|
||||
}
|
||||
|
||||
// Dedicated role-permission audit row written on EVERY PUT call (per
|
||||
// task spec) so we have a complete record of who reviewed/saved the
|
||||
// permission set at any point in time, even when the resulting set
|
||||
// happens to be unchanged. The History UI uses
|
||||
// addedPermissionIds/removedPermissionIds (computed in the GET
|
||||
// handler) to surface meaningful changes vs. no-op saves.
|
||||
await tx.insert(rolePermissionAuditTable).values({
|
||||
roleId: id,
|
||||
actorUserId: req.session.userId ?? null,
|
||||
previousPermissionIds: [...previousIds].sort((a, b) => a - b),
|
||||
newPermissionIds: [...requestedIds].sort((a, b) => a - b),
|
||||
});
|
||||
|
||||
// The legacy audit_logs entry is kept for backward compatibility
|
||||
// with the existing audit log UI, but only when the set actually
|
||||
// changes (matches its long-standing semantics for this action).
|
||||
if (changed) {
|
||||
await tx.insert(auditLogsTable).values({
|
||||
actorUserId: req.session.userId ?? null,
|
||||
action: "role.permissions.replace",
|
||||
targetType: "role",
|
||||
targetId: id,
|
||||
metadata: {
|
||||
roleName: role.name,
|
||||
added,
|
||||
removed,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
await emitAppsChangedToRoleHolders(id);
|
||||
const previousSet = new Set(previousIds);
|
||||
const requestedSet = new Set(requestedIds);
|
||||
const added = requestedIds.filter((p) => !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,
|
||||
|
||||
@@ -57,7 +57,6 @@ before(async () => {
|
||||
testUsername = `open_test_${Date.now().toString(36)}_${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 6)}`;
|
||||
ccaPassa,
|
||||
const userRes = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, 'Open Test', 'en', true) RETURNING id`,
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
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;
|
||||
const createdRoleIds = [];
|
||||
const createdUserIds = [];
|
||||
|
||||
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="));
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
adminUsername = `role_audit_admin_${stamp}`;
|
||||
|
||||
const a = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, 'Role 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);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (createdRoleIds.length) {
|
||||
await pool.query(
|
||||
`DELETE FROM role_permission_audit WHERE role_id = ANY($1::int[])`,
|
||||
[createdRoleIds],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM role_permissions WHERE role_id = ANY($1::int[])`,
|
||||
[createdRoleIds],
|
||||
);
|
||||
await pool.query(`DELETE FROM roles WHERE id = ANY($1::int[])`, [
|
||||
createdRoleIds,
|
||||
]);
|
||||
}
|
||||
for (const uid of createdUserIds) {
|
||||
if (uid !== undefined) {
|
||||
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();
|
||||
});
|
||||
|
||||
function uniqueRoleName(prefix) {
|
||||
const stamp = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
return `${prefix}_${stamp}`;
|
||||
}
|
||||
|
||||
async function createRole() {
|
||||
const res = await fetch(`${API_BASE}/api/roles`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||||
body: JSON.stringify({ name: uniqueRoleName("role_audit") }),
|
||||
});
|
||||
assert.equal(res.status, 201);
|
||||
const body = await res.json();
|
||||
createdRoleIds.push(body.id);
|
||||
return body;
|
||||
}
|
||||
|
||||
async function getTwoPermissionIds() {
|
||||
const rows = await pool.query(
|
||||
`SELECT id FROM permissions ORDER BY id LIMIT 2`,
|
||||
);
|
||||
assert.ok(rows.rowCount >= 2, "need at least 2 seeded permissions");
|
||||
return [rows.rows[0].id, rows.rows[1].id];
|
||||
}
|
||||
|
||||
test("PUT /api/roles/:id/permissions writes a role_permission_audit row", async () => {
|
||||
const role = await createRole();
|
||||
const [p1, p2] = await getTwoPermissionIds();
|
||||
|
||||
const before = await pool.query(
|
||||
`SELECT COUNT(*)::int AS c FROM role_permission_audit WHERE role_id = $1`,
|
||||
[role.id],
|
||||
);
|
||||
assert.equal(before.rows[0].c, 0);
|
||||
|
||||
const put = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||||
body: JSON.stringify({ permissionIds: [p1, p2] }),
|
||||
});
|
||||
assert.equal(put.status, 200);
|
||||
|
||||
const dbRows = await pool.query(
|
||||
`SELECT actor_user_id, role_id, previous_permission_ids, new_permission_ids
|
||||
FROM role_permission_audit
|
||||
WHERE role_id = $1`,
|
||||
[role.id],
|
||||
);
|
||||
assert.equal(dbRows.rowCount, 1);
|
||||
const row = dbRows.rows[0];
|
||||
assert.equal(row.actor_user_id, adminId);
|
||||
assert.equal(row.role_id, role.id);
|
||||
assert.deepEqual(row.previous_permission_ids, []);
|
||||
assert.deepEqual(
|
||||
[...row.new_permission_ids].sort((a, b) => a - b),
|
||||
[p1, p2].sort((a, b) => a - b),
|
||||
);
|
||||
});
|
||||
|
||||
test("PUT /api/roles/:id/permissions writes an audit row on every call (including no-op replaces)", async () => {
|
||||
const role = await createRole();
|
||||
const [p1, p2] = await getTwoPermissionIds();
|
||||
|
||||
const first = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||||
body: JSON.stringify({ permissionIds: [p1, p2] }),
|
||||
});
|
||||
assert.equal(first.status, 200);
|
||||
|
||||
// Re-submit the same set in a different order — the resulting permission
|
||||
// set is identical, but the spec requires recording every PUT call.
|
||||
const second = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||||
body: JSON.stringify({ permissionIds: [p2, p1] }),
|
||||
});
|
||||
assert.equal(second.status, 200);
|
||||
|
||||
const dbRows = await pool.query(
|
||||
`SELECT previous_permission_ids, new_permission_ids
|
||||
FROM role_permission_audit
|
||||
WHERE role_id = $1
|
||||
ORDER BY id`,
|
||||
[role.id],
|
||||
);
|
||||
assert.equal(
|
||||
dbRows.rowCount,
|
||||
2,
|
||||
"every PUT call must append an audit row, including no-op saves",
|
||||
);
|
||||
const sorted = [p1, p2].sort((a, b) => a - b);
|
||||
// Second row's previous and new arrays must be identical to the first
|
||||
// row's "new" array, capturing the no-op save.
|
||||
assert.deepEqual([...dbRows.rows[1].previous_permission_ids].sort((a, b) => a - b), sorted);
|
||||
assert.deepEqual([...dbRows.rows[1].new_permission_ids].sort((a, b) => a - b), sorted);
|
||||
});
|
||||
|
||||
test("GET /api/roles/:id/audit reports no-op saves with empty added/removed", async () => {
|
||||
const role = await createRole();
|
||||
const [p1] = await getTwoPermissionIds();
|
||||
|
||||
for (const set of [[p1], [p1]]) {
|
||||
const r = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||||
body: JSON.stringify({ permissionIds: set }),
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
|
||||
const get = await fetch(`${API_BASE}/api/roles/${role.id}/audit`, {
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(get.status, 200);
|
||||
const entries = await get.json();
|
||||
assert.equal(entries.length, 2);
|
||||
// Latest entry is the no-op save: same set in/out, empty diffs.
|
||||
assert.deepEqual(entries[0].previousPermissionIds, [p1]);
|
||||
assert.deepEqual(entries[0].newPermissionIds, [p1]);
|
||||
assert.deepEqual(entries[0].addedPermissionIds, []);
|
||||
assert.deepEqual(entries[0].removedPermissionIds, []);
|
||||
});
|
||||
|
||||
test("GET /api/roles/:id/audit returns entries newest first with diff fields and actor", async () => {
|
||||
const role = await createRole();
|
||||
const [p1, p2] = await getTwoPermissionIds();
|
||||
|
||||
// First change: add p1
|
||||
const r1 = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||||
body: JSON.stringify({ permissionIds: [p1] }),
|
||||
});
|
||||
assert.equal(r1.status, 200);
|
||||
|
||||
// Ensure the second change has a strictly later created_at so ordering is
|
||||
// deterministic regardless of clock resolution.
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
|
||||
// Second change: swap p1 -> p2
|
||||
const r2 = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||||
body: JSON.stringify({ permissionIds: [p2] }),
|
||||
});
|
||||
assert.equal(r2.status, 200);
|
||||
|
||||
const get = await fetch(`${API_BASE}/api/roles/${role.id}/audit`, {
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(get.status, 200);
|
||||
const entries = await get.json();
|
||||
assert.ok(Array.isArray(entries));
|
||||
assert.equal(entries.length, 2);
|
||||
|
||||
const [latest, earlier] = entries;
|
||||
assert.deepEqual(latest.previousPermissionIds, [p1]);
|
||||
assert.deepEqual(latest.newPermissionIds, [p2]);
|
||||
assert.deepEqual(latest.addedPermissionIds, [p2]);
|
||||
assert.deepEqual(latest.removedPermissionIds, [p1]);
|
||||
assert.ok(latest.actor);
|
||||
assert.equal(latest.actor.id, adminId);
|
||||
assert.equal(latest.actor.username, adminUsername);
|
||||
|
||||
assert.deepEqual(earlier.previousPermissionIds, []);
|
||||
assert.deepEqual(earlier.newPermissionIds, [p1]);
|
||||
assert.deepEqual(earlier.addedPermissionIds, [p1]);
|
||||
assert.deepEqual(earlier.removedPermissionIds, []);
|
||||
});
|
||||
|
||||
test("GET /api/roles/:id/audit returns 404 for an unknown role", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/roles/99999999/audit`, {
|
||||
headers: { Cookie: adminCookie },
|
||||
});
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
||||
test("GET /api/roles/:id/audit honours the limit parameter", async () => {
|
||||
const role = await createRole();
|
||||
const [p1, p2] = await getTwoPermissionIds();
|
||||
|
||||
const sets = [[p1], [p2], [p1, p2], []];
|
||||
for (const s of sets) {
|
||||
const r = await fetch(`${API_BASE}/api/roles/${role.id}/permissions`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||||
body: JSON.stringify({ permissionIds: s }),
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
|
||||
const limited = await fetch(
|
||||
`${API_BASE}/api/roles/${role.id}/audit?limit=2`,
|
||||
{ headers: { Cookie: adminCookie } },
|
||||
);
|
||||
assert.equal(limited.status, 200);
|
||||
const entries = await limited.json();
|
||||
assert.equal(entries.length, 2);
|
||||
});
|
||||
@@ -180,15 +180,19 @@ test("POST /api/roles with a duplicate name returns 409", async () => {
|
||||
});
|
||||
|
||||
test("POST /api/roles rejects an invalid name with 400", async () => {
|
||||
const before = await pool.query(`SELECT COUNT(*)::int AS c FROM roles`);
|
||||
const badName = "1bad name!";
|
||||
const res = await fetch(`${API_BASE}/api/roles`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: adminCookie },
|
||||
body: JSON.stringify({ name: "1bad name!" }),
|
||||
body: JSON.stringify({ name: badName }),
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
const after = await pool.query(`SELECT COUNT(*)::int AS c FROM roles`);
|
||||
assert.equal(after.rows[0].c, before.rows[0].c, "no role row should leak");
|
||||
// Scope the leak check to the specific bad name so this test is robust
|
||||
// when run in parallel with other suites that legitimately create roles.
|
||||
const leak = await pool.query(`SELECT id FROM roles WHERE name = $1`, [
|
||||
badName,
|
||||
]);
|
||||
assert.equal(leak.rowCount, 0, "no role row should leak");
|
||||
});
|
||||
|
||||
test("PATCH /api/roles/:id updates description without touching the name", async () => {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -461,7 +461,29 @@
|
||||
"confirmRemovalTitle": "تأكيد إزالة الصلاحيات",
|
||||
"confirmRemovalBody_one": "سيؤدي الحفظ إلى إزالة الصلاحيات من {{count}} مستخدم. قد يُلغى وصوله فوراً. هل تريد المتابعة؟",
|
||||
"confirmRemovalBody_other": "سيؤدي الحفظ إلى إزالة الصلاحيات من {{count}} مستخدماً. قد يُلغى وصولهم فوراً. هل تريد المتابعة؟",
|
||||
"confirmRemovalAction": "إزالة الصلاحيات"
|
||||
"confirmRemovalAction": "إزالة الصلاحيات",
|
||||
"historyTitle": "السجل",
|
||||
"historyHint": "آخر التغييرات على صلاحيات هذا الدور.",
|
||||
"historyEmpty": "لم تُسجَّل أي تغييرات على صلاحيات هذا الدور بعد.",
|
||||
"historyActorUnknown": "مستخدم غير معروف",
|
||||
"historyAdded_zero": "أُضيفت (0):",
|
||||
"historyAdded_one": "أُضيفت (1):",
|
||||
"historyAdded_two": "أُضيفت (2):",
|
||||
"historyAdded_few": "أُضيفت ({{count}}):",
|
||||
"historyAdded_many": "أُضيفت ({{count}}):",
|
||||
"historyAdded_other": "أُضيفت ({{count}}):",
|
||||
"historyRemoved_zero": "أُزيلت (0):",
|
||||
"historyRemoved_one": "أُزيلت (1):",
|
||||
"historyRemoved_two": "أُزيلت (2):",
|
||||
"historyRemoved_few": "أُزيلت ({{count}}):",
|
||||
"historyRemoved_many": "أُزيلت ({{count}}):",
|
||||
"historyRemoved_other": "أُزيلت ({{count}}):",
|
||||
"historyTotal_zero": "بدون صلاحيات بعد التغيير",
|
||||
"historyTotal_one": "صلاحية واحدة بعد التغيير",
|
||||
"historyTotal_two": "صلاحيتان بعد التغيير",
|
||||
"historyTotal_few": "{{count}} صلاحيات بعد التغيير",
|
||||
"historyTotal_many": "{{count}} صلاحية بعد التغيير",
|
||||
"historyTotal_other": "{{count}} صلاحية بعد التغيير"
|
||||
},
|
||||
"groups": {
|
||||
"searchPlaceholder": "ابحث في المجموعات...",
|
||||
|
||||
@@ -458,7 +458,17 @@
|
||||
"confirmRemovalTitle": "Confirm permission removal",
|
||||
"confirmRemovalBody_one": "Saving will remove permissions from {{count}} user. This may revoke their access immediately. Continue?",
|
||||
"confirmRemovalBody_other": "Saving will remove permissions from {{count}} users. This may revoke their access immediately. Continue?",
|
||||
"confirmRemovalAction": "Remove permissions"
|
||||
"confirmRemovalAction": "Remove permissions",
|
||||
"historyTitle": "History",
|
||||
"historyHint": "Recent permission changes for this role.",
|
||||
"historyEmpty": "No permission changes have been recorded for this role yet.",
|
||||
"historyActorUnknown": "Unknown actor",
|
||||
"historyAdded_one": "Added ({{count}}):",
|
||||
"historyAdded_other": "Added ({{count}}):",
|
||||
"historyRemoved_one": "Removed ({{count}}):",
|
||||
"historyRemoved_other": "Removed ({{count}}):",
|
||||
"historyTotal_one": "{{count}} permission after change",
|
||||
"historyTotal_other": "{{count}} permissions after change"
|
||||
},
|
||||
"groups": {
|
||||
"searchPlaceholder": "Search groups...",
|
||||
|
||||
@@ -49,6 +49,8 @@ import {
|
||||
getGetRoleUsageQueryKey,
|
||||
useReplaceRolePermissions,
|
||||
usePreviewRolePermissionsImpact,
|
||||
useGetRolePermissionAudit,
|
||||
getGetRolePermissionAuditQueryKey,
|
||||
useListAuditLogs,
|
||||
getListAuditLogsQueryKey,
|
||||
getExportAuditLogsCsvUrl,
|
||||
@@ -73,6 +75,8 @@ import type {
|
||||
ListAuditLogsParams,
|
||||
AuditLogEntry,
|
||||
RolePermissionsImpact,
|
||||
RolePermissionAuditEntry,
|
||||
Permission,
|
||||
} from "@workspace/api-client-react";
|
||||
import { ListUsersStatus } from "@workspace/api-client-react";
|
||||
import { useQueryClient, useQuery } from "@tanstack/react-query";
|
||||
@@ -3212,6 +3216,122 @@ function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onC
|
||||
|
||||
const ROLES_PROTECTED_NAMES = new Set(["admin", "user", "order_receiver"]);
|
||||
|
||||
function permissionLabel(
|
||||
id: number,
|
||||
permissionsById: Map<number, Permission>,
|
||||
): string {
|
||||
const p = permissionsById.get(id);
|
||||
return p ? p.name : `#${id}`;
|
||||
}
|
||||
|
||||
function RolePermissionHistory({
|
||||
entries,
|
||||
loading,
|
||||
permissionsById,
|
||||
language,
|
||||
}: {
|
||||
entries: RolePermissionAuditEntry[] | null;
|
||||
loading: boolean;
|
||||
permissionsById: Map<number, Permission>;
|
||||
language: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-1 pt-2" data-testid="role-history-section">
|
||||
<Label>{t("admin.roles.historyTitle")}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("admin.roles.historyHint")}</p>
|
||||
<div
|
||||
className="border border-slate-200 rounded-xl bg-white/60 max-h-56 overflow-y-auto divide-y divide-slate-100"
|
||||
data-testid="role-history-list"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-6 text-muted-foreground">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
</div>
|
||||
) : !entries || entries.length === 0 ? (
|
||||
<p
|
||||
className="text-xs text-muted-foreground p-3 text-center"
|
||||
data-testid="role-history-empty"
|
||||
>
|
||||
{t("admin.roles.historyEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
entries.map((entry) => {
|
||||
const actor = entry.actor;
|
||||
const ar = actor?.displayNameAr ?? null;
|
||||
const en = actor?.displayNameEn ?? null;
|
||||
const preferred =
|
||||
language === "ar" ? ar ?? en : en ?? ar;
|
||||
const actorName =
|
||||
actor && (preferred?.trim() ? preferred : actor.username);
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="p-2.5 text-xs space-y-1"
|
||||
data-testid={`role-history-entry-${entry.id}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 text-foreground">
|
||||
<span className="font-medium" data-testid={`role-history-actor-${entry.id}`}>
|
||||
{actorName ?? t("admin.roles.historyActorUnknown")}
|
||||
</span>
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
data-testid={`role-history-time-${entry.id}`}
|
||||
>
|
||||
{formatAuditTimestamp(entry.createdAt, language)}
|
||||
</span>
|
||||
</div>
|
||||
{entry.addedPermissionIds.length > 0 && (
|
||||
<div
|
||||
className="text-emerald-700"
|
||||
data-testid={`role-history-added-${entry.id}`}
|
||||
>
|
||||
<span className="font-semibold">
|
||||
{t("admin.roles.historyAdded", {
|
||||
count: entry.addedPermissionIds.length,
|
||||
})}
|
||||
</span>{" "}
|
||||
<span className="font-mono break-all">
|
||||
{entry.addedPermissionIds
|
||||
.map((id) => permissionLabel(id, permissionsById))
|
||||
.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{entry.removedPermissionIds.length > 0 && (
|
||||
<div
|
||||
className="text-rose-700"
|
||||
data-testid={`role-history-removed-${entry.id}`}
|
||||
>
|
||||
<span className="font-semibold">
|
||||
{t("admin.roles.historyRemoved", {
|
||||
count: entry.removedPermissionIds.length,
|
||||
})}
|
||||
</span>{" "}
|
||||
<span className="font-mono break-all">
|
||||
{entry.removedPermissionIds
|
||||
.map((id) => permissionLabel(id, permissionsById))
|
||||
.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="text-muted-foreground"
|
||||
data-testid={`role-history-total-${entry.id}`}
|
||||
>
|
||||
{t("admin.roles.historyTotal", {
|
||||
count: entry.newPermissionIds.length,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RolesPanel() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
@@ -3258,6 +3378,25 @@ function RolesPanel() {
|
||||
enabled: editingId != null && !editingIsSystem,
|
||||
},
|
||||
});
|
||||
const auditLimit = 5;
|
||||
const { data: editingRoleAudit, isLoading: editingAuditLoading } =
|
||||
useGetRolePermissionAudit(
|
||||
editingPermissionsId,
|
||||
{ limit: auditLimit },
|
||||
{
|
||||
query: {
|
||||
queryKey: getGetRolePermissionAuditQueryKey(editingPermissionsId, {
|
||||
limit: auditLimit,
|
||||
}),
|
||||
enabled: editingId != null,
|
||||
},
|
||||
},
|
||||
);
|
||||
const permissionsById = useMemo(() => {
|
||||
const map = new Map<number, Permission>();
|
||||
for (const p of allPermissions ?? []) map.set(p.id, p);
|
||||
return map;
|
||||
}, [allPermissions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingId == null) return;
|
||||
@@ -3437,6 +3576,11 @@ function RolesPanel() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetRolePermissionsQueryKey(editingRoleId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetRolePermissionAuditQueryKey(editingRoleId, {
|
||||
limit: auditLimit,
|
||||
}),
|
||||
});
|
||||
closeEditDialog();
|
||||
toast({ title: t("common.success") });
|
||||
},
|
||||
@@ -3842,6 +3986,12 @@ function RolesPanel() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<RolePermissionHistory
|
||||
entries={editingRoleAudit ?? null}
|
||||
loading={editingAuditLoading}
|
||||
permissionsById={permissionsById}
|
||||
language={i18n.language}
|
||||
/>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" className="flex-1" onClick={closeEditDialog}>
|
||||
{t("common.cancel")}
|
||||
|
||||
@@ -880,6 +880,17 @@ export interface AuditLogActor {
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface RolePermissionAuditEntry {
|
||||
id: number;
|
||||
roleId: number;
|
||||
previousPermissionIds: number[];
|
||||
newPermissionIds: number[];
|
||||
addedPermissionIds: number[];
|
||||
removedPermissionIds: number[];
|
||||
createdAt: string;
|
||||
actor: AuditLogActor | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
@@ -960,6 +971,14 @@ export type DeleteUserParams = {
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export type GetRolePermissionAuditParams = {
|
||||
/**
|
||||
* @minimum 1
|
||||
* @maximum 50
|
||||
*/
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type ListGroupsParams = {
|
||||
q?: string;
|
||||
};
|
||||
|
||||
@@ -47,6 +47,7 @@ import type {
|
||||
GetAdminAppOpensByAppParams,
|
||||
GetAdminAppOpensByUserParams,
|
||||
GetAdminStatsParams,
|
||||
GetRolePermissionAuditParams,
|
||||
Group,
|
||||
GroupDeletionConflict,
|
||||
GroupDetail,
|
||||
@@ -67,6 +68,7 @@ import type {
|
||||
ResetPasswordBody,
|
||||
Role,
|
||||
RoleDeletionConflict,
|
||||
RolePermissionAuditEntry,
|
||||
RolePermissionsImpact,
|
||||
RolePermissionsImpactBody,
|
||||
RoleUsage,
|
||||
@@ -5221,6 +5223,127 @@ export function useGetRoleUsage<
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the most recent permission-change audit records for the given
|
||||
role, newest first. Each record captures the actor (if known), the
|
||||
previous permission IDs, the new permission IDs, and the timestamp.
|
||||
|
||||
* @summary List recent permission-change audit entries for a role (admin)
|
||||
*/
|
||||
export const getGetRolePermissionAuditUrl = (
|
||||
id: number,
|
||||
params?: GetRolePermissionAuditParams,
|
||||
) => {
|
||||
const normalizedParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
normalizedParams.append(key, value === null ? "null" : value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const stringifiedParams = normalizedParams.toString();
|
||||
|
||||
return stringifiedParams.length > 0
|
||||
? `/api/roles/${id}/audit?${stringifiedParams}`
|
||||
: `/api/roles/${id}/audit`;
|
||||
};
|
||||
|
||||
export const getRolePermissionAudit = async (
|
||||
id: number,
|
||||
params?: GetRolePermissionAuditParams,
|
||||
options?: RequestInit,
|
||||
): Promise<RolePermissionAuditEntry[]> => {
|
||||
return customFetch<RolePermissionAuditEntry[]>(
|
||||
getGetRolePermissionAuditUrl(id, params),
|
||||
{
|
||||
...options,
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const getGetRolePermissionAuditQueryKey = (
|
||||
id: number,
|
||||
params?: GetRolePermissionAuditParams,
|
||||
) => {
|
||||
return [`/api/roles/${id}/audit`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getGetRolePermissionAuditQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getRolePermissionAudit>>,
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
>(
|
||||
id: number,
|
||||
params?: GetRolePermissionAuditParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getRolePermissionAudit>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetRolePermissionAuditQueryKey(id, params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getRolePermissionAudit>>
|
||||
> = ({ signal }) =>
|
||||
getRolePermissionAudit(id, params, { signal, ...requestOptions });
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getRolePermissionAudit>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetRolePermissionAuditQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getRolePermissionAudit>>
|
||||
>;
|
||||
export type GetRolePermissionAuditQueryError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary List recent permission-change audit entries for a role (admin)
|
||||
*/
|
||||
|
||||
export function useGetRolePermissionAudit<
|
||||
TData = Awaited<ReturnType<typeof getRolePermissionAudit>>,
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
>(
|
||||
id: number,
|
||||
params?: GetRolePermissionAuditParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getRolePermissionAudit>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetRolePermissionAuditQueryOptions(
|
||||
id,
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a candidate set of permission IDs for the role, returns the list of
|
||||
permissions that would be removed and, for each one, how many users would
|
||||
|
||||
@@ -1450,6 +1450,45 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/roles/{id}/audit:
|
||||
get:
|
||||
operationId: getRolePermissionAudit
|
||||
tags: [roles]
|
||||
summary: List recent permission-change audit entries for a role (admin)
|
||||
description: |
|
||||
Returns the most recent permission-change audit records for the given
|
||||
role, newest first. Each record captures the actor (if known), the
|
||||
previous permission IDs, the new permission IDs, and the timestamp.
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: limit
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 50
|
||||
default: 10
|
||||
responses:
|
||||
"200":
|
||||
description: Recent permission-change audit entries for the role
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/RolePermissionAuditEntry"
|
||||
"404":
|
||||
description: Role not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/roles/{id}/permissions/impact-preview:
|
||||
post:
|
||||
operationId: previewRolePermissionsImpact
|
||||
@@ -3579,6 +3618,46 @@ components:
|
||||
- id
|
||||
- username
|
||||
|
||||
RolePermissionAuditEntry:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
roleId:
|
||||
type: integer
|
||||
previousPermissionIds:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
newPermissionIds:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
addedPermissionIds:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
removedPermissionIds:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
actor:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/AuditLogActor"
|
||||
- type: "null"
|
||||
required:
|
||||
- id
|
||||
- roleId
|
||||
- previousPermissionIds
|
||||
- newPermissionIds
|
||||
- addedPermissionIds
|
||||
- removedPermissionIds
|
||||
- createdAt
|
||||
- actor
|
||||
|
||||
AuditLogEntry:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -1908,6 +1908,51 @@ export const GetRoleUsageResponse = zod.object({
|
||||
groupCount: zod.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the most recent permission-change audit records for the given
|
||||
role, newest first. Each record captures the actor (if known), the
|
||||
previous permission IDs, the new permission IDs, and the timestamp.
|
||||
|
||||
* @summary List recent permission-change audit entries for a role (admin)
|
||||
*/
|
||||
export const GetRolePermissionAuditParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const getRolePermissionAuditQueryLimitDefault = 10;
|
||||
export const getRolePermissionAuditQueryLimitMax = 50;
|
||||
|
||||
export const GetRolePermissionAuditQueryParams = zod.object({
|
||||
limit: zod.coerce
|
||||
.number()
|
||||
.min(1)
|
||||
.max(getRolePermissionAuditQueryLimitMax)
|
||||
.default(getRolePermissionAuditQueryLimitDefault),
|
||||
});
|
||||
|
||||
export const GetRolePermissionAuditResponseItem = zod.object({
|
||||
id: zod.number(),
|
||||
roleId: zod.number(),
|
||||
previousPermissionIds: zod.array(zod.number()),
|
||||
newPermissionIds: zod.array(zod.number()),
|
||||
addedPermissionIds: zod.array(zod.number()),
|
||||
removedPermissionIds: zod.array(zod.number()),
|
||||
createdAt: zod.coerce.date(),
|
||||
actor: zod.union([
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
username: zod.string(),
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
}),
|
||||
zod.null(),
|
||||
]),
|
||||
});
|
||||
export const GetRolePermissionAuditResponse = zod.array(
|
||||
GetRolePermissionAuditResponseItem,
|
||||
);
|
||||
|
||||
/**
|
||||
* Given a candidate set of permission IDs for the role, returns the list of
|
||||
permissions that would be removed and, for each one, how many users would
|
||||
|
||||
@@ -12,4 +12,5 @@ export * from "./password-reset-tokens";
|
||||
export * from "./notes";
|
||||
export * from "./groups";
|
||||
export * from "./audit-logs";
|
||||
export * from "./role-audit";
|
||||
export * from "./executive-meetings";
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
pgTable,
|
||||
serial,
|
||||
timestamp,
|
||||
integer,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { usersTable } from "./users";
|
||||
import { rolesTable } from "./roles";
|
||||
|
||||
export const rolePermissionAuditTable = pgTable(
|
||||
"role_permission_audit",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
roleId: integer("role_id")
|
||||
.notNull()
|
||||
.references(() => rolesTable.id, { onDelete: "cascade" }),
|
||||
actorUserId: integer("actor_user_id").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
previousPermissionIds: integer("previous_permission_ids")
|
||||
.array()
|
||||
.notNull()
|
||||
.default([]),
|
||||
newPermissionIds: integer("new_permission_ids")
|
||||
.array()
|
||||
.notNull()
|
||||
.default([]),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => [index("role_permission_audit_role_idx").on(t.roleId, t.createdAt)],
|
||||
);
|
||||
|
||||
export type RolePermissionAudit = typeof rolePermissionAuditTable.$inferSelect;
|
||||
@@ -57,7 +57,7 @@
|
||||
|
||||
## Database Tables
|
||||
|
||||
`users`, `roles`, `permissions`, `user_roles`, `role_permissions`, `apps`, `app_permissions`, `service_categories`, `services`, `conversations`, `conversation_participants`, `messages`, `message_reads`, `notifications`, `user_sessions`, `audit_logs`, `executive_meetings`, `executive_meeting_attendees`, `executive_meeting_requests`, `executive_meeting_tasks`, `executive_meeting_notifications`, `executive_meeting_audit_logs`, `executive_meeting_pdf_archives`, `executive_meeting_font_settings`
|
||||
`users`, `roles`, `permissions`, `user_roles`, `role_permissions`, `role_permission_audit`, `apps`, `app_permissions`, `service_categories`, `services`, `conversations`, `conversation_participants`, `messages`, `message_reads`, `notifications`, `user_sessions`, `audit_logs`, `executive_meetings`, `executive_meeting_attendees`, `executive_meeting_requests`, `executive_meeting_tasks`, `executive_meeting_notifications`, `executive_meeting_audit_logs`, `executive_meeting_pdf_archives`, `executive_meeting_font_settings`
|
||||
|
||||
## Important Notes
|
||||
|
||||
|
||||
Reference in New Issue
Block a user