Skip to content

Commit e71326c

Browse files
committed
fix(audit-logs): present nothing when the workspace scope cannot be answered
Disabling the query was not enough. An unresolved scope drops the filter, so its query key equals the unscoped feed's, and a disabled query still serves whatever is cached under its key — an admin reading the organization-wide feed who then followed a stale scoped link kept those rows on screen, with Export still armed against them because that gate reads the same list. The rule now has a name and a seam: `presentableAuditEntries` returns nothing unless the feed can answer the scope the URL asks for, and the export action states that condition where it is read rather than inheriting it through an empty list.
1 parent 0d40616 commit e71326c

2 files changed

Lines changed: 84 additions & 5 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import type { AuditLogPage } from '@/lib/api/contracts/audit-logs'
6+
import { presentableAuditEntries } from '@/ee/audit-logs/components/audit-logs'
7+
8+
function page(...ids: string[]): AuditLogPage {
9+
return {
10+
success: true,
11+
data: ids.map((id) => ({
12+
id,
13+
workspaceId: null,
14+
actorId: null,
15+
actorName: null,
16+
actorEmail: null,
17+
action: 'organization.updated',
18+
resourceType: 'organization',
19+
resourceId: null,
20+
resourceName: null,
21+
description: null,
22+
metadata: null,
23+
createdAt: '2026-01-01T00:00:00.000Z',
24+
})),
25+
}
26+
}
27+
28+
describe('presentableAuditEntries', () => {
29+
it('flattens every loaded page while the scope is answerable', () => {
30+
expect(presentableAuditEntries([page('a', 'b'), page('c')], true).map((e) => e.id)).toEqual([
31+
'a',
32+
'b',
33+
'c',
34+
])
35+
})
36+
37+
/**
38+
* The case this exists for: an unresolved workspace scope drops the filter, so its
39+
* query key equals the unscoped feed's. Disabling the query does not clear that
40+
* cache entry, so an admin who had just been reading the organization-wide feed
41+
* would have kept its rows on screen under a scoped URL — and Export, which gates
42+
* on this list being non-empty, stayed armed against them.
43+
*/
44+
it('presents nothing when the scope cannot be answered, even with pages cached', () => {
45+
expect(presentableAuditEntries([page('a', 'b')], false)).toEqual([])
46+
})
47+
48+
it('presents nothing before any page has loaded', () => {
49+
expect(presentableAuditEntries(undefined, true)).toEqual([])
50+
})
51+
})

apps/sim/ee/audit-logs/components/audit-logs.tsx

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { createLogger } from '@sim/logger'
2121
import { formatDateTime } from '@sim/utils/formatting'
2222
import { isRecordLike } from '@sim/utils/object'
2323
import { useQueryStates } from 'nuqs'
24+
import type { AuditLogPage } from '@/lib/api/contracts/audit-logs'
2425
import { formatDateShort } from '@/lib/core/utils/date-display'
2526
import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters'
2627
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
@@ -240,6 +241,24 @@ interface AuditLogsProps {
240241
organizationId: string
241242
}
242243

244+
/**
245+
* Entries the feed is allowed to present.
246+
*
247+
* A disabled query still serves whatever is cached under its key, and an unresolved
248+
* workspace scope resolves to the same key as the unscoped feed — so an admin looking
249+
* at the organization-wide feed who then followed a stale scoped link kept those rows
250+
* on screen, with Export still armed against them. The scope a link asks for is a
251+
* ceiling, so when it cannot be honoured the feed presents nothing rather than
252+
* whatever it happens to be holding.
253+
*/
254+
export function presentableAuditEntries(
255+
pages: AuditLogPage[] | undefined,
256+
isScopeAnswerable: boolean
257+
): EnterpriseAuditLogEntry[] {
258+
if (!isScopeAnswerable || !pages) return []
259+
return pages.flatMap((page) => page.data)
260+
}
261+
243262
export function AuditLogs({ organizationId }: AuditLogsProps) {
244263
const [urlFilters, setUrlFilters] = useQueryStates(auditLogFilterParsers, auditLogFilterUrlKeys)
245264
const { types: selectedTypes } = urlFilters
@@ -314,6 +333,9 @@ export function AuditLogs({ organizationId }: AuditLogsProps) {
314333
*/
315334
const isWorkspaceScopeUnresolved =
316335
Boolean(workspaceScope) && !isWorkspaceScopePending && !scopedWorkspace
336+
337+
/** The feed can answer the scope the URL asks for — the gate on reading or exporting. */
338+
const isScopeAnswerable = !isWorkspaceScopePending && !isWorkspaceScopeUnresolved
317339
const {
318340
data,
319341
isLoading,
@@ -324,10 +346,10 @@ export function AuditLogs({ organizationId }: AuditLogsProps) {
324346
refetch,
325347
} = useAuditLogs(organizationId, filters, !isWorkspaceScopePending && !isWorkspaceScopeUnresolved)
326348

327-
const allEntries = useMemo(() => {
328-
if (!data?.pages) return []
329-
return data.pages.flatMap((page) => page.data)
330-
}, [data])
349+
const allEntries = useMemo(
350+
() => presentableAuditEntries(data?.pages, isScopeAnswerable),
351+
[data, isScopeAnswerable]
352+
)
331353

332354
const typeDisplayLabel =
333355
selectedTypes.length === 0
@@ -425,7 +447,13 @@ export function AuditLogs({ organizationId }: AuditLogsProps) {
425447
text: 'Export',
426448
icon: Download,
427449
onSelect: () => void handleExportCsv(),
428-
disabled: allEntries.length === 0 || isExporting || isPlaceholderData,
450+
/*
451+
`isScopeAnswerable` explicitly, not just via the empty `allEntries` it
452+
implies: the export is the action that leaves the building, so the
453+
condition that makes it safe belongs where it is read.
454+
*/
455+
disabled:
456+
!isScopeAnswerable || allEntries.length === 0 || isExporting || isPlaceholderData,
429457
},
430458
]}
431459
>

0 commit comments

Comments
 (0)