@@ -3,7 +3,7 @@ import { credential } from '@sim/db/schema'
33import { createLogger } from '@sim/logger'
44import { safeCompare } from '@sim/security/compare'
55import { generateId } from '@sim/utils/id'
6- import { and , inArray , isNull , ne } from 'drizzle-orm'
6+ import { and , eq , inArray , isNull , ne } from 'drizzle-orm'
77import { z } from 'zod'
88import { env } from '@/lib/core/config/env'
99import 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