Skip to content

Commit 2206138

Browse files
committed
fix(credentials): enforce the saved-credential contract on every credential block, reopen key-based reconnects on their mode, and tighten fixtures and skills
- check-block-registry runs the saved-credential contract for every block with a saved-credential picker, not only successors; the per-block contract moves to saved-credential-contract.ts with tests. The host probe gains a path-shaped id and a per-param shape so mixed-validation builders (Jama, Higgsfield) are probed, not guessed. - Blocks the widened check caught: Coda, Harmonic, and Oracle Database tools declare credentialKind 'service-account'; NetSuite pins instanceUrl in authoritativeParams; Vanta's assetExternalAccountId is a filter, not a host. - prepareWorkloadIdentity covers every keyless provider (AWS, Databricks added) and reuses audiences.ts for the provider-id mapping; the AWS and Databricks dialogs reopen a reconnect on the stored mode. - Snowflake keyless verification maps an egress refusal to host_not_allowed. - Neutral token fixtures in new credential tests; subject uniqueness TSDoc; skills describe native token blocks, the picker shape, keyless setup, and the successor naming exception.
1 parent 219b39c commit 2206138

38 files changed

Lines changed: 586 additions & 222 deletions

‎.agents/skills/add-block/SKILL.md‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ export const {ServiceName}Block: BlockConfig = {
8383
// Multi-line input
8484
{ id: 'field', title: 'Label', type: 'long-input', placeholder: '...', rows: 6 }
8585

86-
// Password input
86+
// Password input: only for legacy blocks or a secret with no saved-credential fit
87+
// (model-provider keys, hosted-key pairs); a service token uses a saved credential
8788
{ id: 'apiKey', title: 'API Key', type: 'short-input', password: true }
8889
```
8990

@@ -136,6 +137,32 @@ export const {ServiceName}Block: BlockConfig = {
136137
}
137138
```
138139

140+
A service-account-only picker (a saved token or key credential) instead sets
141+
`credentialKind: 'service-account'` and `mode: 'basic'` (no scopes), and pairs with an advanced
142+
twin; `isSavedCredentialPicker` matches only this shape:
143+
```typescript
144+
{
145+
id: 'credential',
146+
title: '{Service} Account',
147+
type: 'oauth-input',
148+
serviceId: '{service}',
149+
credentialKind: 'service-account',
150+
canonicalParamId: 'oauthCredential',
151+
mode: 'basic',
152+
placeholder: 'Select {Service} credential',
153+
required: true,
154+
},
155+
{
156+
id: 'manualCredential',
157+
title: '{Service} Account',
158+
type: 'short-input',
159+
canonicalParamId: 'oauthCredential',
160+
mode: 'advanced',
161+
placeholder: 'Enter credential ID',
162+
required: true,
163+
}
164+
```
165+
139166
**Scopes:** Always use `getScopesForService(serviceId)` from `@/lib/oauth/utils` for `requiredScopes`. Never hardcode scope arrays — the single source of truth is `OAUTH_PROVIDERS` in `lib/oauth/oauth.ts`.
140167

141168
**Scope descriptions:** When adding a new OAuth provider, also add human-readable descriptions for all scopes in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`.
@@ -695,7 +722,8 @@ outputs: {
695722
When creating V2 blocks (alongside legacy V1):
696723

697724
```typescript
698-
// V1 Block - mark as legacy
725+
// V1 Block - mark as legacy. A credential-only successor is the exception: the
726+
// successor keeps the legacy block's name, so neither block is renamed.
699727
export const ServiceBlock: BlockConfig = {
700728
type: 'service',
701729
name: 'Service (Legacy)',

‎.agents/skills/add-integration/SKILL.md‎

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ supported paths and choose the simplest supported setup for the intended user:
4343
| Customer-owned client credentials | Saved `service_account` credential with a client ID and secret | Internal-app eligibility, grant activation, scope syntax, token lifetime and revocation behavior |
4444
| API token or personal access token | Saved token service account when reusable connections are useful | Token permissions, identity verification, expiry and rotation |
4545
| Private key, certificate, or service-account JSON | Existing key-based service-account framework | Signing algorithm, audience, subject, tenant binding and key rotation |
46+
| Cloud workload identity (AWS role, Databricks/Snowflake/Entra/GCP federation) | Keyless saved credential: Sim signs a short-lived OIDC token the customer's cloud trusts. Add the provider to every table in `lib/credentials/workload-identity/audiences.ts` (`WORKLOAD_IDENTITY_PROVIDERS`, its service-account provider ID map, `WORKLOAD_IDENTITY_KEYLESS_CONFIGS`, `WORKLOAD_IDENTITY_AUDIENCES`) and to `MIXED_MODE_PROVIDERS` in `lib/credentials/application/prepare-workload-identity.ts` | Issuer and audience the provider accepts, how the trust pins the subject, token lifetime |
47+
48+
API, CLI, and MCP callers set up keyless in two steps: `POST /api/v2/credentials/workload-identity/subjects`
49+
(or `sim credentials keyless-subject <providerId> --output json`) returns the subject to trust and a
50+
`keylessConfig`; they add its `requiredConfigFields` and create the credential with it. The provider
51+
catalog advertises `keyless` only when the deployment can sign tokens.
4652

4753
Do not present customer-owned credentials as a way around a provider's approval requirements
4854
for a shared public integration. Explain the supported account/app type in the setup docs. Keep
@@ -86,15 +92,23 @@ service id:
8692
answers), and `errorBodyEchoesToken` when an error body echoes the token.
8793
3. `apps/sim/lib/oauth/oauth.ts` and `types.ts`: the service entry (`authType: 'service_account'`,
8894
`serviceAccountProviderId`).
89-
4. `apps/sim/tools/shared/saved-credentials.ts`: `tokenCredentialOAuth('<service>', { <toolParam>:
90-
'accessToken' | 'domain' | 'providerWorkspaceId' })`, listed in `SAVED_CREDENTIAL_OAUTH`, and set
91-
as `oauth` on every tool. Map every param that decides the request host; the executor overwrites
92-
mapped params after authorizing the credential, and `check-block-registry` probes the tools' real
93-
URL builders and fails on a host-steering param left unmapped.
95+
4. The tools' credential policy. A NEW integration's tools take a hidden `accessToken` and declare
96+
`oauth: { required: true, provider: '<service>', credentialKind: 'service-account' }`, pinning
97+
any host or account param with `authoritativeParams` (Flow's and Snowflake's tools are the
98+
pattern). `tokenCredentialOAuth('<service>', { <toolParam>: 'accessToken' | 'domain' |
99+
'providerWorkspaceId' })` in `apps/sim/tools/shared/saved-credentials.ts`, listed in
100+
`SAVED_CREDENTIAL_OAUTH`, is for existing tools that already take the token inline (successors).
101+
Either way, the executor overwrites mapped and pinned params after authorizing the credential,
102+
and `check-block-registry` probes the real URL builders of every block with a saved-credential
103+
picker and fails on a host-steering param left unmapped or unpinned.
94104
5. A credential-only block (including a successor) uses `authMode: AuthMode.OAuth`, so the
95105
generated catalog maps the service; a token-only block that keeps `AuthMode.ApiKey`, as Coda
96106
does, registers in `tokenCredentialIntegrationTypes` instead (see below). The credential picker
97-
sits directly under the operation dropdown (`add-block` → OAuth/Credentials). An existing block that already takes the secret inline is never edited in
107+
sits directly under the operation dropdown (`add-block` → OAuth/Credentials). A new block
108+
writes the picker inline, as Higgsfield does: `{ id: 'credential', type: 'oauth-input', serviceId,
109+
credentialKind: 'service-account', canonicalParamId: 'oauthCredential', mode: 'basic',
110+
required: true }`, plus a `manualCredential` `short-input` with the same `canonicalParamId` in
111+
`mode: 'advanced'`. An existing block that already takes the secret inline is never edited in
98112
place: add a credential-only successor (`add-block` → Credential-only successor).
99113

100114
Then run `generate-docs`, `generate:block-successors`, `generate:deployment-config`, and
@@ -461,7 +475,8 @@ If creating V2 versions (API-aligned outputs):
461475

462476
1. **V2 Tools** - Add `_v2` suffix, version `2.0.0`, flat outputs
463477
2. **V2 Block** - Add `_v2` type, use `createVersionedToolSelector`
464-
3. **V1 Block** - Add `(Legacy)` to name, set `hideFromToolbar: true`, and add
478+
3. **V1 Block** - Add `(Legacy)` to name (a credential-only successor is the exception: it keeps
479+
the legacy block's name), set `hideFromToolbar: true`, and add
465480
`sunset: { status: 'legacy', replacedBy: '{service}_v2' }` — `check-block-registry`
466481
fails a legacy block with no `replacedBy`, and the amber legacy badge plus its
467482
click-to-upgrade action read from that field.

‎.agents/skills/validate-integration/SKILL.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,13 @@ For **each tool** in `tools.access`:
226226
`tokenCredentialOAuth` policy, every
227227
param that decides the request host is mapped, and the stored domain is in the form the tools
228228
build URLs from (`check-block-registry.ts origin/staging` passes)
229+
- [ ] A non-successor token block's tools declare `oauth: { required: true, provider,
230+
credentialKind: 'service-account' }` and pin every host or account param in
231+
`authoritativeParams` (`check-block-registry` enforces this for every block with a
232+
saved-credential picker)
233+
- [ ] A keyless provider appears in every table in `lib/credentials/workload-identity/audiences.ts`
234+
and in `MIXED_MODE_PROVIDERS` (`prepare-workload-identity.ts`), and the provider catalog
235+
shows `keyless` only when the deployment can sign tokens
229236
- [ ] The `tokenCheckValidator` request uses the tools' host and auth, its normalizers refuse
230237
anything that could steer the host or path, and a fake key is rejected without a valid
231238
scoped key being refused

‎apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/aws-account-modal.tsx‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,17 @@ function AwsAccountForm({
114114
const [displayName, setDisplayName] = useState(initialDisplayName ?? '')
115115
const [description, setDescription] = useState(initialDescription ?? '')
116116

117-
// Keyless is the default wherever this deployment supports it.
118-
const authType: AwsAuthType =
119-
selectedAuthType ?? (keyless.available ? 'oidc_role' : 'access_keys')
117+
// A reconnect opens on keyless only for a keyless credential, so a key-based connection is
118+
// never switched to keyless by default; a new connection defaults to keyless wherever this
119+
// deployment supports it.
120+
const defaultAuthType: AwsAuthType = credentialId
121+
? keyless.reconnectsKeyless
122+
? 'oidc_role'
123+
: 'access_keys'
124+
: keyless.available
125+
? 'oidc_role'
126+
: 'access_keys'
127+
const authType: AwsAuthType = selectedAuthType ?? defaultAuthType
120128
const needsKeys = authType === 'access_keys' || authType === 'assume_role'
121129
const needsRole = authType === 'oidc_role' || authType === 'assume_role'
122130
const disabled =

‎apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/databricks-account-modal.tsx‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,17 @@ function DatabricksAccountForm({
9393
const [displayName, setDisplayName] = useState(initialDisplayName ?? '')
9494
const [description, setDescription] = useState(initialDescription ?? '')
9595

96-
// Keyless is the default wherever this deployment supports it.
97-
const authType: DatabricksAuthType =
98-
selectedAuthType ?? (keyless.available ? 'oidc_federation' : 'service_principal')
96+
// A reconnect opens on keyless only for a keyless credential, so a key-based connection is
97+
// never switched to keyless by default; a new connection defaults to keyless wherever this
98+
// deployment supports it.
99+
const defaultAuthType: DatabricksAuthType = credentialId
100+
? keyless.reconnectsKeyless
101+
? 'oidc_federation'
102+
: 'service_principal'
103+
: keyless.available
104+
? 'oidc_federation'
105+
: 'service_principal'
106+
const authType: DatabricksAuthType = selectedAuthType ?? defaultAuthType
99107
const usesServicePrincipal = authType === 'service_principal' || authType === 'oidc_federation'
100108
const disabled =
101109
isPending ||

‎apps/sim/lib/credentials/application/prepare-workload-identity.test.ts‎

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ function reconnecting(
5959
encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: JSON.stringify(secret) })
6060
}
6161

62-
function prepare(provider: 'snowflake' | 'google' | 'microsoft' = 'snowflake') {
62+
function prepare(
63+
provider: 'aws' | 'databricks' | 'snowflake' | 'google' | 'microsoft' = 'snowflake'
64+
) {
6365
return prepareWorkloadIdentity.execute({
6466
principal,
6567
input: { workspaceId: 'workspace-1', provider, credentialId: 'cred-1' },
@@ -198,3 +200,80 @@ describe('preparing a Microsoft app credential reconnect', () => {
198200
expect(await prepare('microsoft')).not.toHaveProperty('reconnectsKeyless')
199201
})
200202
})
203+
204+
describe('preparing an AWS or Databricks reconnect', () => {
205+
const subject = 'sim:workspace:workspace-1:credential:abcdef0123456789'
206+
const roleArn = 'arn:aws:iam::123456789012:role/sim-role'
207+
const accessKeys = { accessKeyId: 'TESTACCESSKEYID0000', secretAccessKey: 'test-secret' }
208+
const databricksHost = 'dbc-00000000-0000.cloud.databricks.com'
209+
const clientId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
210+
211+
beforeEach(() => {
212+
workspaceContextMockFns.mockLoadActiveWorkspaceApplicationContext.mockResolvedValue({
213+
workspaceId: 'workspace-1',
214+
workspaceOrganizationId: null,
215+
allowPersonalApiKeys: true,
216+
billedAccountUserId: 'user-1',
217+
})
218+
workspaceAuthzMockFns.mockResolveEffectiveWorkspacePermission.mockResolvedValue('write')
219+
})
220+
221+
it.each([
222+
{
223+
provider: 'aws' as const,
224+
providerId: 'aws-service-account',
225+
config: { authType: 'oidc_role', roleArn, subject },
226+
type: 'aws_credential_v1',
227+
},
228+
{
229+
provider: 'databricks' as const,
230+
providerId: 'databricks-service-account',
231+
config: { authType: 'oidc_federation', host: databricksHost, clientId, subject },
232+
type: 'databricks_credential_v1',
233+
},
234+
])(
235+
'opens a keyless $provider credential on keyless',
236+
async ({ provider, providerId, ...secret }) => {
237+
reconnecting(secret, { providerId })
238+
await expect(prepare(provider)).resolves.toMatchObject({ reconnectsKeyless: true })
239+
}
240+
)
241+
242+
it.each([
243+
{
244+
provider: 'aws' as const,
245+
providerId: 'aws-service-account',
246+
config: { authType: 'access_keys', ...accessKeys },
247+
type: 'aws_credential_v1',
248+
},
249+
{
250+
provider: 'aws' as const,
251+
providerId: 'aws-service-account',
252+
config: { authType: 'assume_role', ...accessKeys, roleArn },
253+
type: 'aws_credential_v1',
254+
},
255+
{
256+
provider: 'databricks' as const,
257+
providerId: 'databricks-service-account',
258+
config: { authType: 'personal_access_token', host: databricksHost, token: 'test-token' },
259+
type: 'databricks_credential_v1',
260+
},
261+
{
262+
provider: 'databricks' as const,
263+
providerId: 'databricks-service-account',
264+
config: {
265+
authType: 'service_principal',
266+
host: databricksHost,
267+
clientId,
268+
clientSecret: 'test-secret',
269+
},
270+
type: 'databricks_credential_v1',
271+
},
272+
])(
273+
'opens a $config.authType $provider credential on its keyed mode',
274+
async ({ provider, providerId, ...secret }) => {
275+
reconnecting(secret, { providerId })
276+
expect(await prepare(provider)).not.toHaveProperty('reconnectsKeyless')
277+
}
278+
)
279+
})

‎apps/sim/lib/credentials/application/prepare-workload-identity.ts‎

Lines changed: 21 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,22 @@ import { OrchestrationError } from '@/lib/core/orchestration/types'
44
import { decryptSecret } from '@/lib/core/security/encryption'
55
import { getCredentialActorContext } from '@/lib/credentials/access'
66
import { credentialOperations } from '@/lib/credentials/application/operations'
7+
import { isKeylessAwsSecret } from '@/lib/credentials/aws-service-account.server'
8+
import { isKeylessDatabricksSecret } from '@/lib/credentials/databricks-service-account.server'
79
import { isGoogleWorkloadIdentitySecret } from '@/lib/credentials/google-workload-identity'
810
import { isKeylessMicrosoftAppSecret } from '@/lib/credentials/microsoft-app-service-account.server'
911
import { isKeylessSnowflakeSecret } from '@/lib/credentials/snowflake-service-account.server'
10-
import { SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/token-service-accounts/descriptors'
1112
import {
1213
fixedWorkloadIdentityAudience,
14+
isWorkloadIdentityServiceAccountProviderId,
1315
type WorkloadIdentityProvider,
16+
workloadIdentityProviderFor,
1417
} from '@/lib/credentials/workload-identity/audiences'
1518
import {
1619
getWorkloadIdentityIssuer,
1720
isWorkloadIdentityConfigured,
1821
} from '@/lib/credentials/workload-identity/issuer'
1922
import { issueWorkloadIdentitySubject } from '@/lib/credentials/workload-identity/subject'
20-
import {
21-
GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
22-
MICROSOFT_APP_SERVICE_ACCOUNT_PROVIDER_ID,
23-
} from '@/lib/oauth/types'
2423
import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/context'
2524

2625
export interface PrepareWorkloadIdentityInput {
@@ -44,29 +43,20 @@ export type PrepareWorkloadIdentityResult =
4443
reconnectsKeyless?: true
4544
}
4645

47-
/** Providers whose credentials can be keyless or key-based under one provider ID. */
48-
const MIXED_MODE_PROVIDERS: Partial<
49-
Record<
50-
WorkloadIdentityProvider,
51-
{ providerId: string; isKeyless: (decrypted: string) => boolean }
52-
>
53-
> = {
54-
snowflake: {
55-
providerId: SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID,
56-
isKeyless: isKeylessSnowflakeSecret,
57-
},
58-
microsoft: {
59-
providerId: MICROSOFT_APP_SERVICE_ACCOUNT_PROVIDER_ID,
60-
isKeyless: isKeylessMicrosoftAppSecret,
61-
},
62-
google: {
63-
providerId: GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
64-
isKeyless: isGoogleWorkloadIdentitySecret,
65-
},
46+
/**
47+
* Every keyless provider also has key-based modes under the same provider ID, so a
48+
* reconnect reads the stored secret to open the dialog on the mode it holds.
49+
*/
50+
const MIXED_MODE_PROVIDERS: Record<WorkloadIdentityProvider, (decrypted: string) => boolean> = {
51+
aws: isKeylessAwsSecret,
52+
databricks: isKeylessDatabricksSecret,
53+
snowflake: isKeylessSnowflakeSecret,
54+
microsoft: isKeylessMicrosoftAppSecret,
55+
google: isGoogleWorkloadIdentitySecret,
6656
}
6757

6858
/**
69-
* Whether a Snowflake, Microsoft app, or Google credential in this workspace, which this person
59+
* Whether a credential of this provider in this workspace, which this person
7060
* administers, is keyless. Only that one bit leaves the stored secret, and only
7161
* for someone allowed to reconnect the credential.
7262
*/
@@ -76,22 +66,21 @@ async function isKeylessCredential(
7666
workspaceId: string,
7767
userId: string
7868
): Promise<boolean> {
79-
const mixedMode = MIXED_MODE_PROVIDERS[provider]
8069
const access = await getCredentialActorContext(credentialId, userId, { workspaceId })
8170
const row = access.credential
8271
if (
8372
!access.isAdmin ||
8473
!row ||
8574
row.workspaceId !== workspaceId ||
8675
row.type !== 'service_account' ||
87-
!mixedMode ||
88-
row.providerId !== mixedMode.providerId ||
76+
!isWorkloadIdentityServiceAccountProviderId(row.providerId) ||
77+
workloadIdentityProviderFor(row.providerId) !== provider ||
8978
!row.encryptedServiceAccountKey
9079
) {
9180
return false
9281
}
9382
const { decrypted } = await decryptSecret(row.encryptedServiceAccountKey)
94-
return mixedMode.isKeyless(decrypted)
83+
return MIXED_MODE_PROVIDERS[provider](decrypted)
9584
}
9685

9786
/**
@@ -118,10 +107,9 @@ export const prepareWorkloadIdentity = defineAuthorizedWorkspaceUseCase({
118107
{ workspaceId: input.workspaceId },
119108
userId
120109
)
121-
const reconnectsKeyless =
122-
MIXED_MODE_PROVIDERS[input.provider] && input.credentialId
123-
? await isKeylessCredential(input.provider, input.credentialId, input.workspaceId, userId)
124-
: false
110+
const reconnectsKeyless = input.credentialId
111+
? await isKeylessCredential(input.provider, input.credentialId, input.workspaceId, userId)
112+
: false
125113
const audience = fixedWorkloadIdentityAudience(input.provider)
126114
return {
127115
available: true,

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,15 @@ export async function resolveAwsSessionCredentials(
215215
return toSessionCredentials(response.Credentials)
216216
}
217217

218+
/** Whether a decrypted AWS secret is an OIDC role rather than access keys. */
219+
export function isKeylessAwsSecret(decrypted: string): boolean {
220+
try {
221+
return readStoredSubject(JSON.parse(decrypted)) !== undefined
222+
} catch {
223+
return false
224+
}
225+
}
226+
218227
function readStoredSubject(storedSecret: Record<string, unknown> | null | undefined) {
219228
const parsed = storedSecretSchema.safeParse(storedSecret)
220229
return parsed.success && parsed.data.config.authType === 'oidc_role'

‎apps/sim/lib/credentials/client-credential-accounts/minters/docusign.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const FIELDS = {
3333
orgId: '',
3434
}
3535

36-
const ACCESS_TOKEN = 'eyJ0eXAiOiJNVCJ9.access-token-value'
36+
const ACCESS_TOKEN = 'test-docusign-access-token'
3737

3838
function userinfo(accounts: Array<Record<string, unknown>>) {
3939
return jsonResponse({

0 commit comments

Comments
 (0)