Skip to content

Commit 297f796

Browse files
committed
fix(credentials): decide subject reuse under the lock on every keyless reconnect
A reconnect that kept its subject skipped the uniqueness check, but whether it kept the subject was decided from the secret read before provider verification. If another reconnect moved the credential to a new subject and a second credential claimed the old one meanwhile, the stale reconnect wrote the old subject back unchecked and two credentials held it. Every keyless reconnect now locks the subject it is writing, then the credential row (FOR UPDATE), re-reads the row's current subject, and runs the sibling check whenever the two differ, with the decision and the write in one transaction.
1 parent aefa2e4 commit 297f796

3 files changed

Lines changed: 96 additions & 29 deletions

File tree

‎apps/sim/lib/credentials/__integration__/workload-identity-subject-reuse.integration.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ const originalFetch = globalThis.fetch
3434

3535
/** Subjects the fixture service principal's federation policy pins. */
3636
const pinnedSubjects = new Set<string>()
37+
/** Runs once inside the next token exchange, while a save is still verifying. */
38+
let duringVerification: (() => Promise<void>) | undefined
3739

3840
function grant() {
3941
const issued = issueWorkloadIdentitySubject('databricks', owner, userId)
@@ -102,6 +104,9 @@ describe('reusing a keyless trust setup', () => {
102104
vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => {
103105
const target = typeof input === 'string' || input instanceof URL ? String(input) : input.url
104106
if (target === `https://${HOST}/oidc/v1/token`) {
107+
const interleaved = duringVerification
108+
duringVerification = undefined
109+
await interleaved?.()
105110
const subjectToken = new URLSearchParams(String(init?.body)).get('subject_token') ?? ''
106111
const subject = subjectToken.replace(/^token-for:/, '')
107112
return pinnedSubjects.has(subject)
@@ -157,6 +162,22 @@ describe('reusing a keyless trust setup', () => {
157162
expect((await reconnect(credentialId, keyless(setup))).success).toBe(true)
158163
})
159164

165+
it('re-checks a kept subject under the lock when the credential moved off it during verification', async () => {
166+
const original = grant()
167+
const mover = await create('Mover', keyless(original))
168+
const moverId = mover.credential?.id ?? ''
169+
const moved = grant()
170+
duringVerification = async () => {
171+
expect((await reconnect(moverId, keyless(moved))).success).toBe(true)
172+
expect((await create('Claimer', keyless(original))).success).toBe(true)
173+
}
174+
175+
const stale = await reconnect(moverId, keyless())
176+
177+
expect(duringVerification).toBeUndefined()
178+
expect(stale).toMatchObject({ success: false, errorCode: 'conflict' })
179+
})
180+
160181
it('lets only one of two concurrent creates of one subject through', async () => {
161182
const setup = grant()
162183

‎apps/sim/lib/credentials/orchestration/index.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ import {
4848
} from '@/lib/credentials/service-account-secret'
4949
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
5050
import {
51-
assertWorkloadIdentitySubjectUnclaimed,
51+
assertReconnectedSubjectUnclaimed,
5252
WorkloadIdentitySubjectError,
5353
} from '@/lib/credentials/workload-identity/subject'
5454
import { invalidateEffectiveDecryptedEnvCache } from '@/lib/environment/utils'
@@ -316,7 +316,7 @@ export async function updateCredentialRecord(
316316

317317
let rotatedSlackBotUserId: string | undefined
318318
let rotatedAuditMetadata: Record<string, string> | undefined
319-
let rotatedWorkloadIdentitySubject: string | undefined
319+
let rotatedKeylessSecret: string | undefined
320320
if (hasRotationSecret) {
321321
const providerId = params.credential.providerId ?? ''
322322

@@ -458,7 +458,7 @@ export async function updateCredentialRecord(
458458
updates.encryptedServiceAccountKey = secret.encryptedServiceAccountKey
459459
rotatedSlackBotUserId = secret.botUserId
460460
rotatedAuditMetadata = secret.auditMetadata
461-
rotatedWorkloadIdentitySubject = secret.newWorkloadIdentitySubject
461+
if (needsStoredBoundSubject) rotatedKeylessSecret = secret.encryptedServiceAccountKey
462462

463463
if (needsStoredIdentity) {
464464
const previousIdentity = deriveStoredDisplayName(storedBlob)
@@ -521,12 +521,12 @@ export async function updateCredentialRecord(
521521
}
522522

523523
updates.updatedAt = new Date()
524-
if (rotatedWorkloadIdentitySubject) {
525-
const subject = rotatedWorkloadIdentitySubject
524+
if (rotatedKeylessSecret) {
525+
const rotatedSecret = rotatedKeylessSecret
526526
try {
527527
await db.transaction(async (tx) => {
528-
await assertWorkloadIdentitySubjectUnclaimed(tx, {
529-
subject,
528+
await assertReconnectedSubjectUnclaimed(tx, {
529+
rotatedSecret,
530530
scope: resourceScopeFromOwner(params.credential),
531531
credentialId: params.credentialId,
532532
})

‎apps/sim/lib/credentials/workload-identity/subject.ts‎

Lines changed: 68 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { credential } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { safeCompare } from '@sim/security/compare'
55
import { generateId } from '@sim/utils/id'
6-
import { and, inArray, isNull, ne } from 'drizzle-orm'
6+
import { and, eq, inArray, isNull, ne } from 'drizzle-orm'
77
import { z } from 'zod'
88
import { env } from '@/lib/core/config/env'
99
import type { ResourceScope } from '@/lib/core/resource-scope'
@@ -183,30 +183,19 @@ async function readStoredSubject(encrypted: string): Promise<string | undefined
183183
}
184184
}
185185

186-
/**
187-
* Refuses a subject that another live credential of the same owner already
188-
* holds. A proof stays valid for a day and is not tied to one credential, so
189-
* without this a member removed from a keyless credential could save a second
190-
* credential with the same subject and keep assuming the customer's role.
191-
*
192-
* The subject lives only inside each credential's encrypted secret, so this
193-
* decrypts the owner's keyless credentials (one owner holds few) rather than
194-
* relying on a unique index. It runs inside the transaction that writes the
195-
* credential: the advisory lock holds concurrent saves of one subject until
196-
* commit, so the later save sees the earlier row. A sibling whose secret cannot
197-
* be read might hold the subject, so it refuses the save rather than skip it.
198-
*
199-
* @param credentialId The credential being reconnected, which may keep its own subject.
200-
*/
201-
export async function assertWorkloadIdentitySubjectUnclaimed(
202-
tx: DbTransaction,
203-
input: { subject: string; scope: ResourceScope; credentialId?: string }
204-
): Promise<void> {
205-
await acquireAdvisoryXactLock(
186+
function lockSubject(tx: DbTransaction, subject: string): Promise<void> {
187+
return acquireAdvisoryXactLock(
206188
tx,
207189
'workload_identity_subject',
208-
`workload-identity-subject:${input.subject}`
190+
`workload-identity-subject:${subject}`
209191
)
192+
}
193+
194+
/** Throws unless no live keyless credential of the owner other than `credentialId` holds `subject`. */
195+
async function assertNoOtherCredentialHolds(
196+
tx: DbTransaction,
197+
input: { subject: string; scope: ResourceScope; credentialId?: string }
198+
): Promise<void> {
210199
const rows = await tx
211200
.select({
212201
id: credential.id,
@@ -240,3 +229,60 @@ export async function assertWorkloadIdentitySubjectUnclaimed(
240229
}
241230
}
242231
}
232+
233+
/**
234+
* Refuses a subject that another live credential of the same owner already
235+
* holds. A proof stays valid for a day and is not tied to one credential, so
236+
* without this a member removed from a keyless credential could save a second
237+
* credential with the same subject and keep assuming the customer's role.
238+
*
239+
* The subject lives only inside each credential's encrypted secret, so this
240+
* decrypts the owner's keyless credentials (one owner holds few) rather than
241+
* relying on a unique index. It runs inside the transaction that writes the
242+
* credential: the advisory lock holds concurrent saves of one subject until
243+
* commit, so the later save sees the earlier row. A sibling whose secret cannot
244+
* be read might hold the subject, so it refuses the save rather than skip it.
245+
*/
246+
export async function assertWorkloadIdentitySubjectUnclaimed(
247+
tx: DbTransaction,
248+
input: { subject: string; scope: ResourceScope }
249+
): Promise<void> {
250+
await lockSubject(tx, input.subject)
251+
await assertNoOtherCredentialHolds(tx, input)
252+
}
253+
254+
/**
255+
* The reconnect counterpart of {@link assertWorkloadIdentitySubjectUnclaimed},
256+
* run in the transaction that writes `rotatedSecret` onto `credentialId`.
257+
*
258+
* Whether the reconnect keeps the credential's subject is decided here, from
259+
* the row as it stands under the lock, not from the secret read before
260+
* provider verification: while that verification ran, another reconnect could
261+
* have moved this credential to a different subject and a second credential
262+
* could have claimed the old one, so writing it back unchecked would leave two
263+
* credentials holding it. Locks the subject first, then the credential row; no
264+
* path takes a subject lock while holding a credential row, so the order cannot
265+
* deadlock.
266+
*/
267+
export async function assertReconnectedSubjectUnclaimed(
268+
tx: DbTransaction,
269+
input: { rotatedSecret: string; scope: ResourceScope; credentialId: string }
270+
): Promise<void> {
271+
const subject = await readStoredSubject(input.rotatedSecret)
272+
if (!subject) return
273+
await lockSubject(tx, subject)
274+
const [current] = await tx
275+
.select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey })
276+
.from(credential)
277+
.where(eq(credential.id, input.credentialId))
278+
.for('update')
279+
const currentSubject = current?.encryptedServiceAccountKey
280+
? await readStoredSubject(current.encryptedServiceAccountKey)
281+
: undefined
282+
if (currentSubject === subject) return
283+
await assertNoOtherCredentialHolds(tx, {
284+
subject,
285+
scope: input.scope,
286+
credentialId: input.credentialId,
287+
})
288+
}

0 commit comments

Comments
 (0)