diff --git a/artifacts/api-server/src/routes/audit.ts b/artifacts/api-server/src/routes/audit.ts index e4e499ae..975f3542 100644 --- a/artifacts/api-server/src/routes/audit.ts +++ b/artifacts/api-server/src/routes/audit.ts @@ -36,6 +36,8 @@ type AuditFilters = { forcedOnly: boolean; fromDate: Date | null; toDateExclusive: Date | null; + targetType: string; + targetId: number | null; }; function parseFilters(req: Request, res: Response): AuditFilters | null { @@ -45,6 +47,10 @@ function parseFilters(req: Request, res: Response): AuditFilters | null { const forcedOnly = forcedOnlyRaw === "true" || forcedOnlyRaw === "1"; const fromStr = typeof req.query.from === "string" ? req.query.from.trim() : ""; const toStr = typeof req.query.to === "string" ? req.query.to.trim() : ""; + const targetType = + typeof req.query.targetType === "string" ? req.query.targetType.trim() : ""; + const targetIdStr = + typeof req.query.targetId === "string" ? req.query.targetId.trim() : ""; let fromDate: Date | null = null; let toDateExclusive: Date | null = null; @@ -67,7 +73,16 @@ function parseFilters(req: Request, res: Response): AuditFilters | null { res.status(400).json({ error: "'from' must be on or before 'to'" }); return null; } - return { action, forcedOnly, fromDate, toDateExclusive }; + let targetId: number | null = null; + if (targetIdStr) { + const parsed = Number(targetIdStr); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1) { + res.status(400).json({ error: "Invalid 'targetId' (expected positive integer)" }); + return null; + } + targetId = parsed; + } + return { action, forcedOnly, fromDate, toDateExclusive, targetType, targetId }; } function buildWhere(filters: AuditFilters): SQL | undefined { @@ -89,6 +104,10 @@ function buildWhere(filters: AuditFilters): SQL | undefined { if (filters.fromDate) conditions.push(gte(auditLogsTable.createdAt, filters.fromDate)); if (filters.toDateExclusive) conditions.push(lt(auditLogsTable.createdAt, filters.toDateExclusive)); + if (filters.targetType) + conditions.push(eq(auditLogsTable.targetType, filters.targetType)); + if (filters.targetId != null) + conditions.push(eq(auditLogsTable.targetId, filters.targetId)); return conditions.length > 0 ? and(...conditions) : undefined; } diff --git a/artifacts/api-server/tests/audit-logs-target-filter.test.mjs b/artifacts/api-server/tests/audit-logs-target-filter.test.mjs new file mode 100644 index 00000000..04e6497e --- /dev/null +++ b/artifacts/api-server/tests/audit-logs-target-filter.test.mjs @@ -0,0 +1,243 @@ +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import pg from "pg"; + +const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080"; +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + throw new Error("DATABASE_URL must be set to run these tests"); +} + +const TEST_PASSWORD = "TestPass123!"; +const TEST_PASSWORD_HASH = + "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; + +const pool = new pg.Pool({ connectionString: DATABASE_URL }); + +const STAMP = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; + +let adminId; +let adminUsername; +let adminCookie; +const insertedAuditIds = []; +const TEST_TAG = `target_filter_test_${STAMP}`; + +// A handful of unique parent IDs so we can verify targetId filtering. +const PARENT_SERVICE_ID = 800100001; +const PARENT_SERVICE_ID_OTHER = 800100002; +const PARENT_GROUP_ID = 800200001; +const PARENT_APP_ID = 800300001; + +async function loginAndGetCookie(username, password) { + const res = await fetch(`${API_BASE}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + assert.equal(res.status, 200, `login expected 200, got ${res.status}`); + const setCookie = res.headers.get("set-cookie"); + return setCookie + .split(",") + .map((c) => c.split(";")[0].trim()) + .find((c) => c.startsWith("connect.sid=")); +} + +async function insertAuditRow({ action, targetType, targetId, metadata }) { + const r = await pool.query( + `INSERT INTO audit_logs (actor_user_id, action, target_type, target_id, metadata) + VALUES ($1, $2, $3, $4, $5::jsonb) RETURNING id`, + [ + adminId, + action, + targetType, + targetId, + JSON.stringify({ ...metadata, testTag: TEST_TAG }), + ], + ); + insertedAuditIds.push(r.rows[0].id); + return r.rows[0].id; +} + +before(async () => { + adminUsername = `audit_target_admin_${STAMP}`; + const a = await pool.query( + `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) + VALUES ($1, $2, $3, 'Audit Target Admin', 'en', true) RETURNING id`, + [adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH], + ); + adminId = a.rows[0].id; + await pool.query( + `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`, + [adminId], + ); + adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD); + + // History for the parent service that will be force-deleted. + await insertAuditRow({ + action: "service.create", + targetType: "service", + targetId: PARENT_SERVICE_ID, + metadata: { nameEn: "ParentSvc" }, + }); + await insertAuditRow({ + action: "service.update", + targetType: "service", + targetId: PARENT_SERVICE_ID, + metadata: { changes: 2 }, + }); + await insertAuditRow({ + action: "service.force_delete", + targetType: "service", + targetId: PARENT_SERVICE_ID, + metadata: { nameEn: "ParentSvc", orderCount: 7, messageCount: 3 }, + }); + // A different service so we can prove targetId narrows correctly. + await insertAuditRow({ + action: "service.create", + targetType: "service", + targetId: PARENT_SERVICE_ID_OTHER, + metadata: { nameEn: "OtherSvc" }, + }); + + // Group and app rows so we can prove targetType-only filtering works. + await insertAuditRow({ + action: "group.create", + targetType: "group", + targetId: PARENT_GROUP_ID, + metadata: { name: "Grp" }, + }); + await insertAuditRow({ + action: "group.force_delete", + targetType: "group", + targetId: PARENT_GROUP_ID, + metadata: { name: "Grp", memberCount: 4 }, + }); + await insertAuditRow({ + action: "app.create", + targetType: "app", + targetId: PARENT_APP_ID, + metadata: { nameEn: "AppX" }, + }); +}); + +after(async () => { + if (insertedAuditIds.length) { + await pool.query(`DELETE FROM audit_logs WHERE id = ANY($1::int[])`, [ + insertedAuditIds, + ]); + } + if (adminId !== undefined) { + await pool.query(`DELETE FROM audit_logs WHERE actor_user_id = $1`, [ + adminId, + ]); + await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [adminId]); + await pool.query(`DELETE FROM users WHERE id = $1`, [adminId]); + } + await pool.end(); +}); + +function ownEntries(body) { + return body.entries.filter( + (e) => + e.metadata && + typeof e.metadata === "object" && + e.metadata.testTag === TEST_TAG, + ); +} + +async function listWithParams(qs) { + const res = await fetch(`${API_BASE}/api/admin/audit-logs?${qs}`, { + headers: { Cookie: adminCookie }, + }); + assert.equal(res.status, 200); + return res.json(); +} + +test("targetType filter narrows results to that target_type", async () => { + const body = await listWithParams("targetType=group&limit=200"); + const ours = ownEntries(body); + assert.ok(ours.length >= 2, "expected at least the seeded group rows"); + for (const e of ours) { + assert.equal(e.targetType, "group", `unexpected targetType ${e.targetType}`); + } +}); + +test("targetType=app excludes service and group rows", async () => { + const body = await listWithParams("targetType=app&limit=200"); + const ours = ownEntries(body); + assert.ok(ours.some((e) => e.targetId === PARENT_APP_ID)); + assert.ok(!ours.some((e) => e.targetType === "service")); + assert.ok(!ours.some((e) => e.targetType === "group")); +}); + +test("targetType + targetId returns only that single entity's history", async () => { + const body = await listWithParams( + `targetType=service&targetId=${PARENT_SERVICE_ID}&limit=200`, + ); + const ours = ownEntries(body); + // 3 seeded rows for that service: create, update, force_delete. + assert.equal(ours.length, 3); + for (const e of ours) { + assert.equal(e.targetType, "service"); + assert.equal(e.targetId, PARENT_SERVICE_ID); + } + const actions = ours.map((e) => e.action).sort(); + assert.deepEqual(actions, [ + "service.create", + "service.force_delete", + "service.update", + ]); +}); + +test("targetType + targetId excludes other entities of the same type", async () => { + const body = await listWithParams( + `targetType=service&targetId=${PARENT_SERVICE_ID}&limit=200`, + ); + const ours = ownEntries(body); + assert.ok( + !ours.some((e) => e.targetId === PARENT_SERVICE_ID_OTHER), + "other service rows must not appear", + ); +}); + +test("invalid targetId returns 400", async () => { + const res = await fetch( + `${API_BASE}/api/admin/audit-logs?targetType=service&targetId=not-a-number`, + { headers: { Cookie: adminCookie } }, + ); + assert.equal(res.status, 400); + const body = await res.json(); + assert.match(body.error ?? "", /targetId/); +}); + +test("targetType combines with forcedOnly filter", async () => { + const body = await listWithParams( + "targetType=service&forcedOnly=true&limit=200", + ); + const ours = ownEntries(body); + assert.ok(ours.some((e) => e.action === "service.force_delete")); + // Non-force rows must be excluded by forcedOnly. + assert.ok(!ours.some((e) => e.action === "service.create")); + assert.ok(!ours.some((e) => e.action === "service.update")); +}); + +test("CSV export honors targetType + targetId filter", async () => { + const res = await fetch( + `${API_BASE}/api/admin/audit-logs/export?targetType=service&targetId=${PARENT_SERVICE_ID}`, + { headers: { Cookie: adminCookie, Accept: "text/csv" } }, + ); + assert.equal(res.status, 200); + assert.match(res.headers.get("content-type") ?? "", /text\/csv/); + const text = await res.text(); + const ourLines = text + .split(/\r?\n/) + .filter((line) => line.includes(TEST_TAG)); + assert.ok(ourLines.length >= 3); + for (const line of ourLines) { + assert.match( + line, + new RegExp(`,service,${PARENT_SERVICE_ID},`), + `expected every CSV row to be the targeted service: ${line}`, + ); + } +}); diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index a0fb4398..68ade125 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -558,7 +558,10 @@ "apply": "تطبيق", "reset": "إعادة ضبط", "forcedOnly": "عمليات الحذف القسري فقط", - "forcedOnlyHint": "عرض إدخالات الحذف القسري للمجموعات والمستخدمين والتطبيقات والخدمات فقط." + "forcedOnlyHint": "عرض إدخالات الحذف القسري للمجموعات والمستخدمين والتطبيقات والخدمات فقط.", + "targetType": "نوع الهدف: {{type}}", + "targetWithId": "الهدف: {{type}} #{{id}}", + "clearTargetFilter": "مسح فلتر الهدف" }, "target": "الهدف: {{type}} #{{id}}", "targetWithName": "الهدف: {{type}} #{{id}} · {{name}}", @@ -649,7 +652,9 @@ "open_other": "{{count}} فتحة" }, "dependencies": { - "label": "التبعيات المُزالة" + "label": "التبعيات المُزالة", + "chipAriaTargetType": "{{label}} — فتح سجل التدقيق مفلتراً حسب نوع الهدف \"{{type}}\"", + "chipAriaParent": "{{label}} — فتح سجل التدقيق لـ {{type}} #{{id}}" }, "summary": { "app": { diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 332e56dc..45715e05 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -543,7 +543,10 @@ "apply": "Apply", "reset": "Reset", "forcedOnly": "Forced deletions only", - "forcedOnlyHint": "Show only force-delete entries for groups, users, apps, and services." + "forcedOnlyHint": "Show only force-delete entries for groups, users, apps, and services.", + "targetType": "Target type: {{type}}", + "targetWithId": "Target: {{type}} #{{id}}", + "clearTargetFilter": "Clear target filter" }, "target": "Target: {{type}} #{{id}}", "targetWithName": "Target: {{type}} #{{id}} · {{name}}", @@ -586,7 +589,9 @@ "open_other": "{{count}} opens" }, "dependencies": { - "label": "Cleared dependencies" + "label": "Cleared dependencies", + "chipAriaTargetType": "{{label}} — open audit log filtered by target type \"{{type}}\"", + "chipAriaParent": "{{label}} — open audit log for {{type}} #{{id}}" }, "summary": { "app": { diff --git a/artifacts/tx-os/src/pages/admin.tsx b/artifacts/tx-os/src/pages/admin.tsx index 454a5ed4..680c5b2e 100644 --- a/artifacts/tx-os/src/pages/admin.tsx +++ b/artifacts/tx-os/src/pages/admin.tsx @@ -580,9 +580,28 @@ export default function AdminPage() { } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Skip the very first render so we don't clobber a deep-linked hash like + // `#section=audit-log&targetType=group&targetId=42` before the mount-time + // parser above has had a chance to apply it. + const skipNextSectionSync = useRef(true); useEffect(() => { if (typeof window === "undefined") return; - const newHash = `#section=${section}`; + if (skipNextSectionSync.current) { + skipNextSectionSync.current = false; + return; + } + const raw = window.location.hash.replace(/^#/, ""); + const params = new URLSearchParams(raw); + const previousSection = params.get("section"); + params.set("section", section); + // Section-scoped params (the audit log's targetType / targetId deep + // links) only make sense within their owning section. Switching to a + // different section drops them so the next visit starts fresh. + if (previousSection !== section) { + params.delete("targetType"); + params.delete("targetId"); + } + const newHash = `#${params.toString()}`; if (window.location.hash !== newHash) { window.history.replaceState(null, "", newHash); } @@ -4827,6 +4846,23 @@ const DEPENDENCY_COUNT_KEYS: Record = { openCount: "open", }; +// When a dependency chip key maps to a known audit-log target_type, clicking +// the chip pivots to "audit log filtered by that target_type". Other chip +// keys (orders, messages, notes, conversations, opens) have no audit_logs +// rows of their own, so they pivot to the parent entity's audit history +// instead (target_type + target_id of the deletion row). +const DEPENDENCY_CHIP_TARGET_TYPES: Record = { + groupCount: "group", + memberCount: "user", + appCount: "app", + roleCount: "role", +}; + +type AuditTargetPivot = { + targetType: string; + targetId: number | null; +}; + function isForceDeleteEntry(entry: AuditLogEntry): boolean { const action = entry.action; if (action.endsWith(".force_delete")) return true; @@ -4861,18 +4897,35 @@ function forceDeletedEntityName( function dependencyChips( entry: AuditLogEntry, t: AuditTFunction, -): Array<{ key: string; label: string }> { +): Array<{ key: string; label: string; pivot: AuditTargetPivot }> { const meta = asRecord(entry.metadata); - const chips: Array<{ key: string; label: string }> = []; + const chips: Array<{ key: string; label: string; pivot: AuditTargetPivot }> = + []; for (const [metaKey, unitRoot] of Object.entries(DEPENDENCY_COUNT_KEYS)) { const value = asNumber(meta[metaKey]); if (value == null || value <= 0) continue; - chips.push({ key: metaKey, label: unitLabel(t, unitRoot, value) }); + const mappedTargetType = DEPENDENCY_CHIP_TARGET_TYPES[metaKey]; + const pivot: AuditTargetPivot = mappedTargetType + ? { targetType: mappedTargetType, targetId: null } + : { targetType: entry.targetType, targetId: entry.targetId ?? null }; + chips.push({ + key: metaKey, + label: unitLabel(t, unitRoot, value), + pivot, + }); } return chips; } -function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) { +function AuditLogRow({ + entry, + lang, + onPivotToTarget, +}: { + entry: AuditLogEntry; + lang: string; + onPivotToTarget: (pivot: AuditTargetPivot) => void; +}) { const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); const hasMetadata = @@ -4961,15 +5014,31 @@ function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) { data-testid={`audit-deps-${entry.id}`} aria-label={t("admin.audit.dependencies.label")} > - {chips.map((chip) => ( - - {chip.label} - - ))} + {chips.map((chip) => { + const ariaLabel = chip.pivot.targetId != null + ? t("admin.audit.dependencies.chipAriaParent", { + label: chip.label, + type: entry.targetType, + id: chip.pivot.targetId, + }) + : t("admin.audit.dependencies.chipAriaTargetType", { + label: chip.label, + type: chip.pivot.targetType, + }); + return ( + + ); + })} )} @@ -5000,6 +5069,51 @@ function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) { ); } +// Parses targetType / targetId values out of the admin page URL hash so the +// audit log panel can deep-link to a pre-filtered view (e.g. when an admin +// clicks a dependency chip on a forced-delete row). +function parseAuditHashTarget(): AuditTargetPivot { + if (typeof window === "undefined") return { targetType: "", targetId: null }; + const raw = window.location.hash.replace(/^#/, ""); + const params = new URLSearchParams(raw); + const targetType = params.get("targetType")?.trim() ?? ""; + const targetIdRaw = params.get("targetId")?.trim(); + let targetId: number | null = null; + if (targetIdRaw) { + const n = Number(targetIdRaw); + if (Number.isFinite(n) && Number.isInteger(n) && n >= 1) { + targetId = n; + } + } + return { targetType, targetId }; +} + +// Updates the admin URL hash (which carries `section=audit-log`) so the +// active targetType/targetId filters survive reload and back/forward nav. +// Uses replaceState to avoid polluting browser history with every tweak. +function syncAuditHashTarget(pivot: AuditTargetPivot) { + if (typeof window === "undefined") return; + const raw = window.location.hash.replace(/^#/, ""); + const params = new URLSearchParams(raw); + if (pivot.targetType) { + params.set("targetType", pivot.targetType); + } else { + params.delete("targetType"); + } + if (pivot.targetId != null) { + params.set("targetId", String(pivot.targetId)); + } else { + params.delete("targetId"); + } + if (!params.has("section")) { + params.set("section", "audit-log"); + } + const newHash = `#${params.toString()}`; + if (window.location.hash !== newHash) { + window.history.replaceState(null, "", newHash); + } +} + function AuditLogPanel() { const { t, i18n } = useTranslation(); const lang = i18n.language; @@ -5012,6 +5126,15 @@ function AuditLogPanel() { const [pageLimit, setPageLimit] = useState(50); const [exporting, setExporting] = useState(false); const [exportError, setExportError] = useState(null); + // Deep-link target filters. Initialised from the URL hash so a user who + // pastes /admin#section=audit-log&targetType=group lands directly on a + // pre-filtered view. Updated by chip clicks and the explicit clear button. + const [targetTypeFilter, setTargetTypeFilter] = useState( + () => parseAuditHashTarget().targetType, + ); + const [targetIdFilter, setTargetIdFilter] = useState( + () => parseAuditHashTarget().targetId, + ); const fromValid = !appliedFrom || DATE_RE.test(appliedFrom); const toValid = !appliedTo || DATE_RE.test(appliedTo); @@ -5029,6 +5152,8 @@ function AuditLogPanel() { : {}), ...(appliedFrom ? { from: appliedFrom } : {}), ...(appliedTo ? { to: appliedTo } : {}), + ...(targetTypeFilter ? { targetType: targetTypeFilter } : {}), + ...(targetIdFilter != null ? { targetId: targetIdFilter } : {}), }; const queryKey = getListAuditLogsQueryKey(params); @@ -5060,6 +5185,9 @@ function AuditLogPanel() { setAppliedFrom(""); setAppliedTo(""); setPageLimit(50); + setTargetTypeFilter(""); + setTargetIdFilter(null); + syncAuditHashTarget({ targetType: "", targetId: null }); }; const toggleForcedOnly = () => { @@ -5071,6 +5199,24 @@ function AuditLogPanel() { }); }; + // Chip click on a forced-delete row: pre-filter the panel to the chosen + // target_type (and optionally target_id). Resets pagination so the new + // result set starts fresh, and updates the URL hash so the filter + // survives reload / back-forward navigation. + const pivotToTarget = (pivot: AuditTargetPivot) => { + setTargetTypeFilter(pivot.targetType); + setTargetIdFilter(pivot.targetId); + setPageLimit(50); + syncAuditHashTarget(pivot); + }; + + const clearTargetFilter = () => { + setTargetTypeFilter(""); + setTargetIdFilter(null); + setPageLimit(50); + syncAuditHashTarget({ targetType: "", targetId: null }); + }; + const loadMore = () => setPageLimit((n) => Math.min(n + 50, 200)); const handleExportCsv = async () => { @@ -5086,6 +5232,8 @@ function AuditLogPanel() { : {}), ...(appliedFrom ? { from: appliedFrom } : {}), ...(appliedTo ? { to: appliedTo } : {}), + ...(targetTypeFilter ? { targetType: targetTypeFilter } : {}), + ...(targetIdFilter != null ? { targetId: targetIdFilter } : {}), }); const res = await fetch(url, { method: "GET", @@ -5116,6 +5264,15 @@ function AuditLogPanel() { } }; + const activeTargetPillLabel = targetTypeFilter + ? targetIdFilter != null + ? t("admin.audit.filters.targetWithId", { + type: targetTypeFilter, + id: targetIdFilter, + }) + : t("admin.audit.filters.targetType", { type: targetTypeFilter }) + : null; + return (
@@ -5136,6 +5293,24 @@ function AuditLogPanel() { {t("admin.audit.filters.forcedOnly")} + {activeTargetPillLabel && ( + + {activeTargetPillLabel} + + + )}
@@ -5245,7 +5420,12 @@ function AuditLogPanel() { ) : (
{entries.map((entry) => ( - + ))}
)} diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 9ad00724..09253ab5 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -1097,6 +1097,21 @@ contains `force: true`. Overrides the `action` filter when set. * End date (inclusive, YYYY-MM-DD UTC). */ to?: string; + /** + * Restrict results to entries whose `target_type` exactly matches +(e.g. "group", "app", "role", "user", "service", "settings"). +Combines with `targetId` and the other filters. + + */ + targetType?: string; + /** + * Restrict results to entries whose `target_id` exactly matches. +Typically used together with `targetType` to focus on the history +of a single entity. + + * @minimum 1 + */ + targetId?: number; /** * @minimum 1 * @maximum 200 @@ -1128,4 +1143,16 @@ Overrides the `action` filter when set. * End date (inclusive, YYYY-MM-DD UTC). */ to?: string; + /** + * Restrict the export to entries whose `target_type` exactly matches. +Combines with `targetId` and the other filters. + + */ + targetType?: string; + /** + * Restrict the export to entries whose `target_id` exactly matches. + + * @minimum 1 + */ + targetId?: number; }; diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 058ecc2b..b0928399 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -2035,6 +2035,25 @@ paths: type: string format: date description: End date (inclusive, YYYY-MM-DD UTC). + - in: query + name: targetType + required: false + schema: + type: string + description: | + Restrict results to entries whose `target_type` exactly matches + (e.g. "group", "app", "role", "user", "service", "settings"). + Combines with `targetId` and the other filters. + - in: query + name: targetId + required: false + schema: + type: integer + minimum: 1 + description: | + Restrict results to entries whose `target_id` exactly matches. + Typically used together with `targetType` to focus on the history + of a single entity. - in: query name: limit required: false @@ -2106,6 +2125,22 @@ paths: type: string format: date description: End date (inclusive, YYYY-MM-DD UTC). + - in: query + name: targetType + required: false + schema: + type: string + description: | + Restrict the export to entries whose `target_type` exactly matches. + Combines with `targetId` and the other filters. + - in: query + name: targetId + required: false + schema: + type: integer + minimum: 1 + description: | + Restrict the export to entries whose `target_id` exactly matches. responses: "200": description: CSV file download diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 542dd7fa..3caac32d 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -2434,6 +2434,19 @@ export const ListAuditLogsQueryParams = zod.object({ .optional() .describe("Start date (inclusive, YYYY-MM-DD UTC)."), to: zod.date().optional().describe("End date (inclusive, YYYY-MM-DD UTC)."), + targetType: zod.coerce + .string() + .optional() + .describe( + 'Restrict results to entries whose `target_type` exactly matches\n(e.g. \"group\", \"app\", \"role\", \"user\", \"service\", \"settings\").\nCombines with `targetId` and the other filters.\n', + ), + targetId: zod.coerce + .number() + .min(1) + .optional() + .describe( + "Restrict results to entries whose `target_id` exactly matches.\nTypically used together with `targetType` to focus on the history\nof a single entity.\n", + ), limit: zod.coerce .number() .min(1) @@ -2504,4 +2517,17 @@ export const ExportAuditLogsCsvQueryParams = zod.object({ .optional() .describe("Start date (inclusive, YYYY-MM-DD UTC)."), to: zod.date().optional().describe("End date (inclusive, YYYY-MM-DD UTC)."), + targetType: zod.coerce + .string() + .optional() + .describe( + "Restrict the export to entries whose `target_type` exactly matches.\nCombines with `targetId` and the other filters.\n", + ), + targetId: zod.coerce + .number() + .min(1) + .optional() + .describe( + "Restrict the export to entries whose `target_id` exactly matches.\n", + ), });