Skip to content

Commit 15b2af7

Browse files
committed
fix(credentials): refuse keyless AWS and Databricks create when keyless isn't configured
1 parent 720dc4f commit 15b2af7

11 files changed

Lines changed: 118 additions & 50 deletions

‎apps/sim/lib/credentials/aws-service-account.server.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const hoisted = vi.hoisted(() => ({ send: vi.fn() }))
99

1010
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
1111
vi.mock('@/lib/credentials/workload-identity/issuer', () => ({
12+
isWorkloadIdentityConfigured: async () => true,
1213
mintWorkloadIdentityToken: async ({ subject }: { subject: string }) => `token-for:${subject}`,
1314
}))
1415
vi.mock('@aws-sdk/client-sts', async (importActual) => ({
@@ -87,6 +88,20 @@ describe('saving a keyless AWS role', () => {
8788
expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled()
8889
})
8990

91+
it('reads a provider message that ends in a period as one sentence', async () => {
92+
hoisted.send.mockRejectedValue(
93+
Object.assign(new Error('Not authorized to perform sts:AssumeRoleWithWebIdentity.'), {
94+
name: 'AccessDenied',
95+
})
96+
)
97+
98+
await expect(
99+
verifyAndEncryptAwsCredential(keylessReconnect.serialized, keylessReconnect.owner)
100+
).rejects.toThrow(
101+
'Sim could not assume this role: Not authorized to perform sts:AssumeRoleWithWebIdentity. Check that'
102+
)
103+
})
104+
90105
it('does not save when the any-subject check cannot be completed', async () => {
91106
roleTrusting(true)
92107
const pinned = hoisted.send.getMockImplementation()

‎apps/sim/lib/credentials/aws-service-account.server.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,22 @@ import {
77
} from '@aws-sdk/client-sts'
88
import { db } from '@sim/db'
99
import { credential } from '@sim/db/schema'
10-
import { getErrorMessage } from '@sim/utils/errors'
1110
import { and, eq, isNull, or } from 'drizzle-orm'
1211
import { z } from 'zod'
1312
import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server'
1413
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
15-
import { parseServiceAccountConfig } from '@/lib/credentials/service-account-config'
14+
import { failureClause, parseServiceAccountConfig } from '@/lib/credentials/service-account-config'
1615
import { WORKLOAD_IDENTITY_AUDIENCES } from '@/lib/credentials/workload-identity/audiences'
1716
import {
17+
isWorkloadIdentityConfigured,
1818
mintWorkloadIdentityToken,
1919
type WorkloadIdentityOwner,
2020
} from '@/lib/credentials/workload-identity/issuer'
2121
import {
2222
bindWorkloadIdentitySubject,
2323
newWorkloadIdentitySubject,
2424
type WorkloadIdentityCredentialOwner,
25+
WorkloadIdentityNotConfiguredError,
2526
} from '@/lib/credentials/workload-identity/subject'
2627
import { AWS_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
2728

@@ -236,7 +237,7 @@ function bindSubject(
236237
}
237238

238239
function verificationFailure(config: AwsCredentialConfig, error: unknown): AwsCredentialError {
239-
const detail = getErrorMessage(error, 'Unknown error')
240+
const detail = failureClause(error)
240241
if (config.authType === 'oidc_role') {
241242
return new AwsCredentialError(
242243
`Sim could not assume this role: ${detail}. Check that the role trusts the Sim OIDC provider with this exact subject and audience ${AWS_WORKLOAD_IDENTITY_AUDIENCE}.`
@@ -265,6 +266,9 @@ export async function verifyAndEncryptAwsCredential(
265266
const submitted = parsed.data
266267
let config: AwsCredentialConfig
267268
if (submitted.authType === 'oidc_role') {
269+
if (!(await isWorkloadIdentityConfigured())) {
270+
throw new WorkloadIdentityNotConfiguredError('Use access keys.')
271+
}
268272
const { subject, subjectProof, ...rest } = submitted
269273
config = {
270274
...rest,
@@ -334,7 +338,7 @@ async function assertRolePinsSubject(
334338
} catch (error) {
335339
if (error instanceof Error && error.name === 'AccessDenied') return
336340
throw new AwsCredentialError(
337-
`Sim could not confirm the role's trust policy limits it to this credential: ${getErrorMessage(error, 'Unknown error')}. Try again.`
341+
`Sim could not confirm the role's trust policy limits it to this credential: ${failureClause(error)}. Try again.`
338342
)
339343
}
340344
throw new AwsCredentialError(

‎apps/sim/lib/credentials/databricks-service-account.server.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
99

1010
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
1111
vi.mock('@/lib/credentials/workload-identity/issuer', () => ({
12+
isWorkloadIdentityConfigured: async () => true,
1213
MAX_WORKLOAD_IDENTITY_TOKEN_TTL_SECONDS: 3600,
1314
mintWorkloadIdentityToken: async ({ subject }: { subject: string }) => `token-for:${subject}`,
1415
}))

‎apps/sim/lib/credentials/databricks-service-account.server.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ import { z } from 'zod'
66
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
77
import { validateDatabricksWorkspaceHost } from '@/lib/core/security/input-validation'
88
import { isPayloadSizeLimitError, readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
9-
import { parseServiceAccountConfig } from '@/lib/credentials/service-account-config'
9+
import { failureClause, parseServiceAccountConfig } from '@/lib/credentials/service-account-config'
1010
import {
1111
providerFailureCode,
1212
TokenServiceAccountValidationError,
1313
} from '@/lib/credentials/token-service-accounts/errors'
1414
import { WORKLOAD_IDENTITY_AUDIENCES } from '@/lib/credentials/workload-identity/audiences'
1515
import {
16+
isWorkloadIdentityConfigured,
1617
MAX_WORKLOAD_IDENTITY_TOKEN_TTL_SECONDS,
1718
mintWorkloadIdentityToken,
1819
type WorkloadIdentityOwner,
@@ -21,6 +22,7 @@ import {
2122
bindWorkloadIdentitySubject,
2223
newWorkloadIdentitySubject,
2324
type WorkloadIdentityCredentialOwner,
25+
WorkloadIdentityNotConfiguredError,
2426
} from '@/lib/credentials/workload-identity/subject'
2527
import { DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
2628

@@ -298,7 +300,7 @@ async function assertPolicyPinsSubject(
298300
providerFailureCode(error.status) === 'invalid_credentials'
299301
if (refused) return
300302
throw new DatabricksCredentialError(
301-
`Sim could not confirm the federation policy limits this service principal to this credential: ${getErrorMessage(error, 'Unknown error')}. Try again.`
303+
`Sim could not confirm the federation policy limits this service principal to this credential: ${failureClause(error)}. Try again.`
302304
)
303305
}
304306
throw new DatabricksCredentialError(
@@ -325,6 +327,11 @@ export async function verifyAndEncryptDatabricksCredential(
325327
const submitted = parsed.data
326328
let config: DatabricksCredentialConfig
327329
if (submitted.authType === 'oidc_federation') {
330+
if (!(await isWorkloadIdentityConfigured())) {
331+
throw new WorkloadIdentityNotConfiguredError(
332+
'Use a personal access token or a service principal OAuth secret.'
333+
)
334+
}
328335
const { subject, subjectProof, ...rest } = submitted
329336
config = { ...rest, subject: bindSubject(subject, subjectProof, owner) }
330337
} else {

‎apps/sim/lib/credentials/google-workload-identity.server.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ export async function verifyAndEncryptGoogleWorkloadIdentityCredential(
360360
)
361361
if (!parsed.success) throw new GoogleWorkloadIdentityError(parsed.message)
362362
if (!(await isWorkloadIdentityConfigured())) {
363-
throw new WorkloadIdentityNotConfiguredError()
363+
throw new WorkloadIdentityNotConfiguredError('Use a JSON key.')
364364
}
365365
const submitted = parsed.data
366366
const config: GoogleWorkloadIdentityConfig = {

‎apps/sim/lib/credentials/microsoft-app-service-account.server.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,7 @@ export async function verifyAndEncryptMicrosoftAppCredential(
547547
let config: MicrosoftAppConfig
548548
if (submitted.authType === 'federated') {
549549
if (!(await isWorkloadIdentityConfigured())) {
550-
throw new WorkloadIdentityNotConfiguredError()
550+
throw new WorkloadIdentityNotConfiguredError('Use a client secret or certificate.')
551551
}
552552
const { subject, subjectProof, ...rest } = submitted
553553
config = { ...rest, subject: bindSubject(subject, subjectProof, owner) }

‎apps/sim/lib/credentials/service-account-config.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
1+
import { getErrorMessage } from '@sim/utils/errors'
12
import type { z } from 'zod'
23

34
const MAX_CONFIG_BYTES = 64 * 1024
45

6+
/**
7+
* A failure's message without its closing punctuation, so it can sit inside a
8+
* sentence that adds the remedy — a provider message that already ends in a
9+
* period would otherwise print two.
10+
*/
11+
export function failureClause(error: unknown): string {
12+
return getErrorMessage(error, 'Unknown error').replace(/[.\s]+$/, '')
13+
}
14+
515
/**
616
* Parses a service-account configuration submitted as JSON (the connect modal's
717
* `serviceAccountJson`) against its provider schema. Returns the first

‎apps/sim/lib/credentials/service-account-secret.test.ts‎

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -380,32 +380,61 @@ describe('verifyAndBuildServiceAccountSecret', () => {
380380
expect(result.principal).toBeNull()
381381
})
382382

383-
it('refuses a keyless credential on a deployment without workload identity, with its code', async () => {
384-
await expect(
385-
verifyAndBuildServiceAccountSecret('snowflake-service-account', {
386-
serviceAccountJson: JSON.stringify({
387-
authType: 'workload_identity',
388-
host: 'myorg-myaccount.snowflakecomputing.com',
389-
}),
390-
owner: { workspaceId: 'ws-1', actorUserId: 'user-1' },
391-
})
392-
).rejects.toMatchObject({
393-
code: 'validation',
394-
detailCode: 'WORKLOAD_IDENTITY_NOT_CONFIGURED',
395-
})
396-
})
397-
398-
it('passes a keyless refusal through with its code rather than as a verification failure', async () => {
399-
await expect(
400-
verifyAndBuildServiceAccountSecret('aws-service-account', {
401-
serviceAccountJson: JSON.stringify({
402-
authType: 'oidc_role',
403-
roleArn: 'arn:aws:iam::123456789012:role/SimWorkflows',
404-
}),
405-
owner: { workspaceId: 'ws-1', actorUserId: 'user-1' },
383+
it.each([
384+
[
385+
'aws-service-account',
386+
{ authType: 'oidc_role', roleArn: 'arn:aws:iam::123456789012:role/SimWorkflows' },
387+
'Use access keys.',
388+
],
389+
[
390+
'databricks-service-account',
391+
{
392+
authType: 'oidc_federation',
393+
host: 'dbc-abc123.cloud.databricks.com',
394+
clientId: '11111111-2222-3333-4444-555555555555',
395+
},
396+
'Use a personal access token or a service principal OAuth secret.',
397+
],
398+
[
399+
'snowflake-service-account',
400+
{ authType: 'workload_identity', host: 'myorg-myaccount.snowflakecomputing.com' },
401+
'Use a programmatic access token.',
402+
],
403+
[
404+
'microsoft-app-service-account',
405+
{
406+
authType: 'federated',
407+
tenantId: '11111111-2222-3333-4444-555555555555',
408+
clientId: '66666666-7777-8888-9999-000000000000',
409+
},
410+
'Use a client secret or certificate.',
411+
],
412+
[
413+
'google-service-account',
414+
{
415+
authType: 'workload_identity',
416+
projectNumber: '123456789012',
417+
poolId: 'sim-pool',
418+
providerId: 'sim-provider',
419+
serviceAccountEmail: 'sim-workflows@my-project.iam.gserviceaccount.com',
420+
},
421+
'Use a JSON key.',
422+
],
423+
])(
424+
'refuses a keyless %s credential where this deployment cannot sign',
425+
async (providerId, configuration, remedy) => {
426+
await expect(
427+
verifyAndBuildServiceAccountSecret(providerId, {
428+
serviceAccountJson: JSON.stringify(configuration),
429+
owner: { workspaceId: 'ws-1', actorUserId: 'user-1' },
430+
})
431+
).rejects.toMatchObject({
432+
code: 'validation',
433+
detailCode: 'WORKLOAD_IDENTITY_NOT_CONFIGURED',
434+
message: `Keyless connections are not available on this deployment. ${remedy}`,
406435
})
407-
).rejects.toMatchObject({ detailCode: 'WORKLOAD_IDENTITY_SUBJECT_REQUIRED' })
408-
})
436+
}
437+
)
409438

410439
it('throws when client-credential required fields are missing, without minting', async () => {
411440
await expect(

‎apps/sim/lib/credentials/snowflake-service-account.server.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ export async function verifyAndEncryptSnowflakeKeylessCredential(
153153
)
154154
if (!parsed.success) throw new SnowflakeCredentialError(parsed.message)
155155
if (!(await isWorkloadIdentityConfigured())) {
156-
throw new WorkloadIdentityNotConfiguredError()
156+
throw new WorkloadIdentityNotConfiguredError('Use a programmatic access token.')
157157
}
158158
const { host, subject: submittedSubject, subjectProof } = parsed.data
159159
const subject = bindWorkloadIdentitySubject({

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,16 @@ export class WorkloadIdentitySubjectError extends KeylessCredentialRefusedError
9696
}
9797
}
9898

99-
/** This deployment has no usable signing key, so it cannot issue or verify keyless credentials. */
99+
/**
100+
* This deployment has no usable signing key, so it cannot issue or verify
101+
* keyless credentials.
102+
*
103+
* @param remedy The provider's key-based alternative, as one sentence.
104+
*/
100105
export class WorkloadIdentityNotConfiguredError extends KeylessCredentialRefusedError {
101-
constructor() {
106+
constructor(remedy = 'Use a key- or token-based credential.') {
102107
super(
103-
'Keyless connections are not available on this deployment. Use a key- or token-based credential.',
108+
`Keyless connections are not available on this deployment. ${remedy}`,
104109
'WORKLOAD_IDENTITY_NOT_CONFIGURED'
105110
)
106111
this.name = 'WorkloadIdentityNotConfiguredError'

0 commit comments

Comments
 (0)