Skip to content

Commit 56f7ed7

Browse files
authored
refactor(credentials): resolve Vanta tokens through the coordinated client-token resolver (#9919)
* refactor(credentials): resolve Vanta tokens through the coordinated client-token resolver vanta-token.ts carried a second copy of the lock-and-memo protocol in coordinated-client-token.ts. Vanta now calls resolveCoordinatedClientToken and invalidateCoordinatedClientToken through four optional inputs whose defaults leave Shopify, Atlassian, and Linear rows unchanged: - minRemainingMs: Vanta keeps its 30-second reuse margin - failureKeyQualifier: Vanta's failure memo stays keyed by secret and scope - accessTokenDigestTag: Vanta's token digest stays keyed by API domain - pinnedState: Vanta's scope is stored with the token and checked against every stored record, expired ones included, under the lock Row ids, the advisory lock name and key, token digests, and encrypted payloads are unchanged, so old and new workers read each other's rows and contend on the same lock during a rollout. Unit tests pin those formats for Vanta and for the shared defaults; they pass against the previous code too. * chore(credentials): keep the expired-failure sweep private to the coordinated resolver * test(credentials): prove rolling-deploy token compatibility against real Postgres Replace the mock-based unit tests for the coordinated resolver and the Vanta token chain with integration tests that seed rows exactly as the previous build wrote them (inline sha256 ids and digests, real encryptSecret) and read back what the new resolver writes. They cover a fresh stored token, a remembered refusal, the expired-row scope pin, mint and refusal payloads at the old ids, invalidation by the old digest, the old build's advisory lock blocking a new resolution, and the default Shopify-style formats.
1 parent d26b61f commit 56f7ed7

4 files changed

Lines changed: 324 additions & 173 deletions

File tree

‎apps/sim/lib/credentials/client-credential-accounts/vanta-token.integration.ts‎

Lines changed: 182 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { createHash } from 'node:crypto'
22
import { createServer, type Server } from 'node:http'
33
import { db } from '@sim/db'
44
import { clientCredentialToken } from '@sim/db/schema'
5+
import { createDeferred } from '@sim/testing/helpers/deferred'
56
import { sleep } from '@sim/utils/helpers'
67
import { generateId } from '@sim/utils/id'
7-
import { eq, like, or } from 'drizzle-orm'
8+
import { eq, like, or, sql } from 'drizzle-orm'
89
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
10+
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
911
import {
1012
invalidateVantaClientToken,
1113
resolveVantaClientToken,
@@ -34,6 +36,67 @@ function fields(suffix: string) {
3436
return { clientId, clientSecret: `secret-${clientId}`, scope: READ }
3537
}
3638

39+
function sha256Json(value: unknown): string {
40+
return createHash('sha256').update(JSON.stringify(value)).digest('hex')
41+
}
42+
43+
/** Row ids and digests exactly as the build before the shared resolver derived them. */
44+
function previousBuildKeys(input: { clientId: string; clientSecret: string }, scope: string) {
45+
const apiDomain = 'https://api.vanta.com'
46+
const applicationKey = `vanta:${sha256Json([apiDomain, input.clientId])}`
47+
const secretFingerprint = sha256Json([apiDomain, input.clientId, input.clientSecret])
48+
return {
49+
applicationKey,
50+
secretFingerprint,
51+
failureKey: `${applicationKey}:${sha256Json([secretFingerprint, scope])}`,
52+
accessTokenDigest: (accessToken: string) => sha256Json([apiDomain, accessToken]),
53+
}
54+
}
55+
56+
/** Writes an application token row the way the previous build's mint did. */
57+
async function storePreviousBuildToken(
58+
keys: ReturnType<typeof previousBuildKeys>,
59+
accessToken: string,
60+
scope: string,
61+
expiresInMs: number,
62+
client: Pick<typeof db, 'insert'> = db
63+
) {
64+
const { encrypted } = await encryptSecret(
65+
JSON.stringify({ accessToken, secretFingerprint: keys.secretFingerprint, scope })
66+
)
67+
await client.insert(clientCredentialToken).values({
68+
id: keys.applicationKey,
69+
encryptedValue: encrypted,
70+
accessTokenDigest: keys.accessTokenDigest(accessToken),
71+
expiresAt: new Date(Date.now() + expiresInMs),
72+
})
73+
}
74+
75+
async function readStoredRow(id: string) {
76+
const [row] = await db
77+
.select()
78+
.from(clientCredentialToken)
79+
.where(eq(clientCredentialToken.id, id))
80+
.limit(1)
81+
if (!row) throw new Error('Expected a stored row')
82+
return { ...row, decrypted: (await decryptSecret(row.encryptedValue)).decrypted }
83+
}
84+
85+
/** Resolves once a session is blocked on the advisory lock for `key`; fails if `pending` settles first. */
86+
async function lockWaiterAppears(key: string, pending: { settled: boolean }): Promise<void> {
87+
for (;;) {
88+
const waiting = await db.execute(sql`
89+
WITH k AS (SELECT hashtextextended(${key}, 0) AS h)
90+
SELECT 1 FROM pg_locks, k
91+
WHERE locktype = 'advisory' AND NOT granted AND objsubid = 1
92+
AND classid::bigint = ((k.h >> 32) & 4294967295)
93+
AND objid::bigint = (k.h & 4294967295)`)
94+
if (waiting.length > 0) return
95+
if (pending.settled) throw new Error('Resolution finished without waiting on the lock')
96+
await sleep(10)
97+
}
98+
}
99+
37100
async function isActive(token: string): Promise<boolean> {
38101
return (
39102
await originalFetch(`${providerUrl}/verify`, {
@@ -114,10 +177,12 @@ afterAll(async () => {
114177

115178
describe('Vanta application token lifecycle against shared storage', () => {
116179
it('shares one active token across simultaneous resolutions for an application', async () => {
180+
const count = tokenRequests
117181
const results = await Promise.all(
118182
Array.from({ length: 12 }, () => resolveVantaClientToken(fields('concurrent')))
119183
)
120184
expect(new Set(results.map((result) => result.accessToken)).size).toBe(1)
185+
expect(tokenRequests).toBe(count + 1)
121186
expect(await isActive(results[0].accessToken)).toBe(true)
122187
expect((await resolveVantaClientToken(fields('concurrent'))).accessToken).toBe(
123188
results[0].accessToken
@@ -265,3 +330,119 @@ describe('Vanta application token lifecycle against shared storage', () => {
265330
expect(tokenRequests).toBe(count)
266331
})
267332
})
333+
334+
describe('Vanta token rows shared with the previous build during a rolling deploy', () => {
335+
it('serves a fresh token the previous build stored down to the 30-second margin without calling Vanta', async () => {
336+
const input = fields('legacy-fresh')
337+
await storePreviousBuildToken(
338+
previousBuildKeys(input, READ),
339+
'legacy-fresh-token',
340+
READ,
341+
60_000
342+
)
343+
const count = tokenRequests
344+
345+
await expect(resolveVantaClientToken(input)).resolves.toMatchObject({
346+
accessToken: 'legacy-fresh-token',
347+
apiDomain: 'https://api.vanta.com',
348+
})
349+
expect(tokenRequests).toBe(count)
350+
})
351+
352+
it('honors a refusal the previous build remembered without calling Vanta', async () => {
353+
const input = fields('legacy-failure')
354+
const { encrypted } = await encryptSecret(
355+
JSON.stringify({ code: 'invalid_credentials', status: 401 })
356+
)
357+
await db.insert(clientCredentialToken).values({
358+
id: previousBuildKeys(input, READ).failureKey,
359+
encryptedValue: encrypted,
360+
expiresAt: new Date(Date.now() + 20_000),
361+
})
362+
const count = tokenRequests
363+
364+
await expect(resolveVantaClientToken(input)).rejects.toMatchObject({
365+
code: 'invalid_credentials',
366+
status: 401,
367+
})
368+
expect(tokenRequests).toBe(count)
369+
})
370+
371+
it('refuses other permissions for an expired application the previous build pinned', async () => {
372+
const input = fields('legacy-expired-scope')
373+
await storePreviousBuildToken(previousBuildKeys(input, READ), 'legacy-expired', READ, -1000)
374+
const count = tokenRequests
375+
376+
await expect(resolveVantaClientToken({ ...input, scope: WRITE })).rejects.toMatchObject({
377+
code: 'permission_conflict',
378+
})
379+
expect(tokenRequests).toBe(count)
380+
})
381+
382+
it('stores a minted token where and how the previous build reads it', async () => {
383+
const input = fields('legacy-readable-mint')
384+
const keys = previousBuildKeys(input, READ)
385+
386+
const { accessToken } = await resolveVantaClientToken(input)
387+
388+
const row = await readStoredRow(keys.applicationKey)
389+
expect(row.decrypted).toBe(
390+
JSON.stringify({ accessToken, secretFingerprint: keys.secretFingerprint, scope: READ })
391+
)
392+
expect(row.accessTokenDigest).toBe(keys.accessTokenDigest(accessToken))
393+
})
394+
395+
it('stores a refusal where and how the previous build reads it', async () => {
396+
const input = fields('legacy-readable-failure')
397+
const keys = previousBuildKeys({ ...input, clientSecret: 'revoked-secret' }, READ)
398+
399+
await expect(
400+
resolveVantaClientToken({ ...input, clientSecret: 'revoked-secret' })
401+
).rejects.toMatchObject({ code: 'invalid_credentials' })
402+
403+
const row = await readStoredRow(keys.failureKey)
404+
expect(row.decrypted).toBe(JSON.stringify({ code: 'invalid_credentials', status: 401 }))
405+
expect(row.accessTokenDigest).toBeNull()
406+
})
407+
408+
it('expires a token the previous build stored when Vanta rejects it', async () => {
409+
const input = fields('legacy-invalidated')
410+
const keys = previousBuildKeys(input, READ)
411+
await storePreviousBuildToken(keys, 'legacy-rejected-token', READ, 3_600_000)
412+
413+
await invalidateVantaClientToken('legacy-rejected-token', 'https://api.vanta.com')
414+
415+
expect((await readStoredRow(keys.applicationKey)).expiresAt.getTime()).toBe(0)
416+
})
417+
418+
it('waits on the application lock the previous build holds and serves the token it minted', async () => {
419+
const input = fields('legacy-lock')
420+
const keys = previousBuildKeys(input, READ)
421+
const locked = createDeferred<void>()
422+
const commit = createDeferred<void>()
423+
const previousBuild = db.transaction(async (tx) => {
424+
await tx.execute(
425+
sql`SELECT pg_advisory_xact_lock(hashtextextended(${keys.applicationKey}, 0))`
426+
)
427+
locked.resolve()
428+
await commit.promise
429+
await storePreviousBuildToken(keys, 'legacy-locked-token', READ, 3_600_000, tx)
430+
})
431+
await locked.promise
432+
const count = tokenRequests
433+
const pending = { settled: false }
434+
const resolution = resolveVantaClientToken(input).finally(() => {
435+
pending.settled = true
436+
})
437+
438+
try {
439+
await lockWaiterAppears(keys.applicationKey, pending)
440+
} finally {
441+
commit.resolve()
442+
await previousBuild
443+
}
444+
445+
await expect(resolution).resolves.toMatchObject({ accessToken: 'legacy-locked-token' })
446+
expect(tokenRequests).toBe(count)
447+
})
448+
})

0 commit comments

Comments
 (0)