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:
@@ -2227,6 +2227,230 @@ export const DeleteGroupQueryParams = zod.object({
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns permission-change audit records for the given user, newest
|
||||
first. Mirrors the role audit endpoint shape and supports the same
|
||||
`limit`/`offset`/`actorUserId`/`from`/`to` filters. Each entry
|
||||
captures the actor (if known), the previous and new id sets, and
|
||||
the change kind (`user.roles` or `user.groups`).
|
||||
|
||||
* @summary List recent permission-change audit entries for a user (admin)
|
||||
*/
|
||||
export const GetUserPermissionAuditParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const getUserPermissionAuditQueryLimitDefault = 10;
|
||||
export const getUserPermissionAuditQueryLimitMax = 200;
|
||||
|
||||
export const getUserPermissionAuditQueryOffsetDefault = 0;
|
||||
export const getUserPermissionAuditQueryOffsetMin = 0;
|
||||
|
||||
export const getUserPermissionAuditQueryActorUserIdMin = 0;
|
||||
|
||||
export const GetUserPermissionAuditQueryParams = zod.object({
|
||||
limit: zod.coerce
|
||||
.number()
|
||||
.min(1)
|
||||
.max(getUserPermissionAuditQueryLimitMax)
|
||||
.default(getUserPermissionAuditQueryLimitDefault),
|
||||
offset: zod.coerce
|
||||
.number()
|
||||
.min(getUserPermissionAuditQueryOffsetMin)
|
||||
.default(getUserPermissionAuditQueryOffsetDefault),
|
||||
actorUserId: zod.coerce
|
||||
.number()
|
||||
.min(getUserPermissionAuditQueryActorUserIdMin)
|
||||
.optional(),
|
||||
from: zod.date().optional(),
|
||||
to: zod.date().optional(),
|
||||
});
|
||||
|
||||
export const GetUserPermissionAuditResponse = zod.object({
|
||||
entries: zod.array(
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
targetKind: zod.enum(["user", "group", "app"]),
|
||||
targetId: zod.number(),
|
||||
changeKind: zod.enum([
|
||||
"user.roles",
|
||||
"user.groups",
|
||||
"group.users",
|
||||
"group.roles",
|
||||
"group.apps",
|
||||
"app.permissions",
|
||||
]),
|
||||
previousIds: zod.array(zod.number()),
|
||||
newIds: zod.array(zod.number()),
|
||||
addedIds: zod.array(zod.number()),
|
||||
removedIds: 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(),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
totalCount: zod.number(),
|
||||
limit: zod.number(),
|
||||
offset: zod.number(),
|
||||
nextOffset: zod.number().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns permission-change audit records for the given group, newest
|
||||
first. Captures changes to the group's user, role and app sets via
|
||||
the discriminator field `changeKind`.
|
||||
|
||||
* @summary List recent permission-change audit entries for a group (admin)
|
||||
*/
|
||||
export const GetGroupPermissionAuditParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const getGroupPermissionAuditQueryLimitDefault = 10;
|
||||
export const getGroupPermissionAuditQueryLimitMax = 200;
|
||||
|
||||
export const getGroupPermissionAuditQueryOffsetDefault = 0;
|
||||
export const getGroupPermissionAuditQueryOffsetMin = 0;
|
||||
|
||||
export const getGroupPermissionAuditQueryActorUserIdMin = 0;
|
||||
|
||||
export const GetGroupPermissionAuditQueryParams = zod.object({
|
||||
limit: zod.coerce
|
||||
.number()
|
||||
.min(1)
|
||||
.max(getGroupPermissionAuditQueryLimitMax)
|
||||
.default(getGroupPermissionAuditQueryLimitDefault),
|
||||
offset: zod.coerce
|
||||
.number()
|
||||
.min(getGroupPermissionAuditQueryOffsetMin)
|
||||
.default(getGroupPermissionAuditQueryOffsetDefault),
|
||||
actorUserId: zod.coerce
|
||||
.number()
|
||||
.min(getGroupPermissionAuditQueryActorUserIdMin)
|
||||
.optional(),
|
||||
from: zod.date().optional(),
|
||||
to: zod.date().optional(),
|
||||
});
|
||||
|
||||
export const GetGroupPermissionAuditResponse = zod.object({
|
||||
entries: zod.array(
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
targetKind: zod.enum(["user", "group", "app"]),
|
||||
targetId: zod.number(),
|
||||
changeKind: zod.enum([
|
||||
"user.roles",
|
||||
"user.groups",
|
||||
"group.users",
|
||||
"group.roles",
|
||||
"group.apps",
|
||||
"app.permissions",
|
||||
]),
|
||||
previousIds: zod.array(zod.number()),
|
||||
newIds: zod.array(zod.number()),
|
||||
addedIds: zod.array(zod.number()),
|
||||
removedIds: 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(),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
totalCount: zod.number(),
|
||||
limit: zod.number(),
|
||||
offset: zod.number(),
|
||||
nextOffset: zod.number().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns permission-change audit records for the given app, newest
|
||||
first. Captures changes to the set of permissions required to open
|
||||
the app (`changeKind: app.permissions`).
|
||||
|
||||
* @summary List recent permission-change audit entries for an app (admin)
|
||||
*/
|
||||
export const GetAppPermissionAuditParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const getAppPermissionAuditQueryLimitDefault = 10;
|
||||
export const getAppPermissionAuditQueryLimitMax = 200;
|
||||
|
||||
export const getAppPermissionAuditQueryOffsetDefault = 0;
|
||||
export const getAppPermissionAuditQueryOffsetMin = 0;
|
||||
|
||||
export const getAppPermissionAuditQueryActorUserIdMin = 0;
|
||||
|
||||
export const GetAppPermissionAuditQueryParams = zod.object({
|
||||
limit: zod.coerce
|
||||
.number()
|
||||
.min(1)
|
||||
.max(getAppPermissionAuditQueryLimitMax)
|
||||
.default(getAppPermissionAuditQueryLimitDefault),
|
||||
offset: zod.coerce
|
||||
.number()
|
||||
.min(getAppPermissionAuditQueryOffsetMin)
|
||||
.default(getAppPermissionAuditQueryOffsetDefault),
|
||||
actorUserId: zod.coerce
|
||||
.number()
|
||||
.min(getAppPermissionAuditQueryActorUserIdMin)
|
||||
.optional(),
|
||||
from: zod.date().optional(),
|
||||
to: zod.date().optional(),
|
||||
});
|
||||
|
||||
export const GetAppPermissionAuditResponse = zod.object({
|
||||
entries: zod.array(
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
targetKind: zod.enum(["user", "group", "app"]),
|
||||
targetId: zod.number(),
|
||||
changeKind: zod.enum([
|
||||
"user.roles",
|
||||
"user.groups",
|
||||
"group.users",
|
||||
"group.roles",
|
||||
"group.apps",
|
||||
"app.permissions",
|
||||
]),
|
||||
previousIds: zod.array(zod.number()),
|
||||
newIds: zod.array(zod.number()),
|
||||
addedIds: zod.array(zod.number()),
|
||||
removedIds: 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(),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
totalCount: zod.number(),
|
||||
limit: zod.number(),
|
||||
offset: zod.number(),
|
||||
nextOffset: zod.number().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Get home screen stats
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user