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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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