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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user