Skip to content

Commit eb276fb

Browse files
committed
fix(credentials): never name a sibling credential in a trust-setup refusal
The caller may not be able to see the credential that holds or blocks the subject, so both refusals name only the workspace or organization. The uniqueness check documents its limits.
1 parent e1b532e commit eb276fb

2 files changed

Lines changed: 16 additions & 4 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,9 @@ describe('reusing a keyless trust setup', () => {
234234
const result = await create(`Blocked by ${unreadableId}`, keyless(grant()))
235235

236236
expect(result).toMatchObject({ success: false, errorCode: 'conflict' })
237-
expect(result.error).toContain('Unreadable sibling')
237+
// The caller may not be able to see the sibling, so the refusal never names it.
238+
expect(result.error).not.toContain('Unreadable sibling')
239+
expect(result.error).toContain('this workspace')
238240
} finally {
239241
await db.delete(credential).where(eq(credential.id, unreadableId))
240242
}

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,11 @@ function lockSubject(tx: DbTransaction, subject: string): Promise<void> {
205205
)
206206
}
207207

208+
/** Names the owner without naming a credential, which the caller may not be able to see. */
209+
function scopeNoun(scope: ResourceScope): string {
210+
return scope.kind === 'workspace' ? 'this workspace' : 'this organization'
211+
}
212+
208213
/** Throws unless no live keyless credential of the owner other than `credentialId` holds `subject`. */
209214
async function assertNoOtherCredentialHolds(
210215
tx: DbTransaction,
@@ -213,7 +218,6 @@ async function assertNoOtherCredentialHolds(
213218
const rows = await tx
214219
.select({
215220
id: credential.id,
216-
displayName: credential.displayName,
217221
encryptedServiceAccountKey: credential.encryptedServiceAccountKey,
218222
})
219223
.from(credential)
@@ -233,12 +237,12 @@ async function assertNoOtherCredentialHolds(
233237
credentialId: row.id,
234238
})
235239
throw new WorkloadIdentitySubjectError(
236-
`Sim could not read the saved credential "${row.displayName}", so it cannot confirm this trust setup is unused. Reconnect or delete that credential, then try again.`
240+
`Sim could not read another keyless credential in ${scopeNoun(input.scope)}, so it cannot confirm this trust setup is unused. Ask an admin to reconnect or delete the broken keyless credential, then try again.`
237241
)
238242
}
239243
if (storedSubject === input.subject) {
240244
throw new WorkloadIdentitySubjectError(
241-
'Another credential already uses this trust setup. Start the keyless connection again.'
245+
`Another credential in ${scopeNoun(input.scope)} already uses this trust setup. Start the keyless connection again.`
242246
)
243247
}
244248
}
@@ -256,6 +260,12 @@ async function assertNoOtherCredentialHolds(
256260
* credential: the advisory lock holds concurrent saves of one subject until
257261
* commit, so the later save sees the earlier row. A sibling whose secret cannot
258262
* be read might hold the subject, so it refuses the save rather than skip it.
263+
*
264+
* Caveats: uniqueness holds only within one owner (a subject embeds its owner,
265+
* so another owner's credential cannot hold it), only among live AWS and
266+
* Databricks keyless credentials, and only for writers that call this inside
267+
* their write transaction. The refusal never names the sibling, which the
268+
* caller may not be able to see.
259269
*/
260270
export async function assertWorkloadIdentitySubjectUnclaimed(
261271
tx: DbTransaction,

0 commit comments

Comments
 (0)