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.
Replit-Task-Id: 4ba8c8de-8dfd-42c7-a3bc-964a1ed3a1a3
This commit is contained in:
@@ -13,4 +13,5 @@ export * from "./notes";
|
||||
export * from "./groups";
|
||||
export * from "./audit-logs";
|
||||
export * from "./role-audit";
|
||||
export * from "./permission-audit";
|
||||
export * from "./executive-meetings";
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
pgTable,
|
||||
serial,
|
||||
timestamp,
|
||||
integer,
|
||||
varchar,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { usersTable } from "./users";
|
||||
|
||||
// Unified permission-change audit table for admin actions across user-role
|
||||
// assignments, group memberships (users/roles/apps) and app-permission grants.
|
||||
//
|
||||
// We chose one table over per-entity tables so the API and UI can share a
|
||||
// single shape and pagination semantics. Discrimination is via:
|
||||
// - targetKind: the entity whose history this row belongs to
|
||||
// ('user' | 'group' | 'app')
|
||||
// - targetId: its id (kept as a plain integer because the FK target
|
||||
// varies; the row stays valid even if the entity is later
|
||||
// deleted)
|
||||
// - changeKind: what set of links was modified
|
||||
// ('user.roles' | 'user.groups' | 'group.users' |
|
||||
// 'group.roles' | 'group.apps' | 'app.permissions')
|
||||
// previous_ids / new_ids store the FULL sorted set of linked ids before and
|
||||
// after the change, mirroring role_permission_audit. The GET endpoint
|
||||
// derives added/removed on read so the UI can render diffs without us
|
||||
// duplicating that data on disk.
|
||||
export const permissionAuditTable = pgTable(
|
||||
"permission_audit",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
targetKind: varchar("target_kind", { length: 16 }).notNull(),
|
||||
targetId: integer("target_id").notNull(),
|
||||
changeKind: varchar("change_kind", { length: 32 }).notNull(),
|
||||
actorUserId: integer("actor_user_id").references(() => usersTable.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
previousIds: integer("previous_ids").array().notNull().default([]),
|
||||
newIds: integer("new_ids").array().notNull().default([]),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index("permission_audit_target_idx").on(
|
||||
t.targetKind,
|
||||
t.targetId,
|
||||
t.createdAt,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export type PermissionAudit = typeof permissionAuditTable.$inferSelect;
|
||||
|
||||
export const PERMISSION_AUDIT_TARGET_KINDS = ["user", "group", "app"] as const;
|
||||
export type PermissionAuditTargetKind =
|
||||
(typeof PERMISSION_AUDIT_TARGET_KINDS)[number];
|
||||
|
||||
export const PERMISSION_AUDIT_CHANGE_KINDS = [
|
||||
"user.roles",
|
||||
"user.groups",
|
||||
"group.users",
|
||||
"group.roles",
|
||||
"group.apps",
|
||||
"app.permissions",
|
||||
] as const;
|
||||
export type PermissionAuditChangeKind =
|
||||
(typeof PERMISSION_AUDIT_CHANGE_KINDS)[number];
|
||||
Reference in New Issue
Block a user