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:
riyadhafraa
2026-04-29 13:16:02 +00:00
parent 19b5dc788e
commit a14b589006
15 changed files with 896 additions and 32 deletions
@@ -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;
};
+123
View File
@@ -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