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:
riyadhafraa
2026-04-30 11:58:18 +00:00
parent 8880d247e8
commit 2bf2f3cd3a
17 changed files with 3453 additions and 79 deletions
@@ -910,6 +910,49 @@ export interface RolePermissionAuditList {
nextOffset: number | null;
}
export type PermissionAuditEntryTargetKind =
(typeof PermissionAuditEntryTargetKind)[keyof typeof PermissionAuditEntryTargetKind];
export const PermissionAuditEntryTargetKind = {
user: "user",
group: "group",
app: "app",
} as const;
export type PermissionAuditEntryChangeKind =
(typeof PermissionAuditEntryChangeKind)[keyof typeof PermissionAuditEntryChangeKind];
export const PermissionAuditEntryChangeKind = {
userroles: "user.roles",
usergroups: "user.groups",
groupusers: "group.users",
grouproles: "group.roles",
groupapps: "group.apps",
apppermissions: "app.permissions",
} as const;
export interface PermissionAuditEntry {
id: number;
targetKind: PermissionAuditEntryTargetKind;
targetId: number;
changeKind: PermissionAuditEntryChangeKind;
previousIds: number[];
newIds: number[];
addedIds: number[];
removedIds: number[];
createdAt: string;
actor: AuditLogActor | null;
}
export interface PermissionAuditList {
entries: PermissionAuditEntry[];
totalCount: number;
limit: number;
offset: number;
/** @nullable */
nextOffset: number | null;
}
/**
* @nullable
*/
@@ -1028,6 +1071,60 @@ export type DeleteGroupParams = {
force?: boolean;
};
export type GetUserPermissionAuditParams = {
/**
* @minimum 1
* @maximum 200
*/
limit?: number;
/**
* @minimum 0
*/
offset?: number;
/**
* @minimum 0
*/
actorUserId?: number;
from?: string;
to?: string;
};
export type GetGroupPermissionAuditParams = {
/**
* @minimum 1
* @maximum 200
*/
limit?: number;
/**
* @minimum 0
*/
offset?: number;
/**
* @minimum 0
*/
actorUserId?: number;
from?: string;
to?: string;
};
export type GetAppPermissionAuditParams = {
/**
* @minimum 1
* @maximum 200
*/
limit?: number;
/**
* @minimum 0
*/
offset?: number;
/**
* @minimum 0
*/
actorUserId?: number;
from?: string;
to?: string;
};
export type GetAdminStatsParams = {
/**
* Time range for trend stats. Use "custom" with from/to.
+369
View File
@@ -49,7 +49,10 @@ import type {
GetAdminAppOpensByAppParams,
GetAdminAppOpensByUserParams,
GetAdminStatsParams,
GetAppPermissionAuditParams,
GetGroupPermissionAuditParams,
GetRolePermissionAuditParams,
GetUserPermissionAuditParams,
Group,
GroupDeletionConflict,
GroupDetail,
@@ -63,6 +66,7 @@ import type {
MessageWithSender,
Notification,
Permission,
PermissionAuditList,
RegisterBody,
ReplaceRolePermissionsBody,
RequestUploadUrlBody,
@@ -6342,6 +6346,371 @@ export const useDeleteGroup = <
return useMutation(getDeleteGroupMutationOptions(options));
};
/**
* 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 getGetUserPermissionAuditUrl = (
id: number,
params?: GetUserPermissionAuditParams,
) => {
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/users/${id}/audit?${stringifiedParams}`
: `/api/users/${id}/audit`;
};
export const getUserPermissionAudit = async (
id: number,
params?: GetUserPermissionAuditParams,
options?: RequestInit,
): Promise<PermissionAuditList> => {
return customFetch<PermissionAuditList>(
getGetUserPermissionAuditUrl(id, params),
{
...options,
method: "GET",
},
);
};
export const getGetUserPermissionAuditQueryKey = (
id: number,
params?: GetUserPermissionAuditParams,
) => {
return [`/api/users/${id}/audit`, ...(params ? [params] : [])] as const;
};
export const getGetUserPermissionAuditQueryOptions = <
TData = Awaited<ReturnType<typeof getUserPermissionAudit>>,
TError = ErrorType<ErrorResponse>,
>(
id: number,
params?: GetUserPermissionAuditParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getUserPermissionAudit>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetUserPermissionAuditQueryKey(id, params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getUserPermissionAudit>>
> = ({ signal }) =>
getUserPermissionAudit(id, params, { signal, ...requestOptions });
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getUserPermissionAudit>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetUserPermissionAuditQueryResult = NonNullable<
Awaited<ReturnType<typeof getUserPermissionAudit>>
>;
export type GetUserPermissionAuditQueryError = ErrorType<ErrorResponse>;
/**
* @summary List recent permission-change audit entries for a user (admin)
*/
export function useGetUserPermissionAudit<
TData = Awaited<ReturnType<typeof getUserPermissionAudit>>,
TError = ErrorType<ErrorResponse>,
>(
id: number,
params?: GetUserPermissionAuditParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getUserPermissionAudit>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetUserPermissionAuditQueryOptions(
id,
params,
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* 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 getGetGroupPermissionAuditUrl = (
id: number,
params?: GetGroupPermissionAuditParams,
) => {
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/groups/${id}/audit?${stringifiedParams}`
: `/api/groups/${id}/audit`;
};
export const getGroupPermissionAudit = async (
id: number,
params?: GetGroupPermissionAuditParams,
options?: RequestInit,
): Promise<PermissionAuditList> => {
return customFetch<PermissionAuditList>(
getGetGroupPermissionAuditUrl(id, params),
{
...options,
method: "GET",
},
);
};
export const getGetGroupPermissionAuditQueryKey = (
id: number,
params?: GetGroupPermissionAuditParams,
) => {
return [`/api/groups/${id}/audit`, ...(params ? [params] : [])] as const;
};
export const getGetGroupPermissionAuditQueryOptions = <
TData = Awaited<ReturnType<typeof getGroupPermissionAudit>>,
TError = ErrorType<ErrorResponse>,
>(
id: number,
params?: GetGroupPermissionAuditParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getGroupPermissionAudit>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetGroupPermissionAuditQueryKey(id, params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getGroupPermissionAudit>>
> = ({ signal }) =>
getGroupPermissionAudit(id, params, { signal, ...requestOptions });
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getGroupPermissionAudit>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetGroupPermissionAuditQueryResult = NonNullable<
Awaited<ReturnType<typeof getGroupPermissionAudit>>
>;
export type GetGroupPermissionAuditQueryError = ErrorType<ErrorResponse>;
/**
* @summary List recent permission-change audit entries for a group (admin)
*/
export function useGetGroupPermissionAudit<
TData = Awaited<ReturnType<typeof getGroupPermissionAudit>>,
TError = ErrorType<ErrorResponse>,
>(
id: number,
params?: GetGroupPermissionAuditParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getGroupPermissionAudit>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetGroupPermissionAuditQueryOptions(
id,
params,
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* 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 getGetAppPermissionAuditUrl = (
id: number,
params?: GetAppPermissionAuditParams,
) => {
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/apps/${id}/audit?${stringifiedParams}`
: `/api/apps/${id}/audit`;
};
export const getAppPermissionAudit = async (
id: number,
params?: GetAppPermissionAuditParams,
options?: RequestInit,
): Promise<PermissionAuditList> => {
return customFetch<PermissionAuditList>(
getGetAppPermissionAuditUrl(id, params),
{
...options,
method: "GET",
},
);
};
export const getGetAppPermissionAuditQueryKey = (
id: number,
params?: GetAppPermissionAuditParams,
) => {
return [`/api/apps/${id}/audit`, ...(params ? [params] : [])] as const;
};
export const getGetAppPermissionAuditQueryOptions = <
TData = Awaited<ReturnType<typeof getAppPermissionAudit>>,
TError = ErrorType<ErrorResponse>,
>(
id: number,
params?: GetAppPermissionAuditParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAppPermissionAudit>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
},
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetAppPermissionAuditQueryKey(id, params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getAppPermissionAudit>>
> = ({ signal }) =>
getAppPermissionAudit(id, params, { signal, ...requestOptions });
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getAppPermissionAudit>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetAppPermissionAuditQueryResult = NonNullable<
Awaited<ReturnType<typeof getAppPermissionAudit>>
>;
export type GetAppPermissionAuditQueryError = ErrorType<ErrorResponse>;
/**
* @summary List recent permission-change audit entries for an app (admin)
*/
export function useGetAppPermissionAudit<
TData = Awaited<ReturnType<typeof getAppPermissionAudit>>,
TError = ErrorType<ErrorResponse>,
>(
id: number,
params?: GetAppPermissionAuditParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAppPermissionAudit>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetAppPermissionAuditQueryOptions(
id,
params,
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get home screen stats
*/
+282
View File
@@ -1846,6 +1846,212 @@ paths:
schema:
$ref: "#/components/schemas/GroupDeletionConflict"
/users/{id}/audit:
get:
operationId: getUserPermissionAudit
tags: [users]
summary: List recent permission-change audit entries for a user (admin)
description: |
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`).
parameters:
- name: id
in: path
required: true
schema:
type: integer
- in: query
name: limit
required: false
schema:
type: integer
minimum: 1
maximum: 200
default: 10
- in: query
name: offset
required: false
schema:
type: integer
minimum: 0
default: 0
- in: query
name: actorUserId
required: false
schema:
type: integer
minimum: 0
- in: query
name: from
required: false
schema:
type: string
format: date
- in: query
name: to
required: false
schema:
type: string
format: date
responses:
"200":
description: A page of permission-change audit entries for the user
content:
application/json:
schema:
$ref: "#/components/schemas/PermissionAuditList"
"400":
description: Invalid filter parameters
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: User not found
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/groups/{id}/audit:
get:
operationId: getGroupPermissionAudit
tags: [users]
summary: List recent permission-change audit entries for a group (admin)
description: |
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`.
parameters:
- name: id
in: path
required: true
schema:
type: integer
- in: query
name: limit
required: false
schema:
type: integer
minimum: 1
maximum: 200
default: 10
- in: query
name: offset
required: false
schema:
type: integer
minimum: 0
default: 0
- in: query
name: actorUserId
required: false
schema:
type: integer
minimum: 0
- in: query
name: from
required: false
schema:
type: string
format: date
- in: query
name: to
required: false
schema:
type: string
format: date
responses:
"200":
description: A page of permission-change audit entries for the group
content:
application/json:
schema:
$ref: "#/components/schemas/PermissionAuditList"
"400":
description: Invalid filter parameters
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: Group not found
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/apps/{id}/audit:
get:
operationId: getAppPermissionAudit
tags: [apps]
summary: List recent permission-change audit entries for an app (admin)
description: |
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`).
parameters:
- name: id
in: path
required: true
schema:
type: integer
- in: query
name: limit
required: false
schema:
type: integer
minimum: 1
maximum: 200
default: 10
- in: query
name: offset
required: false
schema:
type: integer
minimum: 0
default: 0
- in: query
name: actorUserId
required: false
schema:
type: integer
minimum: 0
- in: query
name: from
required: false
schema:
type: string
format: date
- in: query
name: to
required: false
schema:
type: string
format: date
responses:
"200":
description: A page of permission-change audit entries for the app
content:
application/json:
schema:
$ref: "#/components/schemas/PermissionAuditList"
"400":
description: Invalid filter parameters
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: App not found
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
# Stats
/stats/home:
get:
@@ -3864,6 +4070,82 @@ components:
- createdAt
- actor
PermissionAuditList:
type: object
properties:
entries:
type: array
items:
$ref: "#/components/schemas/PermissionAuditEntry"
totalCount:
type: integer
limit:
type: integer
offset:
type: integer
nextOffset:
type: ["integer", "null"]
required:
- entries
- totalCount
- limit
- offset
- nextOffset
PermissionAuditEntry:
type: object
properties:
id:
type: integer
targetKind:
type: string
enum: [user, group, app]
targetId:
type: integer
changeKind:
type: string
enum:
- user.roles
- user.groups
- group.users
- group.roles
- group.apps
- app.permissions
previousIds:
type: array
items:
type: integer
newIds:
type: array
items:
type: integer
addedIds:
type: array
items:
type: integer
removedIds:
type: array
items:
type: integer
createdAt:
type: string
format: date-time
actor:
oneOf:
- $ref: "#/components/schemas/AuditLogActor"
- type: "null"
required:
- id
- targetKind
- targetId
- changeKind
- previousIds
- newIds
- addedIds
- removedIds
- createdAt
- actor
AuditLogEntry:
type: object
properties:
+224
View File
@@ -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
*/
+1
View File
@@ -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";
+68
View File
@@ -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];