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
+538 -3
View File
@@ -56,6 +56,15 @@ import {
usePreviewRolePermissionsImpact,
useGetRolePermissionAudit,
getGetRolePermissionAuditQueryKey,
useGetUserPermissionAudit,
getGetUserPermissionAuditQueryKey,
getUserPermissionAudit,
useGetGroupPermissionAudit,
getGetGroupPermissionAuditQueryKey,
getGroupPermissionAudit,
useGetAppPermissionAudit,
getGetAppPermissionAuditQueryKey,
getAppPermissionAudit,
useListAuditLogs,
getListAuditLogsQueryKey,
getExportAuditLogsCsvUrl,
@@ -82,6 +91,11 @@ import type {
RolePermissionsImpact,
GetRolePermissionAuditParams,
RolePermissionAuditEntry,
GetUserPermissionAuditParams,
GetGroupPermissionAuditParams,
GetAppPermissionAuditParams,
PermissionAuditEntry,
PermissionAuditEntryChangeKind,
Permission,
} from "@workspace/api-client-react";
import { ListUsersStatus } from "@workspace/api-client-react";
@@ -388,6 +402,8 @@ export default function AdminPage() {
const { data: apps } = useQuery({ queryKey: ADMIN_APPS_KEY, queryFn: fetchAdminApps, enabled: isAdmin });
const { data: services } = useListServices({ query: { queryKey: getListServicesQueryKey() } });
const { data: users } = useListUsers(undefined, { query: { queryKey: getListUsersQueryKey() } });
const { data: rolesList } = useListRoles({ query: { queryKey: getListRolesQueryKey(), enabled: isAdmin } });
const { data: permissionsList } = useListPermissions({ query: { queryKey: getListPermissionsQueryKey(), enabled: isAdmin } });
const { data: appSettings } = useGetAppSettings({ query: { queryKey: getGetAppSettingsQueryKey() } });
const statsRangeStorageKey = user?.id ? `admin.statsRange.${user.id}` : null;
const [statsRange, setStatsRange] = useState<"7d" | "30d" | "90d" | "custom">("7d");
@@ -813,6 +829,20 @@ export default function AdminPage() {
{editingApp.id !== undefined && (
<AppPermissionsEditor appId={editingApp.id} />
)}
{editingApp.id !== undefined && (
<PermissionAuditHistory
targetKind="app"
targetId={editingApp.id}
enabled={true}
language={lang}
scopeI18nKey="admin.apps.history"
changeKindLabel={null}
labelResolver={(_ck, id) => {
const p = (permissionsList ?? []).find((x) => x.id === id);
return p ? p.name : `#${id}`;
}}
/>
)}
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={() => setEditingApp(null)}>{t("common.cancel")}</Button>
<Button className="flex-1" onClick={handleSaveApp}>{t("common.save")}</Button>
@@ -2836,9 +2866,12 @@ function UserGroupsEditor({
onClose: () => void;
onSaved: () => void;
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const { toast } = useToast();
const updateUser = useUpdateUser();
const { data: roles } = useListRoles({
query: { queryKey: getListRolesQueryKey() },
});
const original = useMemo(
() => ({
@@ -3028,6 +3061,25 @@ function UserGroupsEditor({
{visibleGroups.length === 0 && <p className="text-sm text-muted-foreground p-2">{t("admin.groups.empty")}</p>}
</div>
</div>
<PermissionAuditHistory
targetKind="user"
targetId={user.id}
enabled={true}
language={i18n.language}
scopeI18nKey="admin.users.history"
changeKindLabel={(ck) =>
ck === "user.roles"
? t("admin.users.history.kind.roles")
: t("admin.users.history.kind.groups")
}
labelResolver={(ck, id) => {
if (ck === "user.groups") {
return groupNameById.get(id) ?? `#${id}`;
}
const r = (roles ?? []).find((x) => x.id === id);
return r ? r.name : `#${id}`;
}}
/>
<div className="flex gap-2 pt-2">
<Button variant="outline" className="flex-1" onClick={onClose}>{t("common.cancel")}</Button>
<Button
@@ -3400,7 +3452,9 @@ function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onC
const [appIds, setAppIds] = useState<Set<number>>(new Set());
const [userIds, setUserIds] = useState<Set<number>>(new Set());
const [roleIds, setRoleIds] = useState<Set<number>>(new Set());
const [tab, setTab] = useState<"info" | "apps" | "users" | "roles">("info");
const [tab, setTab] = useState<
"info" | "apps" | "users" | "roles" | "history"
>("info");
const [userSearch, setUserSearch] = useState("");
const visibleUsers = (users ?? []).filter((u) => {
@@ -3458,7 +3512,7 @@ function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onC
</div>
<div className="flex border-b border-slate-200/70">
{(["info", "apps", "users", "roles"] as const).map((k) => (
{(["info", "apps", "users", "roles", "history"] as const).map((k) => (
<button
key={k}
onClick={() => setTab(k)}
@@ -3583,6 +3637,35 @@ function GroupDetailEditor({ groupId, onClose, onSaved }: { groupId: number; onC
</div>
)}
{tab === "history" && (
<PermissionAuditHistory
targetKind="group"
targetId={group.id}
enabled={true}
language={lang}
scopeI18nKey="admin.groups.history"
changeKindLabel={(ck) =>
ck === "group.users"
? t("admin.groups.history.kind.users")
: ck === "group.roles"
? t("admin.groups.history.kind.roles")
: t("admin.groups.history.kind.apps")
}
labelResolver={(ck, id) => {
if (ck === "group.users") {
const u = (users ?? []).find((x) => x.id === id);
return u ? u.username : `#${id}`;
}
if (ck === "group.roles") {
const r = (roles ?? []).find((x) => x.id === id);
return r ? r.name : `#${id}`;
}
const a = (apps ?? []).find((x) => x.id === id);
return a ? (lang === "ar" ? a.nameAr : a.nameEn) : `#${id}`;
}}
/>
)}
<div className="flex gap-2 pt-2 border-t border-slate-200/70">
<Button variant="outline" className="flex-1" onClick={onClose}>{t("common.cancel")}</Button>
<Button
@@ -4035,6 +4118,458 @@ function RolePermissionHistory({
);
}
// ---------------------------------------------------------------------------
// PermissionAuditHistory
//
// Generalized version of RolePermissionHistory that powers the "History"
// section embedded into the User, Group and App admin dialogs. The shape of
// the audit endpoint is identical across the three entities (see
// permission_audit table); the only thing that varies is which generated
// hook to call and how to render id labels for each changeKind. We always
// call all three React Query hooks (rules-of-hooks) but only the one
// matching `targetKind` is enabled.
// ---------------------------------------------------------------------------
type PermissionAuditScope = "user" | "group" | "app";
type PermAuditLabelResolver = (
changeKind: PermissionAuditEntryChangeKind,
id: number,
) => string;
function PermissionAuditHistory({
targetKind,
targetId,
enabled,
language,
scopeI18nKey,
labelResolver,
changeKindLabel,
}: {
targetKind: PermissionAuditScope;
targetId: number;
enabled: boolean;
language: string;
// Translation prefix, e.g. "admin.users.history" / "admin.groups.history"
// / "admin.apps.history". Mirrors `admin.roles.history*` keys so we get
// bilingual support for free without forking copy per call site.
scopeI18nKey: "admin.users.history" | "admin.groups.history" | "admin.apps.history";
labelResolver: PermAuditLabelResolver;
// Group history mixes user/role/app changes — the caller can render a
// small badge per entry indicating which set was changed. Pass `null`
// for users/apps where the change kind is fixed.
changeKindLabel?: ((changeKind: PermissionAuditEntryChangeKind) => string) | null;
}) {
const { t } = useTranslation();
const testIdRoot = `${targetKind}-history`;
const [actorUserId, setActorUserId] = useState<number | "">("");
const [fromInput, setFromInput] = useState<string>("");
const [toInput, setToInput] = useState<string>("");
const [appliedFrom, setAppliedFrom] = useState<string>("");
const [appliedTo, setAppliedTo] = useState<string>("");
const [extraEntries, setExtraEntries] = useState<PermissionAuditEntry[]>([]);
const [extraNextOffset, setExtraNextOffset] = useState<number | null>(null);
const [hasExtras, setHasExtras] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [loadMoreError, setLoadMoreError] = useState<string | null>(null);
useEffect(() => {
setActorUserId("");
setFromInput("");
setToInput("");
setAppliedFrom("");
setAppliedTo("");
setExtraEntries([]);
setExtraNextOffset(null);
setHasExtras(false);
setLoadMoreError(null);
setIsLoadingMore(false);
}, [targetKind, targetId]);
const fromValid = !appliedFrom || ROLE_HISTORY_DATE_RE.test(appliedFrom);
const toValid = !appliedTo || ROLE_HISTORY_DATE_RE.test(appliedTo);
const orderValid = !appliedFrom || !appliedTo || appliedFrom <= appliedTo;
const filtersValid = fromValid && toValid && orderValid;
const { data: users } = useListUsers(undefined, {
query: { queryKey: getListUsersQueryKey(), enabled },
});
const sortedUsers = useMemo(() => {
const list = (users ?? []).slice();
list.sort((a, b) => {
const aLabel =
(language === "ar"
? a.displayNameAr ?? a.displayNameEn
: a.displayNameEn ?? a.displayNameAr) ?? a.username;
const bLabel =
(language === "ar"
? b.displayNameAr ?? b.displayNameEn
: b.displayNameEn ?? b.displayNameAr) ?? b.username;
return aLabel.localeCompare(bLabel);
});
return list;
}, [users, language]);
const firstPageParams: GetUserPermissionAuditParams = {
limit: ROLE_HISTORY_PAGE_SIZE,
offset: 0,
...(actorUserId !== "" ? { actorUserId: actorUserId as number } : {}),
...(appliedFrom ? { from: appliedFrom } : {}),
...(appliedTo ? { to: appliedTo } : {}),
};
// We must call all three hooks to satisfy rules-of-hooks; the
// `enabled` flag ensures only the one matching `targetKind` actually
// fires a request.
const userQuery = useGetUserPermissionAudit(targetId, firstPageParams, {
query: {
queryKey: getGetUserPermissionAuditQueryKey(targetId, firstPageParams),
enabled: enabled && filtersValid && targetKind === "user",
},
});
const groupQuery = useGetGroupPermissionAudit(
targetId,
firstPageParams as GetGroupPermissionAuditParams,
{
query: {
queryKey: getGetGroupPermissionAuditQueryKey(
targetId,
firstPageParams as GetGroupPermissionAuditParams,
),
enabled: enabled && filtersValid && targetKind === "group",
},
},
);
const appQuery = useGetAppPermissionAudit(
targetId,
firstPageParams as GetAppPermissionAuditParams,
{
query: {
queryKey: getGetAppPermissionAuditQueryKey(
targetId,
firstPageParams as GetAppPermissionAuditParams,
),
enabled: enabled && filtersValid && targetKind === "app",
},
},
);
const activeQuery =
targetKind === "user" ? userQuery : targetKind === "group" ? groupQuery : appQuery;
const { data, isLoading, isFetching } = activeQuery;
const filtersKey = JSON.stringify(firstPageParams);
useEffect(() => {
setExtraEntries([]);
setExtraNextOffset(null);
setHasExtras(false);
setLoadMoreError(null);
setIsLoadingMore(false);
}, [filtersKey]);
const firstPageSignature = data
? `${data.totalCount}|${data.entries[0]?.id ?? ""}`
: null;
useEffect(() => {
if (firstPageSignature == null) return;
setExtraEntries([]);
setExtraNextOffset(null);
setHasExtras(false);
setLoadMoreError(null);
setIsLoadingMore(false);
}, [firstPageSignature]);
const firstPageEntries = data?.entries ?? [];
const totalCount = data?.totalCount ?? 0;
const entries = hasExtras
? [...firstPageEntries, ...extraEntries]
: firstPageEntries;
const showingCount = entries.length;
const nextOffset = hasExtras ? extraNextOffset : data?.nextOffset ?? null;
const hasMore = nextOffset !== null;
const applyFilters = () => {
setAppliedFrom(fromInput.trim());
setAppliedTo(toInput.trim());
};
const resetFilters = () => {
setActorUserId("");
setFromInput("");
setToInput("");
setAppliedFrom("");
setAppliedTo("");
};
const loadMore = async () => {
if (nextOffset === null || isLoadingMore) return;
setIsLoadingMore(true);
setLoadMoreError(null);
try {
const params = { ...firstPageParams, offset: nextOffset };
const next =
targetKind === "user"
? await getUserPermissionAudit(targetId, params)
: targetKind === "group"
? await getGroupPermissionAudit(targetId, params)
: await getAppPermissionAudit(targetId, params);
setExtraEntries((prev) => [...prev, ...next.entries]);
setExtraNextOffset(next.nextOffset);
setHasExtras(true);
} catch (e) {
setLoadMoreError(
(e as { message?: string }).message ?? t("common.error"),
);
} finally {
setIsLoadingMore(false);
}
};
const filtersActive =
actorUserId !== "" || appliedFrom !== "" || appliedTo !== "";
const filterErrors: string[] = [];
if (!fromValid || !toValid)
filterErrors.push(t("admin.roles.historyErrors.invalidDate"));
else if (!orderValid)
filterErrors.push(t("admin.roles.historyErrors.invertedDates"));
return (
<div className="space-y-2 pt-2" data-testid={`${testIdRoot}-section`}>
<Label>{t(`${scopeI18nKey}Title`)}</Label>
<p className="text-xs text-muted-foreground">{t(`${scopeI18nKey}Hint`)}</p>
<div
className="grid gap-2 sm:grid-cols-2 md:grid-cols-4"
data-testid={`${testIdRoot}-filters`}
>
<div className="space-y-1">
<Label className="text-xs">{t("admin.roles.historyFilters.actor")}</Label>
<select
value={actorUserId === "" ? "" : String(actorUserId)}
onChange={(e) => {
const v = e.target.value;
setActorUserId(v === "" ? "" : Number(v));
}}
className="bg-white/70 border border-slate-200 rounded-lg px-3 py-2 text-sm w-full"
data-testid={`${testIdRoot}-actor-filter`}
>
<option value="">
{t("admin.roles.historyFilters.allActors")}
</option>
{sortedUsers.map((u) => {
const label =
(language === "ar"
? u.displayNameAr ?? u.displayNameEn
: u.displayNameEn ?? u.displayNameAr) ?? u.username;
return (
<option key={u.id} value={u.id}>
{label}
</option>
);
})}
</select>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("admin.roles.historyFilters.from")}</Label>
<Input
type="date"
value={fromInput}
onChange={(e) => setFromInput(e.target.value)}
className="bg-white/70 border-slate-200"
data-testid={`${testIdRoot}-from-input`}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("admin.roles.historyFilters.to")}</Label>
<Input
type="date"
value={toInput}
onChange={(e) => setToInput(e.target.value)}
className="bg-white/70 border-slate-200"
data-testid={`${testIdRoot}-to-input`}
/>
</div>
<div className="flex items-end gap-2">
<Button
type="button"
size="sm"
onClick={applyFilters}
className="flex-1"
data-testid={`${testIdRoot}-apply-filters`}
>
{t("admin.roles.historyFilters.apply")}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={resetFilters}
disabled={!filtersActive && !fromInput && !toInput}
data-testid={`${testIdRoot}-reset-filters`}
>
{t("admin.roles.historyFilters.reset")}
</Button>
</div>
</div>
{filterErrors.map((msg, i) => (
<p
key={i}
className="text-xs text-destructive"
data-testid={`${testIdRoot}-filter-error`}
>
{msg}
</p>
))}
<div
className="text-xs text-muted-foreground"
data-testid={`${testIdRoot}-showing`}
>
{filtersValid
? t("admin.roles.historyShowing", {
shown: showingCount,
total: totalCount,
})
: t("admin.roles.historyErrors.fixFiltersFirst")}
</div>
<div
className="border border-slate-200 rounded-xl bg-white/60 max-h-56 overflow-y-auto divide-y divide-slate-100"
data-testid={`${testIdRoot}-list`}
>
{!filtersValid ? (
<p
className="text-xs text-muted-foreground p-3 text-center"
data-testid={`${testIdRoot}-fix-filters`}
>
{t("admin.roles.historyErrors.fixFiltersFirst")}
</p>
) : isLoading ? (
<div className="flex justify-center py-6 text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
</div>
) : entries.length === 0 ? (
<p
className="text-xs text-muted-foreground p-3 text-center"
data-testid={`${testIdRoot}-empty`}
>
{filtersActive
? t(`${scopeI18nKey}EmptyFiltered`)
: t(`${scopeI18nKey}Empty`)}
</p>
) : (
entries.map((entry) => {
const actor = entry.actor;
const ar = actor?.displayNameAr ?? null;
const en = actor?.displayNameEn ?? null;
const preferred =
language === "ar" ? ar ?? en : en ?? ar;
const actorName =
actor && (preferred?.trim() ? preferred : actor.username);
const ck = entry.changeKind;
return (
<div
key={entry.id}
className="p-2.5 text-xs space-y-1"
data-testid={`${testIdRoot}-entry-${entry.id}`}
>
<div className="flex items-center justify-between gap-2 text-foreground">
<span
className="font-medium"
data-testid={`${testIdRoot}-actor-${entry.id}`}
>
{actorName ?? t("admin.roles.historyActorUnknown")}
</span>
<span
className="text-muted-foreground"
data-testid={`${testIdRoot}-time-${entry.id}`}
>
{formatAuditTimestamp(entry.createdAt, language)}
</span>
</div>
{changeKindLabel && (
<div
className="text-[10px] uppercase tracking-wider text-muted-foreground"
data-testid={`${testIdRoot}-kind-${entry.id}`}
>
{changeKindLabel(ck)}
</div>
)}
{entry.addedIds.length > 0 && (
<div
className="text-emerald-700"
data-testid={`${testIdRoot}-added-${entry.id}`}
>
<span className="font-semibold">
{t(`${scopeI18nKey}Added`, {
count: entry.addedIds.length,
})}
</span>{" "}
<span className="font-mono break-all">
{entry.addedIds
.map((id) => labelResolver(ck, id))
.join(", ")}
</span>
</div>
)}
{entry.removedIds.length > 0 && (
<div
className="text-rose-700"
data-testid={`${testIdRoot}-removed-${entry.id}`}
>
<span className="font-semibold">
{t(`${scopeI18nKey}Removed`, {
count: entry.removedIds.length,
})}
</span>{" "}
<span className="font-mono break-all">
{entry.removedIds
.map((id) => labelResolver(ck, id))
.join(", ")}
</span>
</div>
)}
<div
className="text-muted-foreground"
data-testid={`${testIdRoot}-total-${entry.id}`}
>
{t(`${scopeI18nKey}Total`, {
count: entry.newIds.length,
})}
</div>
</div>
);
})
)}
</div>
{filtersValid && hasMore && (
<div className="flex justify-center pt-1">
<Button
type="button"
size="sm"
variant="outline"
disabled={isLoadingMore || isFetching}
onClick={loadMore}
data-testid={`${testIdRoot}-load-more`}
>
{isLoadingMore || isFetching ? (
<Loader2 size={14} className="animate-spin" />
) : (
t("admin.roles.historyLoadMore")
)}
</Button>
</div>
)}
{loadMoreError && (
<p
className="text-xs text-destructive text-center"
data-testid={`${testIdRoot}-load-more-error`}
>
{loadMoreError}
</p>
)}
</div>
);
}
function RolesPanel() {
const { t, i18n } = useTranslation();
const { toast } = useToast();