Skip to content

Commit 06f5e76

Browse files
committed
fix(provenance): close a repair race and stop memory double-reporting
The repair matched sidecars by the id its page captured, so a provenance-aware write committing between the snapshot and the delete had its fresh exact sidecar removed and its marker cleared behind it — a secret-bearing row left reading as legacy. The delete now re-checks status, which under READ COMMITTED re-evaluates against the writer's committed row so it no longer matches. Walk the candidate set by keyset over row_id. A page whose rows were all repaired concurrently clears nothing, and terminating on "cleared nothing" ended the walk with the rest of the backlog untouched. Memory reported unrecorded provenance twice, and counted records even when the surface was enforced — auditing a fail-open read that had actually failed closed.
1 parent 0ee8e08 commit 06f5e76

5 files changed

Lines changed: 244 additions & 22 deletions

File tree

apps/sim/app/api/memory/secret-provenance.test.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,19 @@
55
import { memorySecretProvenance } from '@sim/db/schema'
66
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
77
import { NextRequest } from 'next/server'
8-
import { beforeEach, describe, expect, it } from 'vitest'
8+
import { beforeEach, describe, expect, it, vi } from 'vitest'
9+
10+
const { mockIsEnforced, mockReport } = vi.hoisted(() => ({
11+
mockIsEnforced: vi.fn(() => false),
12+
mockReport: vi.fn(),
13+
}))
14+
15+
vi.mock('@/lib/execution/durable-secret-provenance-enforcement', () => ({
16+
DURABLE_SECRET_PROVENANCE_SURFACES: ['memory', 'table-row', 'knowledge'],
17+
isDurableSecretProvenanceEnforced: mockIsEnforced,
18+
reportUnrecordedDurableProvenance: mockReport,
19+
}))
20+
921
import { AuthType } from '@/lib/auth/hybrid'
1022
import {
1123
PRIVATE_SECRET_PROVENANCE_BUNDLE_V1,
@@ -47,6 +59,8 @@ function privateMemoryWrite(
4759
describe('memory write secret provenance', () => {
4860
beforeEach(() => {
4961
resetDbChainMock()
62+
mockReport.mockClear()
63+
mockIsEnforced.mockReturnValue(false)
5064
})
5165
it('classifies a headerless external write as exact-empty', () => {
5266
const request = new NextRequest('http://localhost/api/memory', { method: 'POST' })
@@ -267,4 +281,63 @@ describe('memory write secret provenance', () => {
267281
[RESOLVED_SECRET_PROVENANCE_FIELD]: { version: 1, complete: true, entries: [] },
268282
})
269283
})
284+
/**
285+
* One entry for the read, not one per record: the per-record import knows no workspace, so its
286+
* report can only ever be a log line, and passing the workspace down instead would write
287+
* thousands of audit rows for a single event.
288+
*/
289+
it('reports one aggregated entry for a read that proceeded unvouched', async () => {
290+
const request = new NextRequest('http://localhost/api/memory', {
291+
headers: {
292+
[PRIVATE_TOOL_METADATA_REQUEST_HEADER]: RESOLVED_SECRET_PROVENANCE_METADATA_V1,
293+
},
294+
})
295+
296+
await createMemoryResponse({
297+
request,
298+
authType: AuthType.INTERNAL_JWT,
299+
userId: 'user-1',
300+
workspaceId: 'workspace-1',
301+
body: { success: true },
302+
memories: [
303+
{ id: 'memory-1', data: 'value', secretProvenanceVersion: 1 },
304+
{ id: 'memory-2', data: 'value', secretProvenanceVersion: 1 },
305+
],
306+
})
307+
308+
expect(mockReport).toHaveBeenCalledTimes(1)
309+
expect(mockReport).toHaveBeenCalledWith(
310+
expect.objectContaining({
311+
surface: 'memory',
312+
cause: 'durable-provenance-unknown',
313+
affectedCount: 2,
314+
workspaceId: 'workspace-1',
315+
})
316+
)
317+
})
318+
319+
/**
320+
* Under enforcement the import fails the registry closed rather than proceeding, so there is no
321+
* fail-open read to record. Counting those records anyway would audit something that never
322+
* happened, in the one trail whose whole purpose is to say a read went ahead unvouched.
323+
*/
324+
it('records nothing when the surface is enforced and the read fails closed', async () => {
325+
mockIsEnforced.mockReturnValue(true)
326+
const request = new NextRequest('http://localhost/api/memory', {
327+
headers: {
328+
[PRIVATE_TOOL_METADATA_REQUEST_HEADER]: RESOLVED_SECRET_PROVENANCE_METADATA_V1,
329+
},
330+
})
331+
332+
await createMemoryResponse({
333+
request,
334+
authType: AuthType.INTERNAL_JWT,
335+
userId: 'user-1',
336+
workspaceId: 'workspace-1',
337+
body: { success: true },
338+
memories: [{ id: 'memory-1', data: 'value', secretProvenanceVersion: 1 }],
339+
})
340+
341+
expect(mockReport).not.toHaveBeenCalled()
342+
})
270343
})

apps/sim/app/api/memory/secret-provenance.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import {
99
EXACT_EMPTY_DURABLE_SECRET_PROVENANCE,
1010
importDurableSecretProvenance,
1111
} from '@/lib/execution/durable-secret-provenance'
12-
import { reportUnrecordedDurableProvenance } from '@/lib/execution/durable-secret-provenance-enforcement'
12+
import {
13+
isDurableSecretProvenanceEnforced,
14+
reportUnrecordedDurableProvenance,
15+
} from '@/lib/execution/durable-secret-provenance-enforcement'
1316
import {
1417
inspectPrivateSecretProvenanceRequest,
1518
isPrivateSecretProvenanceBundleV1,
@@ -121,6 +124,12 @@ export async function createMemoryResponse(options: {
121124
matching.push(memory)
122125
memoriesById.set(memory.id, matching)
123126
}
127+
/**
128+
* Counted only while the surface is open. Under enforcement the import fails the registry closed
129+
* instead of proceeding, so counting those records would audit a fail-open read that never
130+
* happened — and this entry exists precisely to say a read went ahead unvouched.
131+
*/
132+
const memoryEnforced = isDurableSecretProvenanceEnforced('memory')
124133
let unrecordedMemoryCount = 0
125134
for (let index = 0; index < ids.length; index += PRIVATE_MEMORY_QUERY_CHUNK_SIZE) {
126135
const pageIds = ids.slice(index, index + PRIVATE_MEMORY_QUERY_CHUNK_SIZE)
@@ -139,8 +148,10 @@ export async function createMemoryResponse(options: {
139148
status: sidecar?.status ?? null,
140149
entries: sidecar?.entries,
141150
})
142-
if (provenance.status === 'unknown') unrecordedMemoryCount += 1
143-
await importDurableSecretProvenance(registry, provenance, record.data, 'memory')
151+
if (provenance.status === 'unknown' && !memoryEnforced) unrecordedMemoryCount += 1
152+
await importDurableSecretProvenance(registry, provenance, record.data, 'memory', {
153+
reportUnrecorded: false,
154+
})
144155
}
145156
}
146157
}

apps/sim/lib/execution/durable-secret-provenance.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,11 +209,22 @@ export async function importDurableSecretProvenance(
209209
registry: ResolvedSecretTraceRegistry,
210210
provenance: DurableSecretProvenance,
211211
value?: unknown,
212-
surface?: DurableSecretProvenanceSurface
212+
surface?: DurableSecretProvenanceSurface,
213+
/**
214+
* Set by a caller that reports the whole read itself.
215+
*
216+
* This function sees one record and knows no workspace, so its report can only ever be a log
217+
* line, one per record. A caller reading a page can say the same thing once, with the workspace
218+
* and the count — which is the entry that reaches the people who own the secrets. Both reporting
219+
* would double-count the same event at two different granularities.
220+
*/
221+
options: { reportUnrecorded?: boolean } = {}
213222
): Promise<boolean> {
214223
if (provenance.status === 'unknown') {
215224
if (surface && !isDurableSecretProvenanceEnforced(surface)) {
216-
reportUnrecordedDurableProvenance({ surface, cause: 'durable-provenance-unknown' })
225+
if (options.reportUnrecorded !== false) {
226+
reportUnrecordedDurableProvenance({ surface, cause: 'durable-provenance-unknown' })
227+
}
217228
return true
218229
}
219230
registry.markIncomplete('durable-provenance-unknown')
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import type { Sql } from 'postgres'
5+
import { describe, expect, it, vi } from 'vitest'
6+
import { repairUnknownTableRowProvenance } from './0005_repair_unknown_table_row_provenance'
7+
8+
interface PageResult {
9+
candidates: number
10+
repaired: number
11+
lastRowId: string | null
12+
}
13+
14+
function normalizeSql(value: string): string {
15+
return value.replace(/\s+/g, ' ').trim()
16+
}
17+
18+
/** Replays a scripted sequence of pages and records the `afterRowId` each pass asked for. */
19+
function createSqlHarness(pages: PageResult[]): {
20+
sql: Sql
21+
cursors: unknown[]
22+
statements: string[]
23+
} {
24+
const cursors: unknown[] = []
25+
const statements: string[] = []
26+
let call = 0
27+
const query = vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => {
28+
statements.push(normalizeSql(strings.join('?')))
29+
/** `afterRowId` is interpolated before the page size, so it is the first bound value. */
30+
cursors.push(values[0])
31+
const page = pages[call] ?? { candidates: 0, repaired: 0, lastRowId: null }
32+
call += 1
33+
return Promise.resolve([page])
34+
})
35+
return { sql: query as unknown as Sql, cursors, statements }
36+
}
37+
38+
describe('0005 repair unknown table row provenance', () => {
39+
/**
40+
* A provenance-aware write commits its exact sidecar between this statement's snapshot and its
41+
* delete. Matching on the captured id alone would drop that fresh sidecar and clear the marker
42+
* behind it, leaving a secret-bearing row reading as legacy — provenance destroyed by the repair
43+
* meant to make provenance safe. The re-check is what makes the writer's row stop matching.
44+
*/
45+
it('only deletes sidecars still reading unknown', async () => {
46+
const { sql, statements } = createSqlHarness([
47+
{ candidates: 1, repaired: 1, lastRowId: 'row-1' },
48+
{ candidates: 0, repaired: 0, lastRowId: null },
49+
])
50+
51+
await repairUnknownTableRowProvenance.up(sql)
52+
53+
expect(statements[0]).toContain('DELETE FROM user_table_row_secret_provenance')
54+
expect(statements[0]).toContain("AND status = 'unknown'")
55+
})
56+
57+
/**
58+
* A page whose rows were all repaired by a concurrent writer clears nothing. Stopping there would
59+
* have ended the walk and left the rest of the backlog untouched.
60+
*/
61+
it('keeps walking past a page a concurrent writer already repaired', async () => {
62+
const { sql, cursors } = createSqlHarness([
63+
{ candidates: 2, repaired: 0, lastRowId: 'row-2' },
64+
{ candidates: 1, repaired: 1, lastRowId: 'row-9' },
65+
{ candidates: 0, repaired: 0, lastRowId: null },
66+
])
67+
68+
await repairUnknownTableRowProvenance.up(sql)
69+
70+
expect(cursors).toEqual(['', 'row-2', 'row-9'])
71+
})
72+
73+
it('stops on the first page with no candidates left', async () => {
74+
const { sql, cursors } = createSqlHarness([
75+
{ candidates: 1, repaired: 1, lastRowId: 'row-1' },
76+
{ candidates: 0, repaired: 0, lastRowId: null },
77+
])
78+
79+
await repairUnknownTableRowProvenance.up(sql)
80+
81+
expect(cursors).toEqual(['', 'row-1'])
82+
})
83+
})

packages/db/script-migrations/0005_repair_unknown_table_row_provenance.ts

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,34 +3,62 @@ import type { ScriptMigration } from './types'
33

44
export const UNKNOWN_PROVENANCE_REPAIR_BATCH_SIZE = 1000
55

6+
interface RepairPage {
7+
/** Rows still reading `unknown` when the page was selected; zero means the walk is done. */
8+
candidates: number
9+
/** Rows actually returned to untracked. Lower than `candidates` when a writer got there first. */
10+
repaired: number
11+
/** Highest `row_id` in the page, so the next pass resumes past it. */
12+
lastRowId: string | null
13+
}
14+
615
/**
7-
* Returns one page of `unknown` rows to the untracked state, and reports how many it cleared.
16+
* Returns one page of `unknown` rows to the untracked state.
17+
*
18+
* Both halves belong in one statement. Clearing the marker while a sidecar row survives beside it
19+
* is the state a derived table transformation reads as unknown, so a split repair would be undone
20+
* by the next column operation.
821
*
9-
* Both halves are required and belong in one statement. Clearing the marker while a sidecar row
10-
* survives beside it is the state a derived table transformation reads as unknown, so a split
11-
* repair would be undone by the next column operation.
22+
* The delete re-checks `status` rather than trusting the id the page captured. A provenance-aware
23+
* write commits its exact sidecar and its version marker together, and can land between this
24+
* statement's snapshot and its delete; matching on `row_id` alone would drop that fresh exact
25+
* sidecar and clear the marker behind it, leaving a genuinely secret-bearing row reading as
26+
* legacy — provenance destroyed by the repair meant to make provenance safe. Under READ COMMITTED
27+
* the delete re-evaluates its condition against the updated row, so the writer's row no longer
28+
* matches and is left alone; whichever of the two commits second sees the other's result.
1229
*
1330
* `secret_provenance_version` is not a column the demote trigger watches, so this leaves
1431
* `updated_at` alone and cannot disturb a concurrent write's sidecar binding.
1532
*/
16-
async function repairUnknownProvenancePage(sql: Sql, batchSize: number): Promise<number> {
17-
const repaired = await sql<{ id: string }[]>`
33+
async function repairUnknownProvenancePage(
34+
sql: Sql,
35+
batchSize: number,
36+
afterRowId: string
37+
): Promise<RepairPage> {
38+
const [page] = await sql<[RepairPage]>`
1839
WITH page AS (
1940
SELECT row_id
2041
FROM user_table_row_secret_provenance
21-
WHERE status = 'unknown'
42+
WHERE status = 'unknown' AND row_id > ${afterRowId}
43+
ORDER BY row_id
2244
LIMIT ${batchSize}
2345
), cleared AS (
2446
DELETE FROM user_table_row_secret_provenance
2547
WHERE row_id IN (SELECT row_id FROM page)
48+
AND status = 'unknown'
2649
RETURNING row_id
50+
), marked AS (
51+
UPDATE user_table_rows
52+
SET secret_provenance_version = NULL
53+
WHERE id IN (SELECT row_id FROM cleared)
54+
RETURNING id
2755
)
28-
UPDATE user_table_rows
29-
SET secret_provenance_version = NULL
30-
WHERE id IN (SELECT row_id FROM cleared)
31-
RETURNING id
56+
SELECT
57+
(SELECT count(*) FROM page)::int AS "candidates",
58+
(SELECT count(*) FROM marked)::int AS "repaired",
59+
(SELECT max(row_id) FROM page) AS "lastRowId"
3260
`
33-
return repaired.length
61+
return page
3462
}
3563

3664
/**
@@ -56,17 +84,33 @@ async function repairUnknownProvenancePage(sql: Sql, batchSize: number): Promise
5684
* Idempotent and resumable: a repaired row no longer has a sidecar, so it leaves the candidate set
5785
* and a re-run after a crash resumes on what remains. Rows that become unknown after this runs are
5886
* simply left for the writers now instrumented to report them.
87+
*
88+
* Walked by keyset over `row_id` rather than by re-selecting the head of the candidate set. A page
89+
* whose rows were all repaired by a concurrent writer clears nothing, and terminating on "cleared
90+
* nothing" would have ended the walk there and left the rest of the backlog untouched. Advancing
91+
* past the page instead makes each pass finite and the whole walk terminate on the only condition
92+
* that means finished: a page with no candidates left in it.
5993
*/
6094
export const repairUnknownTableRowProvenance: ScriptMigration = {
6195
name: '0005_repair_unknown_table_row_provenance',
6296
async up(sql: Sql): Promise<void> {
6397
let repaired = 0
98+
let skipped = 0
99+
let afterRowId = ''
64100
for (;;) {
65-
const page = await repairUnknownProvenancePage(sql, UNKNOWN_PROVENANCE_REPAIR_BATCH_SIZE)
66-
if (page === 0) break
67-
repaired += page
101+
const page = await repairUnknownProvenancePage(
102+
sql,
103+
UNKNOWN_PROVENANCE_REPAIR_BATCH_SIZE,
104+
afterRowId
105+
)
106+
if (page.candidates === 0 || page.lastRowId === null) break
107+
repaired += page.repaired
108+
skipped += page.candidates - page.repaired
109+
afterRowId = page.lastRowId
68110
console.log(` repaired ${repaired} unknown table row(s)`)
69111
}
70-
console.log(`Unknown table row provenance repair complete: ${repaired} row(s).`)
112+
console.log(
113+
`Unknown table row provenance repair complete: ${repaired} row(s) repaired, ${skipped} left to a concurrent writer.`
114+
)
71115
},
72116
}

0 commit comments

Comments
 (0)