Skip to content

Commit 348caab

Browse files
authored
v0.7.60: tables, memory, sso improvements
2 parents 24114b2 + c4ccee0 commit 348caab

8 files changed

Lines changed: 718 additions & 83 deletions

File tree

apps/sim/app/api/auth/sso/providers/route.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,21 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1111

1212
const logger = createLogger('SSOProvidersRoute')
1313

14+
/** Secrets shorter than this reveal too large a fraction of themselves in 4 characters. */
15+
const MIN_LENGTH_FOR_HINT = 16
16+
17+
/**
18+
* Last four characters of a stored client secret, so an admin can tell *which*
19+
* secret is saved rather than only that one exists. Four characters of a
20+
* high-entropy secret is not a meaningful disclosure to an owner or admin, who
21+
* can rotate it anyway — but short secrets are left unhinted, where the same four
22+
* characters would be a large share of the value.
23+
*/
24+
function buildClientSecretHint(clientSecret: unknown): string | null {
25+
if (typeof clientSecret !== 'string' || clientSecret.length < MIN_LENGTH_FOR_HINT) return null
26+
return clientSecret.slice(-4)
27+
}
28+
1429
export const GET = withRouteHandler(async (request: NextRequest) => {
1530
try {
1631
const session = await getSession()
@@ -69,7 +84,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
6984
if (oidcConfig) {
7085
try {
7186
const parsed = JSON.parse(oidcConfig)
87+
const hint = buildClientSecretHint(parsed.clientSecret)
7288
parsed.clientSecret = REDACTED_MARKER
89+
if (hint) parsed.clientSecretHint = hint
7390
oidcConfig = JSON.stringify(parsed)
7491
} catch {
7592
oidcConfig = null
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { memory } from '@sim/db/schema'
6+
import {
7+
createMockRequest,
8+
hybridAuthMockFns,
9+
queueTableRows,
10+
resetDbChainMock,
11+
} from '@sim/testing'
12+
import { beforeEach, describe, expect, it, vi } from 'vitest'
13+
import { AuthType } from '@/lib/auth/hybrid'
14+
import {
15+
PRIVATE_TOOL_METADATA_REQUEST_HEADER,
16+
PRIVATE_TOOL_METADATA_RESPONSE_HEADER,
17+
RESOLVED_SECRET_PROVENANCE_FIELD,
18+
RESOLVED_SECRET_PROVENANCE_METADATA_V1,
19+
} from '@/lib/execution/private-tool-metadata'
20+
21+
const { mockCheckWorkspaceAccess } = vi.hoisted(() => ({
22+
mockCheckWorkspaceAccess: vi.fn(),
23+
}))
24+
25+
vi.mock('@/lib/workspaces/permissions/utils', () => ({
26+
checkWorkspaceAccess: mockCheckWorkspaceAccess,
27+
}))
28+
29+
import { GET } from '@/app/api/memory/[id]/route'
30+
31+
const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'
32+
const CONTEXT = { params: Promise.resolve({ id: 'missing-conversation' }) }
33+
34+
describe('GET /api/memory/[id]', () => {
35+
beforeEach(() => {
36+
vi.clearAllMocks()
37+
resetDbChainMock()
38+
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
39+
success: true,
40+
userId: 'user-1',
41+
authType: AuthType.INTERNAL_JWT,
42+
})
43+
mockCheckWorkspaceAccess.mockResolvedValue({ exists: true, hasAccess: true })
44+
queueTableRows(memory, [])
45+
})
46+
47+
it('returns verified exact-empty metadata when a tool lookup has no matching memory', async () => {
48+
const response = await GET(
49+
createMockRequest(
50+
'GET',
51+
undefined,
52+
{
53+
[PRIVATE_TOOL_METADATA_REQUEST_HEADER]: RESOLVED_SECRET_PROVENANCE_METADATA_V1,
54+
},
55+
`http://localhost:3000/api/memory/missing-conversation?workspaceId=${WORKSPACE_ID}`
56+
),
57+
CONTEXT
58+
)
59+
60+
expect(response.status).toBe(200)
61+
expect(response.headers.get(PRIVATE_TOOL_METADATA_RESPONSE_HEADER)).toBe(
62+
RESOLVED_SECRET_PROVENANCE_METADATA_V1
63+
)
64+
expect(await response.json()).toEqual({
65+
success: true,
66+
data: null,
67+
[RESOLVED_SECRET_PROVENANCE_FIELD]: {
68+
version: 1,
69+
complete: true,
70+
entries: [],
71+
scope: { userId: 'user-1', workspaceId: WORKSPACE_ID },
72+
},
73+
})
74+
})
75+
76+
it('preserves the existing headerless empty response for ordinary API callers', async () => {
77+
const response = await GET(
78+
createMockRequest(
79+
'GET',
80+
undefined,
81+
{},
82+
`http://localhost:3000/api/memory/missing-conversation?workspaceId=${WORKSPACE_ID}`
83+
),
84+
CONTEXT
85+
)
86+
87+
expect(response.status).toBe(200)
88+
expect(response.headers.get(PRIVATE_TOOL_METADATA_RESPONSE_HEADER)).toBeNull()
89+
expect(await response.json()).toEqual({ success: true, data: null })
90+
})
91+
})

apps/sim/app/api/memory/[id]/route.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,14 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Memory
9191
.limit(1)
9292

9393
if (memories.length === 0) {
94-
return NextResponse.json({ success: true, data: null }, { status: 200 })
94+
return createMemoryResponse({
95+
request,
96+
authType: accessCheck.authType,
97+
userId: accessCheck.userId,
98+
workspaceId: validatedWorkspaceId,
99+
body: { success: true, data: null },
100+
memories: [],
101+
})
95102
}
96103

97104
const mem = memories[0]
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest } from '@sim/testing'
5+
import { describe, expect, it } from 'vitest'
6+
import { AuthType } from '@/lib/auth/hybrid'
7+
import {
8+
PRIVATE_SECRET_PROVENANCE_BUNDLE_V1,
9+
PRIVATE_SECRET_PROVENANCE_FIELD,
10+
PRIVATE_SECRET_PROVENANCE_HEADER,
11+
} from '@/lib/execution/private-tool-metadata'
12+
import { rowDataNameToId } from '@/lib/table/column-keys'
13+
import { tableRowSecretProvenanceSelectionKey } from '@/lib/table/secret-provenance-selection'
14+
import type { RowData } from '@/lib/table/types'
15+
import {
16+
createTableWriteProvenanceTargets,
17+
resolveTableWriteSecretProvenance,
18+
} from '@/app/api/table/row-secret-provenance'
19+
20+
const USER_ID = 'user-1'
21+
const WORKSPACE_ID = 'ws-1'
22+
23+
/** Mirrors the internal-JWT wire translator: names → ids, unknown names dropped. */
24+
const ID_BY_NAME = new Map([
25+
['email', 'col_email'],
26+
['company', 'col_company'],
27+
])
28+
29+
const translateNames = (data: RowData): RowData => rowDataNameToId(data, ID_BY_NAME)
30+
const translateIdentity = (data: RowData): RowData => data
31+
32+
function traceProvenance() {
33+
return {
34+
version: 1,
35+
complete: true,
36+
entries: [],
37+
scope: { userId: USER_ID, workspaceId: WORKSPACE_ID },
38+
}
39+
}
40+
41+
function bundleRequest(selectionKeys: string[]) {
42+
const payload = {
43+
[PRIVATE_SECRET_PROVENANCE_FIELD]: {
44+
version: 1,
45+
complete: true,
46+
selections: selectionKeys.map((key) => ({ key, provenance: traceProvenance() })),
47+
},
48+
}
49+
const request = createMockRequest('POST', payload, {
50+
[PRIVATE_SECRET_PROVENANCE_HEADER]: PRIVATE_SECRET_PROVENANCE_BUNDLE_V1,
51+
})
52+
return { request, payload }
53+
}
54+
55+
describe('createTableWriteProvenanceTargets', () => {
56+
it('maps column names to their storage ids', () => {
57+
const targets = createTableWriteProvenanceTargets([{ email: 'a@b.c' }], translateNames)
58+
59+
expect(targets).toEqual([
60+
{
61+
selectionKey: tableRowSecretProvenanceSelectionKey(0, 'email'),
62+
rowKey: '0',
63+
columnId: 'col_email',
64+
},
65+
])
66+
})
67+
68+
it('returns a null column id for a column the wire translator drops', () => {
69+
const targets = createTableWriteProvenanceTargets(
70+
[{ email: 'a@b.c', notAColumn: 'x' }],
71+
translateNames
72+
)
73+
74+
expect(targets).toHaveLength(2)
75+
expect(targets[0].columnId).toBe('col_email')
76+
expect(targets[1]).toEqual({
77+
selectionKey: tableRowSecretProvenanceSelectionKey(0, 'notAColumn'),
78+
rowKey: '0',
79+
columnId: null,
80+
})
81+
})
82+
83+
it('keeps one target per submitted column so bundle selections stay paired', () => {
84+
const targets = createTableWriteProvenanceTargets(
85+
[{ notAColumn: 'x', alsoNotAColumn: 'y' }],
86+
translateNames
87+
)
88+
89+
expect(targets.map((target) => target.columnId)).toEqual([null, null])
90+
})
91+
92+
it('passes column ids through for identity (session) translation', () => {
93+
const targets = createTableWriteProvenanceTargets([{ col_email: 'a@b.c' }], translateIdentity)
94+
95+
expect(targets[0].columnId).toBe('col_email')
96+
})
97+
98+
it('keys targets by row index across multiple rows', () => {
99+
const targets = createTableWriteProvenanceTargets(
100+
[{ email: 'a@b.c' }, { company: 'Acme' }],
101+
translateNames
102+
)
103+
104+
expect(targets.map((target) => target.rowKey)).toEqual(['0', '1'])
105+
expect(targets[1].selectionKey).toBe(tableRowSecretProvenanceSelectionKey(1, 'company'))
106+
})
107+
})
108+
109+
describe('resolveTableWriteSecretProvenance', () => {
110+
it('records no provenance for a dropped column on an unsupported session write', () => {
111+
const rows = [{ email: 'a@b.c', notAColumn: 'x' }]
112+
const result = resolveTableWriteSecretProvenance({
113+
request: createMockRequest('POST', { rows }),
114+
payload: { rows },
115+
authType: AuthType.SESSION,
116+
userId: USER_ID,
117+
workspaceId: WORKSPACE_ID,
118+
targets: createTableWriteProvenanceTargets(rows, translateNames),
119+
rowKeys: ['0'],
120+
})
121+
122+
expect(result.success).toBe(true)
123+
if (!result.success) return
124+
expect(Object.keys(result.provenanceByRowKey?.['0'].columns ?? {})).toEqual(['col_email'])
125+
})
126+
127+
it('accepts a complete bundle that covers a dropped column', () => {
128+
const rows = [{ email: 'a@b.c', notAColumn: 'x' }]
129+
const { request, payload } = bundleRequest([
130+
tableRowSecretProvenanceSelectionKey(0, 'email'),
131+
tableRowSecretProvenanceSelectionKey(0, 'notAColumn'),
132+
])
133+
134+
const result = resolveTableWriteSecretProvenance({
135+
request,
136+
payload,
137+
authType: AuthType.INTERNAL_JWT,
138+
userId: USER_ID,
139+
workspaceId: WORKSPACE_ID,
140+
targets: createTableWriteProvenanceTargets(rows, translateNames),
141+
rowKeys: ['0'],
142+
})
143+
144+
expect(result.success).toBe(true)
145+
if (!result.success) return
146+
expect(Object.keys(result.provenanceByRowKey?.['0'].columns ?? {})).toEqual(['col_email'])
147+
})
148+
149+
it('stores provenance for a fully translatable bundle', () => {
150+
const rows = [{ email: 'a@b.c', company: 'Acme' }]
151+
const { request, payload } = bundleRequest([
152+
tableRowSecretProvenanceSelectionKey(0, 'email'),
153+
tableRowSecretProvenanceSelectionKey(0, 'company'),
154+
])
155+
156+
const result = resolveTableWriteSecretProvenance({
157+
request,
158+
payload,
159+
authType: AuthType.INTERNAL_JWT,
160+
userId: USER_ID,
161+
workspaceId: WORKSPACE_ID,
162+
targets: createTableWriteProvenanceTargets(rows, translateNames),
163+
rowKeys: ['0'],
164+
})
165+
166+
expect(result.success).toBe(true)
167+
if (!result.success) return
168+
expect(Object.keys(result.provenanceByRowKey?.['0'].columns ?? {}).sort()).toEqual([
169+
'col_company',
170+
'col_email',
171+
])
172+
})
173+
174+
it('rejects a bundle whose selection matches no submitted column', () => {
175+
const rows = [{ email: 'a@b.c' }]
176+
const { request, payload } = bundleRequest([tableRowSecretProvenanceSelectionKey(0, 'company')])
177+
178+
const result = resolveTableWriteSecretProvenance({
179+
request,
180+
payload,
181+
authType: AuthType.INTERNAL_JWT,
182+
userId: USER_ID,
183+
workspaceId: WORKSPACE_ID,
184+
targets: createTableWriteProvenanceTargets(rows, translateNames),
185+
rowKeys: ['0'],
186+
})
187+
188+
expect(result.success).toBe(false)
189+
})
190+
191+
it('rejects a bundle whose selection scope does not match the caller', () => {
192+
const rows = [{ email: 'a@b.c' }]
193+
const payload = {
194+
[PRIVATE_SECRET_PROVENANCE_FIELD]: {
195+
version: 1,
196+
complete: true,
197+
selections: [
198+
{
199+
key: tableRowSecretProvenanceSelectionKey(0, 'email'),
200+
provenance: {
201+
...traceProvenance(),
202+
scope: { userId: 'someone-else', workspaceId: WORKSPACE_ID },
203+
},
204+
},
205+
],
206+
},
207+
}
208+
209+
const result = resolveTableWriteSecretProvenance({
210+
request: createMockRequest('POST', payload, {
211+
[PRIVATE_SECRET_PROVENANCE_HEADER]: PRIVATE_SECRET_PROVENANCE_BUNDLE_V1,
212+
}),
213+
payload,
214+
authType: AuthType.INTERNAL_JWT,
215+
userId: USER_ID,
216+
workspaceId: WORKSPACE_ID,
217+
targets: createTableWriteProvenanceTargets(rows, translateNames),
218+
rowKeys: ['0'],
219+
})
220+
221+
expect(result.success).toBe(false)
222+
})
223+
})

0 commit comments

Comments
 (0)