diff --git a/apps/docs/content/docs/cli/credentials.mdx b/apps/docs/content/docs/cli/credentials.mdx index 4a516f7eb40..57dc0db372e 100644 --- a/apps/docs/content/docs/cli/credentials.mdx +++ b/apps/docs/content/docs/cli/credentials.mdx @@ -93,6 +93,8 @@ Keyless (workload identity) credentials take two steps. Step 1: run `sim credentials keyless-subject --output json` and configure your cloud to trust the printed issuer, audience, and subject. Its requiredConfigFields lists the values your provider's keylessConfig still needs. +The printed audience is the default. To have Sim sign tokens for your own audience instead, set audience in keylessConfig and trust that value. Google Cloud prints no audience: its default is the credential's own workload identity pool provider. + Step 2: add those values to keylessConfig and pass it to `sim credentials create --credentials @file`. The proof expires after 24 hours and works only for the user who requested it, with the same providerId. If creation reports WORKLOAD_IDENTITY_PROOF_EXPIRED, WORKLOAD_IDENTITY_PROOF_INVALID, WORKLOAD_IDENTITY_SUBJECT_REQUIRED, WORKLOAD_IDENTITY_SUBJECT_IN_USE, run step 1 again, trust the new subject, and retry. @@ -366,6 +368,8 @@ Keyless (workload identity) credentials take two steps. Step 1: run `sim credentials keyless-subject --output json` and configure your cloud to trust the printed issuer, audience, and subject. Its requiredConfigFields lists the values your provider's keylessConfig still needs. +The printed audience is the default. To have Sim sign tokens for your own audience instead, set audience in keylessConfig and trust that value. Google Cloud prints no audience: its default is the credential's own workload identity pool provider. + Step 2: add those values to keylessConfig and pass it to `sim credentials create --credentials @file`. The proof expires after 24 hours and works only for the user who requested it, with the same providerId. If creation reports WORKLOAD_IDENTITY_PROOF_EXPIRED, WORKLOAD_IDENTITY_PROOF_INVALID, WORKLOAD_IDENTITY_SUBJECT_REQUIRED, WORKLOAD_IDENTITY_SUBJECT_IN_USE, run step 1 again, trust the new subject, and retry. diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 579ddc3c433..5edce25dba9 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -780,6 +780,8 @@ Keyless (workload identity) credentials take two steps. Step 1: run `sim credentials keyless-subject --output json` and configure your cloud to trust the printed issuer, audience, and subject. Its requiredConfigFields lists the values your provider's keylessConfig still needs. +The printed audience is the default. To have Sim sign tokens for your own audience instead, set audience in keylessConfig and trust that value. Google Cloud prints no audience: its default is the credential's own workload identity pool provider. + Step 2: add those values to keylessConfig and pass it to `sim credentials create --credentials @file`. The proof expires after 24 hours and works only for the user who requested it, with the same providerId. If creation reports WORKLOAD_IDENTITY_PROOF_EXPIRED, WORKLOAD_IDENTITY_PROOF_INVALID, WORKLOAD_IDENTITY_SUBJECT_REQUIRED, WORKLOAD_IDENTITY_SUBJECT_IN_USE, run step 1 again, trust the new subject, and retry. @@ -816,6 +818,8 @@ Keyless (workload identity) credentials take two steps. Step 1: run `sim credentials keyless-subject --output json` and configure your cloud to trust the printed issuer, audience, and subject. Its requiredConfigFields lists the values your provider's keylessConfig still needs. +The printed audience is the default. To have Sim sign tokens for your own audience instead, set audience in keylessConfig and trust that value. Google Cloud prints no audience: its default is the credential's own workload identity pool provider. + Step 2: add those values to keylessConfig and pass it to `sim credentials create --credentials @file`. The proof expires after 24 hours and works only for the user who requested it, with the same providerId. If creation reports WORKLOAD_IDENTITY_PROOF_EXPIRED, WORKLOAD_IDENTITY_PROOF_INVALID, WORKLOAD_IDENTITY_SUBJECT_REQUIRED, WORKLOAD_IDENTITY_SUBJECT_IN_USE, run step 1 again, trust the new subject, and retry. diff --git a/apps/docs/content/docs/integrations/aws-credentials.mdx b/apps/docs/content/docs/integrations/aws-credentials.mdx index c296508abd5..95b3b3d53d9 100644 --- a/apps/docs/content/docs/integrations/aws-credentials.mdx +++ b/apps/docs/content/docs/integrations/aws-credentials.mdx @@ -46,13 +46,27 @@ STS **Get Session Token** works only with an **Access keys** credential: AWS ref +### Use your own audience [#keyless-audience] + +Tokens Sim signs for a keyless role carry the audience `sts.amazonaws.com` by default. To have your account accept only tokens issued for it, enter your own **Audience** in the dialog (for example your AWS account ID): up to 255 letters, digits, and `. _ ~ : / ? # @ & + = % -`. The **Role trust policy** then uses it in its `aud` condition. + +AWS accepts a token only when its audience is one of the identity provider's audiences (client IDs), so add yours to the provider. In the IAM console, open the provider and choose **Add audience**, or run: + +```bash +aws iam add-client-id-to-open-id-connect-provider \ + --open-id-connect-provider-arn arn:aws:iam:::oidc-provider/ \ + --client-id +``` + +A provider holds up to 100 audiences. Once no role relies on `sts.amazonaws.com` any more, you can remove it from the provider. Reconnecting the credential keeps its audience; clearing the field returns it to the default. + The subject is unique to the credential. Reconnecting a keyless credential keeps its subject by default, so the role's trust policy keeps working; choose **Issue a new subject** only if you also update the trust policy. ### From the API or CLI [#keyless-api-cli] -Run `sim credentials keyless-subject aws-service-account`, trust the printed subject as above, then add `roleArn` (optionally `stsRegion` and `sessionDurationSeconds`) to its `keylessConfig` and pass it to `sim credentials create aws-service-account`. See [the CLI credentials reference](/cli/credentials) for the full two-step example; the proof expires after 24 hours and works only for the user who requested it. +Run `sim credentials keyless-subject aws-service-account`, trust the printed subject as above, then add `roleArn` (optionally `stsRegion`, `sessionDurationSeconds`, and `audience`) to its `keylessConfig` and pass it to `sim credentials create aws-service-account`. See [the CLI credentials reference](/cli/credentials) for the full two-step example; the proof expires after 24 hours and works only for the user who requested it. ## Use access keys @@ -80,6 +94,6 @@ Without a signing key, the keyless option is unavailable and access keys work as ## Troubleshooting -- **`InvalidIdentityToken` or "No OpenIDConnect provider found":** the IAM identity provider's URL or audience doesn't match the dialog exactly, or AWS can't reach the issuer. Open `/.well-known/openid-configuration` from outside your network; it must load over `https`. +- **`InvalidIdentityToken` or "No OpenIDConnect provider found":** the IAM identity provider's URL doesn't match the dialog exactly, its audiences don't include the credential's audience, or AWS can't reach the issuer. Open `/.well-known/openid-configuration` from outside your network; it must load over `https`. - **"Sim could not assume this role: Not authorized to perform sts:AssumeRoleWithWebIdentity":** the role's trust policy doesn't trust this credential's subject. Paste the trust policy from the dialog again; if you chose **Issue a new subject**, update the policy to the new one. - **"This role trusts every Sim subject":** the trust policy is missing the `sub` condition. Use the policy from the dialog. diff --git a/apps/docs/content/docs/integrations/databricks-credentials.mdx b/apps/docs/content/docs/integrations/databricks-credentials.mdx index 0f332e9b62d..e660c98dd94 100644 --- a/apps/docs/content/docs/integrations/databricks-credentials.mdx +++ b/apps/docs/content/docs/integrations/databricks-credentials.mdx @@ -29,18 +29,28 @@ Every option resolves on Sim's servers at run time; tokens never appear in block In Sim, add a **Databricks connection**, choose **Service principal (keyless, via Sim OIDC)**, and enter the workspace host and the service principal's **application ID**. - A Databricks account admin runs the **Federation policy** command from the dialog with the service principal's numeric ID. First sign in to the account with `databricks auth login --host --account-id ` (not a workspace profile or the workspace web terminal). Find the numeric ID with `databricks account service-principals list --filter 'applicationId eq ""'`, or on the service principal's page in the account console. The policy trusts Sim's issuer, the Sim audience, and this connection's subject only. + A Databricks account admin runs the **Federation policy** command from the dialog with the service principal's numeric ID. First sign in to the account with `databricks auth login --host --account-id ` (not a workspace profile or the workspace web terminal). Find the numeric ID with `databricks account service-principals list --filter 'applicationId eq ""'`, or on the service principal's page in the account console. The policy trusts Sim's issuer, the audience, and this connection's subject only. Make sure the service principal can access the workspace and the resources your workflows use, then save. Sim verifies the connection by exchanging a token and reading the service principal's identity, and refuses a policy that would also trust a different subject, since every Sim workspace shares the issuer and audience. -Reconnecting a keyless connection keeps its subject by default, so the federation policy keeps working. +Reconnecting a keyless connection keeps its subject and audience by default, so the federation policy keeps working. + +### Use your own audience [#keyless-audience] + +By default Sim's tokens carry the audience `sim-databricks`, which every Sim connection shares. To pin the federation policy to a value only your tokens carry, such as your Databricks account ID, enter it in the dialog's **Audience** field before you copy the command. Sim then issues this connection's tokens for that audience, and the command lists it in the policy's `audiences`: + +```bash +databricks account service-principal-federation-policy create --json '{"oidc_policy":{"issuer":"","audiences":[""],"subject":""}}' +``` + +Databricks accepts any non-empty audience; Sim takes up to 255 letters, digits, and `. _ ~ : / ? # @ & + = % -`, and refuses another cloud's default audience. To change the audience of an existing connection, add the new value to the policy's `audiences` (with `databricks account service-principal-federation-policy update`), then reconnect with it. Clearing the field goes back to the default. ### From the API or CLI [#keyless-api-cli] -Run `sim credentials keyless-subject databricks-service-account`, trust the printed subject as above, then add `host` and `clientId` to its `keylessConfig` and pass it to `sim credentials create databricks-service-account`. See [the CLI credentials reference](/cli/credentials) for the full two-step example; the proof expires after 24 hours and works only for the user who requested it. +Run `sim credentials keyless-subject databricks-service-account`, trust the printed subject as above, then add `host` and `clientId` (and optionally `audience`) to its `keylessConfig` and pass it to `sim credentials create databricks-service-account`. See [the CLI credentials reference](/cli/credentials) for the full two-step example; the proof expires after 24 hours and works only for the user who requested it. ## Self-hosted deployments @@ -48,5 +58,5 @@ Keyless connections need `WORKLOAD_IDENTITY_SIGNING_KEY` set and an `https` issu ## Troubleshooting -- **"Databricks rejected the token request":** the federation policy's issuer, audience, or subject doesn't match the dialog, or the policy is on a different service principal than the application ID you entered. +- **"Databricks rejected the token request":** the federation policy's issuer, audience, or subject doesn't match the dialog (the error names the audience Sim sent), or the policy is on a different service principal than the application ID you entered. - **"This service principal's federation policy also trusts other Sim credentials":** the policy's `subject_claim` isn't `sub`, so it matches more than this connection. Recreate it with the command from the dialog. diff --git a/apps/docs/content/docs/integrations/google-service-account.mdx b/apps/docs/content/docs/integrations/google-service-account.mdx index 46038c880ea..cf6d44731fc 100644 --- a/apps/docs/content/docs/integrations/google-service-account.mdx +++ b/apps/docs/content/docs/integrations/google-service-account.mdx @@ -218,7 +218,7 @@ Keyless credentials work with Google Cloud APIs that the service account calls a --member="principal://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/subject/SUBJECT" ``` - The provider sets no allowed audiences, so it accepts its own resource name, which is the **Audience** the dialog shows. + By default the provider sets no allowed audiences, so it accepts its own resource name, which Sim's token carries as its `aud`. Save in Sim. Sim exchanges a token at STS and impersonates the service account; a token from that step proves the setup works. It also checks that a subject no credential holds can't impersonate the service account, so only this credential can use it. New IAM bindings can take a few minutes to apply; if saving fails right after you run the commands, wait and try again. @@ -229,13 +229,25 @@ Keyless credentials work with Google Cloud APIs that the service account calls a Keep the attribute condition and bind the role to the exact `principal://…/subject/SUBJECT` member. Without the condition, the provider accepts a token for any Sim subject, and a role granted to a `principalSet` for the whole pool would let other Sim credentials act with it. Sim refuses to save a service account that another subject can impersonate, but it can't see roles you grant the pool directly on other resources. +### Custom audience [#keyless-custom-audience] + +To pin the provider to a value of your own instead of its resource name, enter it under **Audience** (up to 255 letters, digits, and `. _ ~ : / ? # @ & + = % -`). Sim's token then carries that value as its `aud`, and the setup commands add it to the provider's [allowed audiences](https://cloud.google.com/iam/docs/reference/rest/v1/projects.locations.workloadIdentityPools.providers#oidc), which then accept only the listed values: + +```bash +gcloud iam workload-identity-pools providers create-oidc PROVIDER_ID \ + ... \ + --allowed-audiences="AUDIENCE" +``` + +For an existing provider, run `gcloud iam workload-identity-pools providers update-oidc PROVIDER_ID --project="PROJECT_NUMBER" --location="global" --workload-identity-pool="POOL_ID" --allowed-audiences="AUDIENCE"` before reconnecting the credential with the new audience. Sim still exchanges the token at STS for the provider itself. To go back to the default, clear **Audience** and remove the allowed audiences from the provider (or add its resource name, `https://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID`). Another cloud's default audience, such as `sts.amazonaws.com`, is refused. + Each keyless credential has its own subject; reconnecting keeps it by default so your provider and binding keep working. Use one provider per credential (a pool can hold many). Keyless credentials are available for workspace credentials, not organization credentials. Sim uses impersonation rather than direct federated access because some Google Cloud APIs don't accept federated tokens, and impersonation keeps the same identity a JSON key would use. Self-hosted deployments need `WORKLOAD_IDENTITY_SIGNING_KEY` set and an `https` issuer Google can reach over the public internet. See [AWS credentials](/integrations/aws-credentials#self-hosted-deployments). ### From the API or CLI [#keyless-api-cli] -Run `sim credentials keyless-subject google-service-account`, trust the printed subject as above, then add `projectNumber`, `poolId`, `providerId`, and `serviceAccountEmail` to its `keylessConfig` and pass it to `sim credentials create google-service-account`. See [the CLI credentials reference](/cli/credentials) for the full two-step example; the proof expires after 24 hours and works only for the user who requested it. For domain-wide delegation, also add `"delegation": {"user": "alice@yourcompany.com", "services": ["gmail", "google-drive"]}`, listing any of `gmail`, `google-drive`, `google-docs`, `google-sheets`, and `google-calendar`. +Run `sim credentials keyless-subject google-service-account`, trust the printed subject as above, then add `projectNumber`, `poolId`, `providerId`, and `serviceAccountEmail` (and optionally `audience`) to its `keylessConfig` and pass it to `sim credentials create google-service-account`. See [the CLI credentials reference](/cli/credentials) for the full two-step example; the proof expires after 24 hours and works only for the user who requested it. For domain-wide delegation, also add `"delegation": {"user": "alice@yourcompany.com", "services": ["gmail", "google-drive"]}`, listing any of `gmail`, `google-drive`, `google-docs`, `google-sheets`, and `google-calendar`. ### Keyless domain-wide delegation [#keyless-domain-wide-delegation] @@ -265,7 +277,7 @@ A keyless credential acts only as the Workspace user you entered. A workflow tha ### Troubleshooting keyless access -- **"Google Cloud refused Sim's token for this workload identity provider":** the project number, pool ID, or provider ID doesn't match; the provider is disabled, has allowed audiences set, or uses a different issuer; or its attribute condition doesn't allow this credential's subject. +- **"Google Cloud refused Sim's token for this workload identity provider":** the project number, pool ID, or provider ID doesn't match; the provider is disabled or uses a different issuer; its allowed audiences don't accept the credential's audience (with the default, it needs none set, or its own resource name among them; with a custom audience, that value must be listed); or its attribute condition doesn't allow this credential's subject. - **"Google Cloud could not fetch the signing keys from Sim's issuer":** the issuer isn't reachable over public HTTPS (self-hosted deployments). - **"The service account … does not let this credential impersonate it":** run the `add-iam-policy-binding` command with the exact member shown, and enable the IAM Service Account Credentials API. - **"This service account also trusts other Sim credentials":** remove any `principalSet` binding for the pool from the service account and keep the attribute condition. diff --git a/apps/docs/content/docs/integrations/microsoft-app-credentials.mdx b/apps/docs/content/docs/integrations/microsoft-app-credentials.mdx index 2782c79874b..92debf4e645 100644 --- a/apps/docs/content/docs/integrations/microsoft-app-credentials.mdx +++ b/apps/docs/content/docs/integrations/microsoft-app-credentials.mdx @@ -58,18 +58,24 @@ Only **Azure Data Explorer** runs outside the global cloud: a cluster in `kusto. In Sim, add a **Microsoft app credential**, choose **Federated credential (keyless, via Sim OIDC)**, and enter the tenant ID and client ID. The dialog shows an **Issuer**, **Audience**, and **Subject**. - In the app registration, go to **Certificates & secrets** → **Federated credentials** → **Add credential** → **Other issuer**. Paste the **Issuer** and the **Subject identifier** exactly as shown, set the audience to `api://AzureADTokenExchange` (the dialog's value), and give it a name. Matching is case-sensitive. + In the app registration, go to **Certificates & secrets** → **Federated credentials** → **Add credential** → **Other issuer**. Paste the **Issuer** and the **Subject identifier** exactly as shown, leave **Audience** at `api://AzureADTokenExchange` (or edit it to your custom audience, below), and give it a name. Matching is case-sensitive. Save in Sim. Sim requests a token as the app and reads the app's own service principal from Microsoft Graph, which needs no extra permission. It also checks that the app refuses a subject no credential holds, so only this credential can use the app. A new federated credential can take a few minutes to take effect; if saving fails with a matching error right after you add it, wait and try again. +### Use your own audience [#custom-audience] + +By default, Sim's token carries the audience `api://AzureADTokenExchange`, the value Microsoft recommends. To make the federated credential accept only tokens issued for your app, enter your own value in the dialog's **Audience** field (up to 255 letters, digits, and `. _ ~ : / ? # @ & + = % -`, such as `api://contoso-sim`), and set the federated credential's **Audience** to the same value. Entra takes exactly one audience per federated credential and matches it case-sensitively. Sim uses that audience for every token it requests for the credential, including the checks it runs when you save. Another cloud's default audience (such as `sts.amazonaws.com`) is refused. + +To change the audience later, edit the federated credential first, then reconnect in Sim with the new value. Clearing the field on reconnect returns to the default. + An app can have up to 20 federated credentials. Each Sim credential has its own subject, and reconnecting keeps the subject by default so the federated credential keeps working. Keyless credentials are available for workspace credentials, not organization credentials. ### From the API or CLI [#keyless-api-cli] -Run `sim credentials keyless-subject microsoft-app-service-account`, trust the printed subject as above, then add `tenantId` and `clientId` (optionally `environmentUrl`, or `cloud` as `usgov`, `usgov_dod`, or `china`) to its `keylessConfig` and pass it to `sim credentials create microsoft-app-service-account`. See [the CLI credentials reference](/cli/credentials) for the full two-step example; the proof expires after 24 hours and works only for the user who requested it. +Run `sim credentials keyless-subject microsoft-app-service-account`, trust the printed subject as above, then add `tenantId` and `clientId` (optionally `environmentUrl`, `cloud` as `usgov`, `usgov_dod`, or `china`, and `audience` for a [custom audience](#custom-audience)) to its `keylessConfig` and pass it to `sim credentials create microsoft-app-service-account`. See [the CLI credentials reference](/cli/credentials) for the full two-step example; the proof expires after 24 hours and works only for the user who requested it. ## Use a client secret or certificate @@ -82,7 +88,7 @@ Keyless credentials need `WORKLOAD_IDENTITY_SIGNING_KEY` set and an `https` issu ## Troubleshooting -- **"No federated credential on this app matches…":** the federated credential's issuer, subject, or audience differs from the dialog (check case and trailing slashes), it's on a different app than the client ID, or it was added moments ago. +- **"No federated credential on this app matches…":** the federated credential's issuer, subject, or audience differs from the dialog (check case and trailing slashes; the error names the audience Sim presented), it's on a different app than the client ID, or it was added moments ago. - **"Microsoft Entra rejected the client secret":** you pasted the Secret ID, or the secret was deleted. **"This client secret has expired":** create a new one and reconnect. - **"…could not verify the signed assertion":** the certificate in Sim isn't the one uploaded to the app registration. - **"…the Dataverse environment did not accept it":** add the app as an application user of the environment with a security role. diff --git a/apps/docs/content/docs/integrations/snowflake-service-account.mdx b/apps/docs/content/docs/integrations/snowflake-service-account.mdx index b9e3eb10f1c..7e32befebfa 100644 --- a/apps/docs/content/docs/integrations/snowflake-service-account.mdx +++ b/apps/docs/content/docs/integrations/snowflake-service-account.mdx @@ -37,7 +37,14 @@ CREATE USER SIM_WORKFLOWS TYPE = SERVICE; ``` - The audience is `snowflakecomputing.com`, Snowflake's default, so the statement needs no `OIDC_AUDIENCE_LIST`. + By default the audience is `snowflakecomputing.com`, Snowflake's default, so the statement needs no `OIDC_AUDIENCE_LIST`. That audience is shared by every Snowflake account, so Snowflake recommends one scoped to your account. To use one, enter it in the dialog's **Audience** field (for example your account URL, up to 255 letters, digits, and `. _ ~ : / ? # @ & + = % -`), and the statement lists it: + +```sql + SUBJECT = '' + OIDC_AUDIENCE_LIST = ('') +``` + + Snowflake can't change `OIDC_AUDIENCE_LIST` on an existing workload identity, so to change the audience later, recreate the user's workload identity with the new value, then reconnect in Sim with the same audience. Grant the user a role with the access your workflows need and make it the default, for example `GRANT ROLE SIM_WORKFLOW_ROLE TO USER SIM_WORKFLOWS;` and `ALTER USER SIM_WORKFLOWS SET DEFAULT_ROLE = SIM_WORKFLOW_ROLE;`. If a network policy or authentication policy applies to the user, it must allow Sim's egress and workload identity. @@ -51,7 +58,7 @@ Snowflake requires each issuer and subject pair to be unique in an account, and ### From the API or CLI [#keyless-api-cli] -Run `sim credentials keyless-subject snowflake-service-account`, trust the printed subject as above, then add `host` to its `keylessConfig` and pass it to `sim credentials create snowflake-service-account`. See [the CLI credentials reference](/cli/credentials) for the full two-step example; the proof expires after 24 hours and works only for the user who requested it. +Run `sim credentials keyless-subject snowflake-service-account`, trust the printed subject as above, then add `host` (and `audience`, if you listed one in `OIDC_AUDIENCE_LIST`) to its `keylessConfig` and pass it to `sim credentials create snowflake-service-account`. See [the CLI credentials reference](/cli/credentials) for the full two-step example; the proof expires after 24 hours and works only for the user who requested it. ### Self-hosted deployments diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 1cc4e6524dc..5077105d00a 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -4382,7 +4382,7 @@ "post": { "operationId": "createCredentialWorkloadIdentitySubject", "summary": "Create workload identity subject", - "description": "Start a keyless credential. Returns the issuer, audience, and a fresh subject to configure in the cloud's trust policy, plus `keylessConfig`: a prefilled `serviceAccountJson` whose `subjectProof` is valid for 24 hours and only for the requesting user. Add the `requiredConfigFields`, then call Create service-account credential with the same `providerId`; Sim verifies the trust while saving. Google Cloud has no fixed audience: it is the credential's own pool provider. Without keyless support the deployment returns `400` with `details.code` `WORKLOAD_IDENTITY_NOT_CONFIGURED`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "description": "Start a keyless credential. Returns the issuer, audience, and a fresh subject to configure in the cloud's trust policy, plus `keylessConfig`: a prefilled `serviceAccountJson` whose `subjectProof` is valid for 24 hours and only for the requesting user. Add the `requiredConfigFields`, then call Create service-account credential with the same `providerId`; Sim verifies the trust while saving. The returned `audience` is the default; set `audience` in `keylessConfig` to have Sim sign tokens for your own value instead, and trust that value in the cloud. Google Cloud has no fixed default: it is the credential's own pool provider. Without keyless support the deployment returns `400` with `details.code` `WORKLOAD_IDENTITY_NOT_CONFIGURED`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", "x-sim-operation": "credentials.workload_identity.prepare", "x-oauth-scope": "api:write", "tags": ["Credentials"], @@ -4449,7 +4449,7 @@ "subjectProof": "" }, "requiredConfigFields": ["roleArn"], - "optionalConfigFields": ["stsRegion", "sessionDurationSeconds"] + "optionalConfigFields": ["stsRegion", "sessionDurationSeconds", "audience"] } } } @@ -18154,7 +18154,7 @@ "items": { "type": "string" }, - "description": "Fields `keylessConfig` may also carry." + "description": "Fields `keylessConfig` may also carry. `audience` is a custom `aud` claim Sim signs in place of the default; your trust configuration must then accept it." } }, "required": ["authType", "requiredConfigFields", "optionalConfigFields"], @@ -18633,7 +18633,7 @@ "description": "OpenID Connect issuer URL to register as the identity provider the cloud trusts." }, "audience": { - "description": "The `aud` claim the cloud must accept. Absent for Google Cloud, whose audience is each credential's own workload identity pool provider.", + "description": "The default `aud` claim the cloud must accept; a keyless config's optional `audience` replaces it. Absent for Google Cloud, whose default audience is each credential's own workload identity pool provider.", "type": "string" }, "subject": { @@ -18668,7 +18668,7 @@ }, "required": ["authType", "subject", "subjectProof"], "additionalProperties": false, - "description": "Prefilled keyless `serviceAccountJson`: add `requiredConfigFields`, then send it to Create service-account credential." + "description": "Prefilled keyless `serviceAccountJson`: add `requiredConfigFields` (and any `optionalConfigFields`, such as a custom `audience`), then send it to Create service-account credential." }, "requiredConfigFields": { "type": "array", @@ -18682,7 +18682,7 @@ "items": { "type": "string" }, - "description": "Fields `keylessConfig` may also carry." + "description": "Fields `keylessConfig` may also carry. `audience` replaces the default `audience` above with your own value, such as one specific to your account; trust that value instead of the default." } }, "required": [ @@ -18696,7 +18696,7 @@ ], "additionalProperties": false, "title": "Credential workload identity subject", - "description": "The issuer, audience, and subject a cloud trust configuration pins, and the proof that attaches the subject to a new keyless credential." + "description": "The issuer, default audience, and subject a cloud trust configuration pins, and the proof that attaches the subject to a new keyless credential. A `keylessConfig.audience` overrides the default audience." }, "CreateCredentialWorkloadIdentitySubjectResponse": { "type": "object", @@ -18724,7 +18724,7 @@ "subjectProof": "" }, "requiredConfigFields": ["roleArn"], - "optionalConfigFields": ["stsRegion", "sessionDurationSeconds"] + "optionalConfigFields": ["stsRegion", "sessionDurationSeconds", "audience"] } } ] diff --git a/apps/docs/scripts/fixtures/content-baseline.json b/apps/docs/scripts/fixtures/content-baseline.json index 41cdbd0b4f5..374997b16ac 100644 --- a/apps/docs/scripts/fixtures/content-baseline.json +++ b/apps/docs/scripts/fixtures/content-baseline.json @@ -67119,6 +67119,13 @@ "sha256": "b867716c5b178cc345e9ec091ffd5db588c778b02f244e3e51513c9266fbaf8a", "bytes": 733, "line": 199 + }, + { + "lang": "bash", + "meta": null, + "sha256": "9027a7d2a3cca34b202d5fdb0c5166f12a6273f412dfe19b918cdedc9a330c8f", + "bytes": 115, + "line": 236 } ], "links": [ @@ -141594,6 +141601,13 @@ "bytes": 169, "line": 29 }, + { + "lang": "sql", + "meta": null, + "sha256": "8ba22e026d880965c3aa9a33b6d11244442b5057801b4d08e6d031d5178b950e", + "bytes": 86, + "line": 42 + }, { "lang": "sql", "meta": null, diff --git a/apps/sim/app/api/v2/credentials/workload-identity/subjects/route.test.ts b/apps/sim/app/api/v2/credentials/workload-identity/subjects/route.test.ts index 00f6c61d333..34db14a7e72 100644 --- a/apps/sim/app/api/v2/credentials/workload-identity/subjects/route.test.ts +++ b/apps/sim/app/api/v2/credentials/workload-identity/subjects/route.test.ts @@ -94,7 +94,7 @@ describe('POST /api/v2/credentials/workload-identity/subjects', () => { subjectProof: data.subjectProof, }, requiredConfigFields: ['roleArn'], - optionalConfigFields: ['stsRegion', 'sessionDurationSeconds'], + optionalConfigFields: ['stsRegion', 'sessionDurationSeconds', 'audience'], }) const owner = { workspaceId: WORKSPACE_ID } diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/aws-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/aws-account-modal.tsx index 273eda7eaf5..571306ea7eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/aws-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/aws-account-modal.tsx @@ -15,7 +15,7 @@ import { resourceScopeFromOwner, resourceScopeKey } from '@/lib/core/resource-sc import { AWS_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' import { KeylessTrustFields, - keylessSubjectFields, + keylessConfigFields, useKeylessSetup, useSavedConnectionSubmit, } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/keyless-connection' @@ -137,7 +137,7 @@ function AwsAccountForm({ const buildConfig = (): Record => { if (authType === 'oidc_role') { - return { authType, roleArn: roleArn.trim(), ...keylessSubjectFields(keyless) } + return { authType, roleArn: roleArn.trim(), ...keylessConfigFields(keyless) } } const keys = { accessKeyId: accessKeyId.trim(), secretAccessKey } if (authType === 'assume_role') { @@ -152,13 +152,8 @@ function AwsAccountForm({ } const trustPolicy = - authType === 'oidc_role' && keyless.trust?.audience - ? buildTrustPolicy( - keyless.trust.issuer, - keyless.trust.audience, - keyless.trust.subject, - roleArn - ) + authType === 'oidc_role' && keyless.trust && keyless.audience + ? buildTrustPolicy(keyless.trust.issuer, keyless.audience, keyless.trust.subject, roleArn) : null const action = credentialId ? 'Reconnect' : 'Add' const authOptions = [ @@ -189,7 +184,12 @@ function AwsAccountForm({ hint={AUTH_TYPE_HINTS[authType]} /> - {authType === 'oidc_role' && } + {authType === 'oidc_role' && ( + + )} {needsKeys && ( <> @@ -256,7 +256,7 @@ function AwsAccountForm({ {(aria) => ( => { const base = { authType, host: host.trim() } if (authType === 'oidc_federation') { - return { ...base, clientId: clientId.trim(), ...keylessSubjectFields(keyless) } + return { ...base, clientId: clientId.trim(), ...keylessConfigFields(keyless) } } if (authType === 'service_principal') { return { ...base, clientId: clientId.trim(), clientSecret } @@ -127,12 +127,8 @@ function DatabricksAccountForm({ } const federationCommand = - authType === 'oidc_federation' && keyless.trust?.audience - ? buildFederationPolicyCommand( - keyless.trust.issuer, - keyless.trust.audience, - keyless.trust.subject - ) + authType === 'oidc_federation' && keyless.trust && keyless.audience + ? buildFederationPolicyCommand(keyless.trust.issuer, keyless.audience, keyless.trust.subject) : null const action = credentialId ? 'Reconnect' : 'Add' const authOptions = [ @@ -214,7 +210,12 @@ function DatabricksAccountForm({ )} - {authType === 'oidc_federation' && } + {authType === 'oidc_federation' && ( + + )} {federationCommand && ( 0 ? { delegation: { user: delegatedUser, services: delegatedServices } } : {}), - ...keylessSubjectFields(setup), + ...keylessConfigFields(setup), } } @@ -130,15 +130,16 @@ export function GoogleKeylessFields({ input, onChange, setup }: GoogleKeylessFie const value = normalize(input) const trustComplete = isTrustComplete(value) const delegated = value.delegatedServices.length > 0 - const audience = trustComplete ? googleWorkloadIdentityAudience(value) : undefined + const providerAudience = trustComplete ? googleWorkloadIdentityAudience(value) : undefined const commands = - trustComplete && setup.trust + trustComplete && setup.trust && !setup.audienceError ? googleWorkloadIdentitySetupCommands({ ref: value, serviceAccountEmail: value.serviceAccountEmail, issuer: setup.trust.issuer, subject: setup.trust.subject, domainWideDelegation: delegated, + audience: setup.customAudience, }) : null const edit = (field: Exclude) => (next: string) => @@ -227,7 +228,11 @@ export function GoogleKeylessFields({ input, onChange, setup }: GoogleKeylessFie /> )} - + {commands && ( ({ credentialId, }) const trust = setup.data?.available ? setup.data : undefined + // Undefined until edited, so a reconnect opens on its stored audience once that loads. + const [audienceInput, setAudienceInput] = useState() + const audienceValue = audienceInput ?? setup.data?.reconnectAudience ?? '' + const defaultAudience = setup.data?.available ? setup.data.audience : undefined + const typedAudience = audienceValue.trim() + const typedCustom = typedAudience && typedAudience !== defaultAudience ? typedAudience : undefined + const audienceCheck = typedCustom + ? workloadIdentityAudienceSchema(provider).safeParse(typedCustom) + : undefined + const audienceError = + audienceCheck && !audienceCheck.success ? audienceCheck.error.issues[0]?.message : undefined + const customAudience = audienceError ? undefined : typedCustom const storedAuthType = providerAuthType(provider, setup.data?.reconnectAuthType) // A keyless credential reopens on keyless only where this deployment can still sign. const reconnectAuthType = @@ -87,8 +100,19 @@ export function useKeylessSetup

({ setSubjectChoice, /** The new subject to submit, or undefined when keeping the stored one. */ trust: subjectChoice === 'new' ? trust : undefined, + /** The audience field's value; empty keeps the default. */ + audienceInput: audienceValue, + setAudienceInput, + /** The provider's default audience; undefined for Google, whose default is each credential's provider. */ + defaultAudience, + /** The audience typed into the field, unless it is blank, the default, or invalid. */ + customAudience, + /** Why the typed audience can't be used; setup snippets stay hidden until it can. */ + audienceError, + /** The audience tokens carry: the custom one, else the default; undefined while invalid. */ + audience: audienceError ? undefined : (customAudience ?? defaultAudience), /** Ready to submit a keyless configuration. */ - ready: subjectChoice === 'keep' ? Boolean(credentialId) : Boolean(trust), + ready: !audienceError && (subjectChoice === 'keep' ? Boolean(credentialId) : Boolean(trust)), error: setup.isError ? setup.error : null, } } @@ -105,15 +129,25 @@ function providerAuthType

( return modes.find((mode) => mode === value) } -/** The subject fields of a keyless config: a new subject with its proof, or none to keep the stored one. */ -export function keylessSubjectFields(setup: KeylessSetup) { - return setup.trust ? { subject: setup.trust.subject, subjectProof: setup.trust.subjectProof } : {} +/** + * The trust fields of a keyless config: a new subject with its proof (none keeps the + * stored one), and the custom audience when one is set (none means the default). + */ +export function keylessConfigFields(setup: KeylessSetup) { + return { + ...(setup.trust + ? { subject: setup.trust.subject, subjectProof: setup.trust.subjectProof } + : {}), + ...(setup.customAudience ? { audience: setup.customAudience } : {}), + } } interface KeylessTrustFieldsProps { setup: KeylessSetup - /** The credential's own audience, for a provider without a fixed one (Google). */ - audience?: string + /** The default audience, for a provider without a fixed one (Google: the credential's own provider). */ + defaultAudience?: string + /** Where the provider lists the audience it accepts, appended to the field's hint. */ + audienceHint: string /** Where in the provider's console the values below are pasted. */ pasteHint?: string } @@ -121,7 +155,8 @@ interface KeylessTrustFieldsProps { /** Reconnect choice plus the issuer, audience, and subject the customer configures. */ export function KeylessTrustFields({ setup, - audience = setup.trust?.audience, + defaultAudience = setup.defaultAudience, + audienceHint, pasteHint, }: KeylessTrustFieldsProps) { return ( @@ -159,14 +194,6 @@ export function KeylessTrustFields({ copyLabel='Copy issuer' hint={pasteHint} /> - {audience && ( - - )} )} + {(setup.trust || setup.isReconnect) && ( + + )} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/microsoft-app-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/microsoft-app-account-modal.tsx index 4be33abfdc3..52d15dfb216 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/microsoft-app-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/microsoft-app-account-modal.tsx @@ -19,7 +19,7 @@ import { import { MICROSOFT_APP_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' import { KeylessTrustFields, - keylessSubjectFields, + keylessConfigFields, useKeylessSetup, useSavedConnectionSubmit, } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/keyless-connection' @@ -137,7 +137,7 @@ function MicrosoftAppAccountForm({ ...(isGlobal ? {} : { cloud: appCloud }), ...(isGlobal && environmentUrl.trim() ? { environmentUrl: environmentUrl.trim() } : {}), } - if (authType === 'federated') return { ...base, ...keylessSubjectFields(keyless) } + if (authType === 'federated') return { ...base, ...keylessConfigFields(keyless) } if (authType === 'certificate') return { ...base, privateKey, certificate } return { ...base, clientSecret } } @@ -261,6 +261,7 @@ function MicrosoftAppAccountForm({ )} diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/snowflake-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/snowflake-account-modal.tsx index 95bed3c46a1..3ba3690ecc3 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/snowflake-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/snowflake-account-modal.tsx @@ -26,7 +26,7 @@ import { import { type KeylessSetup, KeylessTrustFields, - keylessSubjectFields, + keylessConfigFields, useKeylessSetup, useSavedConnectionSubmit, } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/keyless-connection' @@ -87,14 +87,19 @@ function keylessErrorMessage(code: string): string | undefined { } } -/** The statement that creates a service user trusting only this credential's subject. */ -function buildCreateUserStatement(issuer: string, subject: string) { +/** + * The statement that creates a service user trusting only this credential's + * subject. Snowflake accepts the default `snowflakecomputing.com` audience with + * no `OIDC_AUDIENCE_LIST`; a custom audience must be listed there. + */ +function buildCreateUserStatement(issuer: string, subject: string, customAudience?: string) { return [ 'CREATE USER SIM_WORKFLOWS', ' WORKLOAD_IDENTITY = (', ' TYPE = OIDC', ` ISSUER = '${issuer}'`, ` SUBJECT = '${subject}'`, + ...(customAudience ? [` OIDC_AUDIENCE_LIST = ('${customAudience}')`] : []), ' )', ' TYPE = SERVICE;', ].join('\n') @@ -251,7 +256,7 @@ function SnowflakeAccountForm({ config: { authType: 'workload_identity', host: normalizedHost, - ...keylessSubjectFields(keyless), + ...keylessConfigFields(keyless), }, displayName, description, @@ -259,8 +264,12 @@ function SnowflakeAccountForm({ } const createUserStatement = - authType === 'workload_identity' && keyless.trust - ? buildCreateUserStatement(keyless.trust.issuer, keyless.trust.subject) + authType === 'workload_identity' && keyless.trust && !keyless.audienceError + ? buildCreateUserStatement( + keyless.trust.issuer, + keyless.trust.subject, + keyless.customAudience + ) : null const action = credentialId ? 'Reconnect' : 'Add' const authOptions = [ @@ -359,7 +368,12 @@ function SnowflakeAccountForm({ )} - {authType === 'workload_identity' && } + {authType === 'workload_identity' && ( + + )} {createUserStatement && ( import('@/app/api/v2/credentials/workload-identity/subjects/route').then( diff --git a/apps/sim/lib/credentials/application/prepare-workload-identity.test.ts b/apps/sim/lib/credentials/application/prepare-workload-identity.test.ts index c22de110c25..eedb4b1dabc 100644 --- a/apps/sim/lib/credentials/application/prepare-workload-identity.test.ts +++ b/apps/sim/lib/credentials/application/prepare-workload-identity.test.ts @@ -197,6 +197,65 @@ const STORED_MODES = [ }, ] as const +const CUSTOM_AUDIENCE = 'sim-123456789012' + +/** One stored keyless secret per provider, each carrying {@link CUSTOM_AUDIENCE}. */ +const CUSTOM_AUDIENCE_SECRETS = [ + { + provider: 'aws', + providerId: 'aws-service-account', + secret: { + type: 'aws_credential_v1', + config: { authType: 'oidc_role', roleArn, subject, audience: CUSTOM_AUDIENCE }, + }, + }, + { + provider: 'databricks', + providerId: 'databricks-service-account', + secret: { + type: 'databricks_credential_v1', + config: { + authType: 'oidc_federation', + host: databricksHost, + clientId, + subject, + audience: CUSTOM_AUDIENCE, + }, + }, + }, + { + provider: 'snowflake', + providerId: 'snowflake-service-account', + secret: { + ...keylessSecret, + config: { ...keylessSecret.config, audience: CUSTOM_AUDIENCE }, + }, + }, + { + provider: 'microsoft', + providerId: 'microsoft-app-service-account', + secret: { + type: 'microsoft_app_credential_v1', + config: { ...appConfig, authType: 'federated', subject, audience: CUSTOM_AUDIENCE }, + }, + }, + { + provider: 'google', + providerId: 'google-service-account', + secret: { + type: 'google_workload_identity_v1', + config: { + projectNumber: '123456789012', + poolId: 'sim-pool', + providerId: 'sim-oidc', + serviceAccountEmail: 'runner@analytics-prod.iam.gserviceaccount.com', + subject, + audience: CUSTOM_AUDIENCE, + }, + }, + }, +] as const + describe('preparing a reconnect', () => { beforeEach(() => { workspaceContextMockFns.mockLoadActiveWorkspaceApplicationContext.mockResolvedValue({ @@ -219,6 +278,25 @@ describe('preparing a reconnect', () => { } ) + it.each(CUSTOM_AUDIENCE_SECRETS)( + 'reopens a $provider keyless credential on its custom audience', + async ({ provider, providerId, secret }) => { + reconnecting(secret, { providerId }) + await expect(prepare(provider)).resolves.toMatchObject({ + available: true, + reconnectAudience: CUSTOM_AUDIENCE, + }) + } + ) + + it.each(STORED_MODES)( + 'reports no audience for a $provider credential stored as $expected', + async ({ provider, providerId, secret }) => { + reconnecting(secret, { providerId }) + expect(await prepare(provider)).not.toHaveProperty('reconnectAudience') + } + ) + it('reports the stored mode when this deployment cannot sign, so keyed modes still reopen', async () => { Reflect.set(env, 'WORKLOAD_IDENTITY_SIGNING_KEY', undefined) try { diff --git a/apps/sim/lib/credentials/application/prepare-workload-identity.ts b/apps/sim/lib/credentials/application/prepare-workload-identity.ts index 73a02f8535e..2cee3688fde 100644 --- a/apps/sim/lib/credentials/application/prepare-workload-identity.ts +++ b/apps/sim/lib/credentials/application/prepare-workload-identity.ts @@ -4,16 +4,15 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { decryptSecret } from '@/lib/core/security/encryption' import { getCredentialActorContext } from '@/lib/credentials/access' import { credentialOperations } from '@/lib/credentials/application/operations' -import { storedAwsAuthType } from '@/lib/credentials/aws-service-account.server' -import { storedDatabricksAuthType } from '@/lib/credentials/databricks-service-account.server' -import { isGoogleWorkloadIdentitySecret } from '@/lib/credentials/google-workload-identity' +import { storedAwsReconnectFields } from '@/lib/credentials/aws-service-account.server' +import { storedDatabricksReconnectFields } from '@/lib/credentials/databricks-service-account.server' import { type GoogleStoredDelegation, - storedGoogleDelegation, + storedGoogleReconnectFields, } from '@/lib/credentials/google-workload-identity.server' import { storedMicrosoftAppReconnectFields } from '@/lib/credentials/microsoft-app-service-account.server' import type { MicrosoftCloudId } from '@/lib/credentials/microsoft-clouds' -import { storedSnowflakeAuthType } from '@/lib/credentials/snowflake-service-account.server' +import { storedSnowflakeReconnectFields } from '@/lib/credentials/snowflake-service-account.server' import { fixedWorkloadIdentityAudience, isWorkloadIdentityServiceAccountProviderId, @@ -46,6 +45,8 @@ interface ReconnectFields

( - read: (decrypted: string) => WorkloadIdentityProviderAuthType

| undefined -): (decrypted: string) => ReconnectFields

{ - return (decrypted) => { - const reconnectAuthType = read(decrypted) - return reconnectAuthType ? { reconnectAuthType } : {} - } -} - -function storedMicrosoftAppReconnect(decrypted: string): ReconnectFields<'microsoft'> { - const { authType, cloud } = storedMicrosoftAppReconnectFields(decrypted) - return { - ...(authType ? { reconnectAuthType: authType } : {}), - ...(cloud ? { reconnectCloud: cloud } : {}), - } +/** What a provider's stored-secret reader returns: the non-secret fields a reconnect keeps. */ +interface StoredReconnectRead

{ + authType?: WorkloadIdentityProviderAuthType

+ audience?: string + cloud?: MicrosoftCloudId + delegation?: GoogleStoredDelegation } -function storedGoogleReconnect(decrypted: string): ReconnectFields<'google'> { - const reconnectDelegation = storedGoogleDelegation(decrypted) - return { - reconnectAuthType: isGoogleWorkloadIdentitySecret(decrypted) ? 'workload_identity' : 'json_key', - ...(reconnectDelegation ? { reconnectDelegation } : {}), +function reconnectFrom

( + read: (decrypted: string) => StoredReconnectRead

+): (decrypted: string) => ReconnectFields

{ + return (decrypted) => { + const { authType, audience, cloud, delegation } = read(decrypted) + return { + ...(authType ? { reconnectAuthType: authType } : {}), + ...(audience ? { reconnectAudience: audience } : {}), + ...(cloud ? { reconnectCloud: cloud } : {}), + ...(delegation ? { reconnectDelegation: delegation } : {}), + } } } @@ -91,16 +89,16 @@ function storedGoogleReconnect(decrypted: string): ReconnectFields<'google'> { * on a default that would drop the credential's role, secret, or certificate. A * provider whose mode has further non-secret settings a reconnect must keep (a * Google credential's domain-wide delegation, a Microsoft app credential's national - * cloud) reads those here too. + * cloud, a keyless credential's custom audience) reads those here too. */ const STORED_RECONNECT: { [P in WorkloadIdentityProvider]: (decrypted: string) => ReconnectFields

} = { - aws: authTypeOnly<'aws'>(storedAwsAuthType), - databricks: authTypeOnly<'databricks'>(storedDatabricksAuthType), - snowflake: authTypeOnly<'snowflake'>(storedSnowflakeAuthType), - microsoft: storedMicrosoftAppReconnect, - google: storedGoogleReconnect, + aws: reconnectFrom<'aws'>(storedAwsReconnectFields), + databricks: reconnectFrom<'databricks'>(storedDatabricksReconnectFields), + snowflake: reconnectFrom<'snowflake'>(storedSnowflakeReconnectFields), + microsoft: reconnectFrom<'microsoft'>(storedMicrosoftAppReconnectFields), + google: reconnectFrom<'google'>(storedGoogleReconnectFields), } /** diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts index 4216a302d5f..1933f5089cc 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -176,7 +176,7 @@ function withKeylessMode( if (!mode) return descriptor const { keyless, scope } = mode const scopeNote = scope ? ` ${scope}` : '' - const hint = `Keyless: send the keylessConfig from Create workload identity subject with ${keyless.requiredConfigFields.join(', ')} added.${scopeNote}` + const hint = `Keyless: send the keylessConfig from Create workload identity subject with ${keyless.requiredConfigFields.join(', ')} added. Optionally add audience to have Sim sign for your own value instead of the default audience (for Google Cloud, the workload identity provider's resource name), and trust that value.${scopeNote}` const configurationField = descriptor.fields.find((field) => field.id === 'serviceAccountJson') const fields = configurationField ? descriptor.fields.map((field) => diff --git a/apps/sim/lib/credentials/aws-service-account.server.test.ts b/apps/sim/lib/credentials/aws-service-account.server.test.ts index 9479f69c189..f99f068f03d 100644 --- a/apps/sim/lib/credentials/aws-service-account.server.test.ts +++ b/apps/sim/lib/credentials/aws-service-account.server.test.ts @@ -136,6 +136,75 @@ describe('saving a keyless AWS role', () => { }) }) +describe('choosing the audience of a keyless AWS role', () => { + const CUSTOM_AUDIENCE = 'sim-123456789012' + + beforeEach(() => { + hoisted.send.mockReset() + hoisted.mint.mockClear() + encryptionMockFns.mockEncryptSecret.mockReset() + encryptionMockFns.mockEncryptSecret.mockResolvedValue({ encrypted: 'ciphertext', iv: 'iv' }) + roleTrusting(true) + }) + + function reconnectWith(audience?: string) { + return JSON.stringify({ + authType: 'oidc_role', + roleArn: ROLE_ARN, + ...(audience ? { audience } : {}), + }) + } + + function storedConfig() { + return JSON.parse(encryptionMockFns.mockEncryptSecret.mock.calls[0][0]).config + } + + it('verifies and probes the trust pin with a custom audience, and stores it', async () => { + await verifyAndEncryptAwsCredential(reconnectWith(CUSTOM_AUDIENCE), keylessReconnect.owner) + + expect(hoisted.mint.mock.calls.length).toBeGreaterThanOrEqual(2) + for (const [input] of hoisted.mint.mock.calls) { + expect(input).toMatchObject({ audience: CUSTOM_AUDIENCE }) + } + expect(hoisted.mint).toHaveBeenCalledWith(expect.objectContaining({ subject: SUBJECT })) + expect(hoisted.mint).toHaveBeenCalledWith( + expect.objectContaining({ subject: expect.not.stringMatching(SUBJECT) }) + ) + expect(storedConfig()).toMatchObject({ audience: CUSTOM_AUDIENCE }) + }) + + it.each([undefined, 'sts.amazonaws.com'])( + 'mints the default audience and stores no audience key when given %s', + async (audience) => { + await verifyAndEncryptAwsCredential(reconnectWith(audience), keylessReconnect.owner) + + expect(hoisted.mint).toHaveBeenCalled() + for (const [input] of hoisted.mint.mock.calls) { + expect(input).toMatchObject({ audience: 'sts.amazonaws.com' }) + } + expect(storedConfig()).not.toHaveProperty('audience') + } + ) + + it('refuses another cloud’s default audience before signing anything', async () => { + await expect( + verifyAndEncryptAwsCredential( + reconnectWith('api://AzureADTokenExchange'), + keylessReconnect.owner + ) + ).rejects.toThrow(/another cloud/) + expect(hoisted.mint).not.toHaveBeenCalled() + }) + + it('names the custom audience when the role refuses it', async () => { + hoisted.send.mockRejectedValue(accessDenied()) + + await expect( + verifyAndEncryptAwsCredential(reconnectWith(CUSTOM_AUDIENCE), keylessReconnect.owner) + ).rejects.toThrow(`audience ${CUSTOM_AUDIENCE}`) + }) +}) + describe('loading a saved AWS credential for a run', () => { beforeEach(() => resetDbChainMock()) @@ -194,4 +263,20 @@ describe("resolving a run's AWS session", () => { expect(hoisted.send.mock.calls[0][0].input.DurationSeconds).toBe(durationSeconds) } ) + + it.each([ + { stored: {}, audience: 'sts.amazonaws.com' }, + { stored: { audience: 'sim-123456789012' }, audience: 'sim-123456789012' }, + ])('mints a run token for the stored audience $audience', async ({ stored, audience }) => { + hoisted.mint.mockClear() + hoisted.send.mockResolvedValue({ Credentials: { ...session, Expiration: expiration } }) + + await resolveAwsSessionCredentials( + { authType: 'oidc_role', roleArn: ROLE_ARN, subject: SUBJECT, ...stored }, + owner, + options + ) + + expect(hoisted.mint).toHaveBeenCalledWith(expect.objectContaining({ audience })) + }) }) diff --git a/apps/sim/lib/credentials/aws-service-account.server.ts b/apps/sim/lib/credentials/aws-service-account.server.ts index 6bb817a7edb..513bd7ab74b 100644 --- a/apps/sim/lib/credentials/aws-service-account.server.ts +++ b/apps/sim/lib/credentials/aws-service-account.server.ts @@ -12,7 +12,12 @@ import { z } from 'zod' import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { failureClause, parseServiceAccountConfig } from '@/lib/credentials/service-account-config' -import { WORKLOAD_IDENTITY_AUDIENCES } from '@/lib/credentials/workload-identity/audiences' +import { + storedAudienceField, + storedWorkloadIdentityAudienceSchema, + WORKLOAD_IDENTITY_AUDIENCES, + workloadIdentityAudienceSchema, +} from '@/lib/credentials/workload-identity/audiences' import { isWorkloadIdentityConfigured, mintWorkloadIdentityToken, @@ -43,6 +48,8 @@ import { AWS_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' * - `assume_role`: a key pair that assumes a role, optionally with an external ID. * - `oidc_role`: no stored key at all — Sim signs a short-lived OIDC token for * this credential's subject and exchanges it with STS for the customer's role. + * Its `aud` is the stored custom `audience`, else `sts.amazonaws.com`; either + * must be a client ID on the customer's IAM OIDC identity provider. */ const AWS_WORKLOAD_IDENTITY_AUDIENCE = WORKLOAD_IDENTITY_AUDIENCES.aws @@ -85,6 +92,8 @@ const oidcRoleShape = { authType: z.literal('oidc_role'), roleArn: roleArnSchema, subject: z.string().min(1).max(512), + /** Absent for the default audience, so earlier releases' strict schemas still parse it. */ + audience: storedWorkloadIdentityAudienceSchema, ...commonShape, } @@ -108,6 +117,7 @@ const submittedConfigSchema = z.discriminatedUnion('authType', [ ...oidcRoleShape, subject: oidcRoleShape.subject.optional(), subjectProof: z.string().min(1).max(512).optional(), + audience: workloadIdentityAudienceSchema('aws').optional(), }) .strict(), ]) @@ -200,7 +210,7 @@ export async function resolveAwsSessionCredentials( } const webIdentityToken = await mintWorkloadIdentityToken({ subject: config.subject, - audience: AWS_WORKLOAD_IDENTITY_AUDIENCE, + audience: config.audience ?? AWS_WORKLOAD_IDENTITY_AUDIENCE, owner, }) /** @@ -225,13 +235,23 @@ export async function resolveAwsSessionCredentials( return toSessionCredentials(response.Credentials, durationSeconds) } -/** The auth mode a decrypted AWS secret holds, or undefined when it is not one. */ -export function storedAwsAuthType(decrypted: string): AwsCredentialConfig['authType'] | undefined { +/** + * A decrypted AWS secret's auth mode and, for a keyless role, its custom + * audience, for a reconnect to prefill. Empty when the secret is not one. + */ +export function storedAwsReconnectFields(decrypted: string): { + authType?: AwsCredentialConfig['authType'] + audience?: string +} { try { const parsed = storedSecretSchema.safeParse(JSON.parse(decrypted)) - return parsed.success ? parsed.data.config.authType : undefined + if (!parsed.success) return {} + const { config } = parsed.data + return config.authType === 'oidc_role' + ? { authType: config.authType, audience: config.audience } + : { authType: config.authType } } catch { - return undefined + return {} } } @@ -263,7 +283,7 @@ function verificationFailure(config: AwsCredentialConfig, error: unknown): Error const detail = failureClause(error) if (config.authType === 'oidc_role') { return new AwsCredentialError( - `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}.` + `Sim could not assume this role: ${detail}. Check that the role trusts the Sim OIDC provider with this exact subject and audience ${config.audience ?? AWS_WORKLOAD_IDENTITY_AUDIENCE}, and that the IAM identity provider lists that audience.` ) } if (config.authType === 'assume_role') { @@ -292,10 +312,11 @@ export async function verifyAndEncryptAwsCredential( if (!(await isWorkloadIdentityConfigured())) { throw new WorkloadIdentityNotConfiguredError('Use access keys.') } - const { subject, subjectProof, ...rest } = submitted + const { subject, subjectProof, audience, ...rest } = submitted config = { ...rest, subject: bindSubject(subject, subjectProof, readStoredSubject(owner.storedSecret), owner), + ...storedAudienceField(audience, AWS_WORKLOAD_IDENTITY_AUDIENCE), } } else { config = submitted diff --git a/apps/sim/lib/credentials/databricks-service-account.server.test.ts b/apps/sim/lib/credentials/databricks-service-account.server.test.ts index 420689c5606..50a8fb721ab 100644 --- a/apps/sim/lib/credentials/databricks-service-account.server.test.ts +++ b/apps/sim/lib/credentials/databricks-service-account.server.test.ts @@ -175,6 +175,171 @@ describe('saving a keyless Databricks connection', () => { }) }) +describe('a keyless Databricks connection with its own audience', () => { + const HOST = 'dbc-1234abcd-5678.cloud.databricks.com' + const CLIENT_ID = '00000000-0000-0000-0000-000000000000' + const SUBJECT = 'sim:workspace:ws-1:credential:abcdef0123456789' + const ACCOUNT_ID = '11111111-2222-3333-4444-555555555555' + const fetch = vi.fn() + const config = { authType: 'oidc_federation', host: HOST, clientId: CLIENT_ID } + const reconnectOwner = { + ...owner, + storedSecret: { type: 'databricks_credential_v1', config: { ...config, subject: SUBJECT } }, + } + + /** A federation policy that pins {@link SUBJECT} and lists only `audience`. */ + function policyTrusting(audience: string) { + hoisted.mint.mockImplementation( + async ({ subject, audience: aud }: { subject: string; audience?: string }) => + `token-for:${subject}:${aud}` + ) + fetch.mockImplementation(async (url: string, init: RequestInit) => { + if (url.endsWith('/oidc/v1/token')) { + const body = new URLSearchParams(String(init.body)) + return body.get('subject_token') === `token-for:${SUBJECT}:${audience}` + ? jsonResponse({ access_token: 'dbx-token' }) + : jsonResponse({ error: 'invalid_grant' }, 401) + } + return jsonResponse({ userName: 'sim-workflows' }) + }) + } + + function storedConfig(): Record { + const [plaintext] = encryptionMockFns.mockEncryptSecret.mock.calls[0] as [string] + return JSON.parse(plaintext).config + } + + function mintedAudiences(): unknown[] { + return hoisted.mint.mock.calls.map(([input]) => (input as { audience?: string }).audience) + } + + beforeEach(() => { + fetch.mockReset() + vi.stubGlobal('fetch', fetch) + hoisted.mint.mockClear() + encryptionMockFns.mockEncryptSecret.mockReset() + encryptionMockFns.mockEncryptSecret.mockResolvedValue({ encrypted: 'ciphertext', iv: 'iv' }) + }) + + it('verifies, probes, and stores the custom audience', async () => { + policyTrusting(ACCOUNT_ID) + + await expect( + verifyAndEncryptDatabricksCredential( + JSON.stringify({ ...config, audience: ` ${ACCOUNT_ID} ` }), + reconnectOwner + ) + ).resolves.toMatchObject({ identity: 'sim-workflows' }) + // The verification and the any-subject probe both carry the audience real runs use. + expect(hoisted.mint).toHaveBeenCalledTimes(2) + expect(mintedAudiences()).toEqual([ACCOUNT_ID, ACCOUNT_ID]) + expect(storedConfig()).toMatchObject({ audience: ACCOUNT_ID }) + }) + + it.each([ + ['no audience', {}], + ['the default audience', { audience: 'sim-databricks' }], + ])('mints the default and stores no audience when given %s', async (_case, audience) => { + policyTrusting('sim-databricks') + + await verifyAndEncryptDatabricksCredential( + JSON.stringify({ ...config, ...audience }), + reconnectOwner + ) + expect(mintedAudiences()).toEqual(['sim-databricks', 'sim-databricks']) + expect(storedConfig()).not.toHaveProperty('audience') + }) + + it('goes back to the default when a reconnect clears a custom audience', async () => { + policyTrusting('sim-databricks') + const customOwner = { + ...owner, + storedSecret: { + type: 'databricks_credential_v1', + config: { ...config, subject: SUBJECT, audience: ACCOUNT_ID }, + }, + } + + await verifyAndEncryptDatabricksCredential(JSON.stringify(config), customOwner) + expect(mintedAudiences()).toEqual(['sim-databricks', 'sim-databricks']) + expect(storedConfig()).not.toHaveProperty('audience') + }) + + it('refuses another cloud’s default audience before calling Databricks', async () => { + await expect( + verifyAndEncryptDatabricksCredential( + JSON.stringify({ ...config, audience: 'sts.amazonaws.com' }), + reconnectOwner + ) + ).rejects.toThrow(/belongs to another cloud/) + expect(fetch).not.toHaveBeenCalled() + expect(hoisted.mint).not.toHaveBeenCalled() + }) + + it('names the audience it sent when the policy does not trust it', async () => { + policyTrusting('sim-databricks') + + await expect( + verifyAndEncryptDatabricksCredential( + JSON.stringify({ ...config, audience: ACCOUNT_ID }), + reconnectOwner + ) + ).rejects.toThrow(ACCOUNT_ID) + }) +}) + +describe('getting a keyless Databricks token for a run', () => { + const fetch = vi.fn() + + function storeKeyless(extra: Record) { + resetDbChainMock() + queueTableRows(credential, [ + { + workspaceId: 'ws-1', + organizationId: null, + type: 'service_account', + providerId: DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID, + revokedAt: null, + encryptedServiceAccountKey: 'ciphertext', + }, + ]) + encryptionMockFns.mockDecryptSecret.mockResolvedValueOnce({ + decrypted: JSON.stringify({ + type: 'databricks_credential_v1', + config: { + authType: 'oidc_federation', + host: 'dbc-1234abcd-5678.cloud.databricks.com', + clientId: '00000000-0000-0000-0000-000000000000', + subject: 'sim:workspace:ws-1:credential:abcdef0123456789', + ...extra, + }, + }), + }) + } + + beforeEach(() => { + hoisted.mint.mockClear() + fetch.mockReset().mockResolvedValue(jsonResponse({ access_token: 'dbx-token' })) + vi.stubGlobal('fetch', fetch) + }) + + it('mints with the stored custom audience', async () => { + storeKeyless({ audience: 'acct-1234' }) + + await resolveDatabricksCredentialToken('cred-1') + expect(hoisted.mint).toHaveBeenCalledWith(expect.objectContaining({ audience: 'acct-1234' })) + }) + + it('mints exactly the old default for a credential saved before audiences were configurable', async () => { + storeKeyless({}) + + await resolveDatabricksCredentialToken('cred-1') + expect(hoisted.mint).toHaveBeenCalledWith( + expect.objectContaining({ audience: 'sim-databricks' }) + ) + }) +}) + describe('getting a Databricks token for a run', () => { const fetch = vi.fn() diff --git a/apps/sim/lib/credentials/databricks-service-account.server.ts b/apps/sim/lib/credentials/databricks-service-account.server.ts index 6281a15be41..3354bead8dc 100644 --- a/apps/sim/lib/credentials/databricks-service-account.server.ts +++ b/apps/sim/lib/credentials/databricks-service-account.server.ts @@ -11,7 +11,12 @@ import { providerFailureCode, TokenServiceAccountValidationError, } from '@/lib/credentials/token-service-accounts/errors' -import { WORKLOAD_IDENTITY_AUDIENCES } from '@/lib/credentials/workload-identity/audiences' +import { + storedAudienceField, + storedWorkloadIdentityAudienceSchema, + WORKLOAD_IDENTITY_AUDIENCES, + workloadIdentityAudienceSchema, +} from '@/lib/credentials/workload-identity/audiences' import { isWorkloadIdentityConfigured, MAX_WORKLOAD_IDENTITY_TOKEN_TTL_SECONDS, @@ -35,14 +40,16 @@ import { DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' * client ID and secret mint a workspace token per run. * - `oidc_federation`: no stored secret — Sim signs a short-lived OIDC token * for this credential's subject and exchanges it (RFC 8693) for a token of - * the service principal whose federation policy trusts it. + * the service principal whose federation policy trusts it. The token's `aud` + * is the credential's own `audience` when set (one the customer's federation + * policy lists, such as their account ID), else the Sim default. * * Every mode resolves server-side to a bearer token plus the workspace host * (`accessToken`, `domain`), which fill the tools' `apiKey` and `host`. */ const DATABRICKS_CREDENTIAL_SECRET_TYPE = 'databricks_credential_v1' as const -const DATABRICKS_WORKLOAD_IDENTITY_AUDIENCE = WORKLOAD_IDENTITY_AUDIENCES.databricks +const DEFAULT_DATABRICKS_AUDIENCE = WORKLOAD_IDENTITY_AUDIENCES.databricks const REQUEST_TIMEOUT_MS = 15_000 /** Token and identity responses are small; the cap keeps a misbehaving host from exhausting memory. */ @@ -79,6 +86,8 @@ const oidcShape = { host: hostSchema, clientId: clientIdSchema, subject: z.string().min(1).max(512), + /** Absent for the default audience, so those credentials keep the shape earlier releases read. */ + audience: storedWorkloadIdentityAudienceSchema, } const patSchema = z @@ -114,6 +123,7 @@ const submittedConfigSchema = z.discriminatedUnion('authType', [ ...oidcShape, subject: oidcShape.subject.optional(), subjectProof: z.string().min(1).max(512).optional(), + audience: workloadIdentityAudienceSchema('databricks').optional(), }) .strict(), ]) @@ -137,7 +147,14 @@ async function requestWorkspaceToken( host: string, body: URLSearchParams, signal: AbortSignal | undefined, - headers: Record = {} + { + headers = {}, + refusalDetail = '', + }: { + headers?: Record + /** Appended to a refusal, naming what the federation policy must match. */ + refusalDetail?: string + } = {} ): Promise { const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS) const url = `${workspaceOrigin(host)}/oidc/v1/token` @@ -184,7 +201,7 @@ async function requestWorkspaceToken( ? payload.error : `HTTP ${response.status}` throw new DatabricksCredentialError( - `Databricks rejected the token request: ${reason}`, + `Databricks rejected the token request: ${reason}${refusalDetail}`, response.status ) } @@ -204,12 +221,13 @@ async function resolveDatabricksAccessToken( config.host, new URLSearchParams({ grant_type: 'client_credentials', scope: 'all-apis' }), signal, - { Authorization: `Basic ${basic}` } + { headers: { Authorization: `Basic ${basic}` } } ) } + const audience = config.audience ?? DEFAULT_DATABRICKS_AUDIENCE const subjectToken = await mintWorkloadIdentityToken({ subject: config.subject, - audience: DATABRICKS_WORKLOAD_IDENTITY_AUDIENCE, + audience, owner, // Databricks' token expires with this one, and Genie polls for up to five minutes. ttlSeconds: MAX_WORKLOAD_IDENTITY_TOKEN_TTL_SECONDS, @@ -223,19 +241,25 @@ async function resolveDatabricksAccessToken( grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', scope: 'all-apis', }), - signal + signal, + { refusalDetail: ` (the federation policy's audiences must include ${audience})` } ) } -/** The auth mode a decrypted Databricks secret holds, or undefined when it is not one. */ -export function storedDatabricksAuthType( - decrypted: string -): DatabricksCredentialConfig['authType'] | undefined { +/** The non-secret fields a reconnect keeps from a decrypted Databricks secret: its mode and custom audience. */ +export function storedDatabricksReconnectFields(decrypted: string): { + authType?: DatabricksCredentialConfig['authType'] + audience?: string +} { try { const parsed = storedSecretSchema.safeParse(JSON.parse(decrypted)) - return parsed.success ? parsed.data.config.authType : undefined + if (!parsed.success) return {} + const { config } = parsed.data + return config.authType === 'oidc_federation' && config.audience + ? { authType: config.authType, audience: config.audience } + : { authType: config.authType } } catch { - return undefined + return {} } } @@ -356,8 +380,12 @@ export async function verifyAndEncryptDatabricksCredential( 'Use a personal access token or a service principal OAuth secret.' ) } - const { subject, subjectProof, ...rest } = submitted - config = { ...rest, subject: bindSubject(subject, subjectProof, owner) } + const { subject, subjectProof, audience, ...rest } = submitted + config = { + ...rest, + subject: bindSubject(subject, subjectProof, owner), + ...storedAudienceField(audience, DEFAULT_DATABRICKS_AUDIENCE), + } } else { config = submitted } diff --git a/apps/sim/lib/credentials/google-workload-identity.server.test.ts b/apps/sim/lib/credentials/google-workload-identity.server.test.ts index 36f70a3bb15..0361b6ebe1c 100644 --- a/apps/sim/lib/credentials/google-workload-identity.server.test.ts +++ b/apps/sim/lib/credentials/google-workload-identity.server.test.ts @@ -92,6 +92,8 @@ function fakeGoogle( firstTokenError?: Response /** Answers a Workspace read instead of 200. */ readError?: Response + /** The provider's allowed audiences; empty accepts only its own resource name, as Google does. */ + allowedAudiences?: string[] } = {} ) { const sts: RecordedRequest[] = [] @@ -146,10 +148,10 @@ function fakeGoogle( sts.push(request) if (options.stsError) return options.stsError.clone() const [, audience, subject] = String(request.body.subjectToken).split('|') - if ( - audience !== `https://iam.googleapis.com/${PROVIDER_NAME}` || - options.acceptSubject?.(subject) === false - ) { + const audienceAccepted = options.allowedAudiences?.length + ? options.allowedAudiences.includes(audience) + : audience === `https://iam.googleapis.com/${PROVIDER_NAME}` + if (!audienceAccepted || options.acceptSubject?.(subject) === false) { return jsonResponse( { error: 'invalid_grant', error_description: 'The audience in ID Token does not match' }, 400 @@ -377,7 +379,7 @@ describe('saving a keyless Google Cloud credential', () => { const error: Error = await save({}).catch((caught) => caught) expect(error.message).toMatch(/Google Cloud|service account/) expect(error.message).not.toContain(leaked) - expect(error.message).not.toContain('sim-oidc') + expect(error.message).not.toContain('sim-oidc|') expect(error.message).not.toContain('fed:') } }) @@ -1014,3 +1016,106 @@ describe('reading a stored keyless secret', () => { expect(fetch).not.toHaveBeenCalled() }) }) + +describe('a keyless credential with its own audience', () => { + const fetch = vi.fn() + const CUSTOM_AUDIENCE = 'https://sim.example.com/acme-prod' + const customSecret = JSON.stringify({ + type: 'google_workload_identity_v1', + config: { ...storedConfig, audience: CUSTOM_AUDIENCE }, + }) + + beforeEach(() => { + fetch.mockReset() + vi.stubGlobal('fetch', fetch) + workloadIdentityConfigured.value = true + encryptionMockFns.mockEncryptSecret.mockReset() + encryptionMockFns.mockEncryptSecret.mockResolvedValue({ encrypted: 'ciphertext', iv: 'iv' }) + }) + + it('signs the verification and the pin probe for that audience, still exchanging at the provider', async () => { + const google = fakeGoogle(fetch, { + allowedAudiences: [CUSTOM_AUDIENCE], + acceptSubject: (subject) => subject === SUBJECT, + }) + + await expect(save({ audience: ` ${CUSTOM_AUDIENCE} ` })).resolves.toMatchObject({ + serviceAccountEmail: SA_EMAIL, + }) + expect(google.sts.length).toBeGreaterThanOrEqual(2) + for (const request of google.sts) { + expect(String(request.body.subjectToken).split('|')[1]).toBe(CUSTOM_AUDIENCE) + expect(request.body.audience).toBe(`//iam.googleapis.com/${PROVIDER_NAME}`) + } + expect(google.sts.some((request) => !String(request.body.subjectToken).endsWith(SUBJECT))).toBe( + true + ) + expect(encryptionMockFns.mockEncryptSecret).toHaveBeenCalledWith( + JSON.stringify({ + type: 'google_workload_identity_v1', + config: { ...storedConfig, audience: CUSTOM_AUDIENCE }, + }) + ) + }) + + it('refuses the pin when a stray subject is accepted for that audience', async () => { + fakeGoogle(fetch, { allowedAudiences: [CUSTOM_AUDIENCE] }) + + await expect(save({ audience: CUSTOM_AUDIENCE })).rejects.toThrow( + /also trusts other Sim credentials/ + ) + }) + + it.each([`https://iam.googleapis.com/${PROVIDER_NAME}`, `//iam.googleapis.com/${PROVIDER_NAME}`])( + 'stores no audience when %s, the provider’s own name, is submitted', + async (audience) => { + fakeGoogle(fetch, { acceptSubject: (subject) => subject === SUBJECT }) + + await expect(save({ audience })).resolves.toMatchObject({ serviceAccountEmail: SA_EMAIL }) + expect(encryptionMockFns.mockEncryptSecret).toHaveBeenCalledWith( + JSON.stringify({ type: 'google_workload_identity_v1', config: storedConfig }) + ) + } + ) + + it('refuses another cloud’s default audience without contacting Google', async () => { + fakeGoogle(fetch) + + await expect(save({ audience: 'sts.amazonaws.com' })).rejects.toThrow( + /belongs to another cloud/ + ) + expect(fetch).not.toHaveBeenCalled() + }) + + it('names the custom audience to allow when STS refuses the token', async () => { + fakeGoogle(fetch) + + await expect(save({ audience: CUSTOM_AUDIENCE })).rejects.toThrow( + `with ${CUSTOM_AUDIENCE} in its allowed audiences` + ) + }) + + it('names the provider’s own name when STS refuses a default-audience token', async () => { + fakeGoogle(fetch, { allowedAudiences: [CUSTOM_AUDIENCE] }) + + await expect(save({})).rejects.toThrow( + `with no allowed audiences, or with https://iam.googleapis.com/${PROVIDER_NAME} among them` + ) + }) + + it('mints run tokens for the stored audience', async () => { + const google = fakeGoogle(fetch, { allowedAudiences: [CUSTOM_AUDIENCE] }) + + await expect( + resolveGoogleWorkloadIdentityToken({ + credentialId: 'cred-custom-audience', + decrypted: customSecret, + secretVersion: 'v1', + owner: { workspaceId: 'ws-1' }, + scopes: [BIGQUERY], + service: 'google-bigquery', + }) + ).resolves.toBe(`ya29.sa-token-for-${BIGQUERY}`) + expect(google.sts[0]?.body.subjectToken).toBe(`sim-oidc|${CUSTOM_AUDIENCE}|${SUBJECT}`) + }) +}) diff --git a/apps/sim/lib/credentials/google-workload-identity.server.ts b/apps/sim/lib/credentials/google-workload-identity.server.ts index 5ea3e858dbf..1d7ef75254e 100644 --- a/apps/sim/lib/credentials/google-workload-identity.server.ts +++ b/apps/sim/lib/credentials/google-workload-identity.server.ts @@ -27,6 +27,11 @@ import { providerFailureCode, TokenServiceAccountValidationError, } from '@/lib/credentials/token-service-accounts/errors' +import { + storedAudienceField, + storedWorkloadIdentityAudienceSchema, + workloadIdentityAudienceSchema, +} from '@/lib/credentials/workload-identity/audiences' import { isWorkloadIdentityConfigured, mintWorkloadIdentityToken, @@ -181,9 +186,18 @@ function knownDelegatedServices(delegation: StoredDelegation): GoogleDelegationS return GOOGLE_DELEGATION_SERVICES.filter((service) => delegation.services.includes(service)) } -/** Absent on every credential saved without delegation, so their stored secret is unchanged. */ +/** + * `delegation` and `audience` are absent on every credential saved without them, so + * their stored secret is unchanged and earlier releases' strict schema still reads it. + */ const storedConfigSchema = z - .object({ ...refShape, subject: subjectSchema, delegation: storedDelegationSchema.optional() }) + .object({ + ...refShape, + subject: subjectSchema, + delegation: storedDelegationSchema.optional(), + /** A custom `aud` for Sim's token; absent means the provider's resource name. */ + audience: storedWorkloadIdentityAudienceSchema, + }) .strict() type GoogleWorkloadIdentityConfig = z.infer @@ -217,6 +231,7 @@ const submittedConfigSchema = z subject: z.string().min(1).max(512).optional(), subjectProof: z.string().min(1).max(512).optional(), delegation: submittedDelegationSchema.optional(), + audience: workloadIdentityAudienceSchema('google').optional(), }) .strict() @@ -303,24 +318,35 @@ function isProviderOutage(status: number): boolean { return providerFailureCode(status) === 'provider_unavailable' } -const STS_REFUSED_MESSAGE = - 'Google Cloud refused Sim’s token for this workload identity provider. Check the project number, pool ID, and provider ID; that the provider is enabled and is an OIDC provider for the issuer from Sim (the connect dialog or `sim credentials keyless-subject`), with no allowed audiences and the attribute mapping google.subject=assertion.sub; and that its attribute condition allows this credential’s subject.' +function stsRefusedMessage(config: GoogleWorkloadIdentityConfig): string { + const audiences = config.audience + ? `with ${config.audience} in its allowed audiences` + : `with no allowed audiences, or with ${googleWorkloadIdentityAudience(config)} among them` + return `Google Cloud refused Sim’s token for this workload identity provider. Check the project number, pool ID, and provider ID; that the provider is enabled and is an OIDC provider for the issuer from Sim (the connect dialog or \`sim credentials keyless-subject\`), ${audiences}, and the attribute mapping google.subject=assertion.sub; and that its attribute condition allows this credential’s subject.` +} /** Copy for an STS refusal. Only Google's documented issuer-connection error is matched by text. */ -function stsFailureMessage(status: number, payload: Record): string { +function stsFailureMessage( + status: number, + payload: Record, + config: GoogleWorkloadIdentityConfig +): string { if (isProviderOutage(status)) return 'Google Cloud STS is unavailable. Try again.' const description = typeof payload.error_description === 'string' ? payload.error_description : '' if (description.includes('Error connecting to the given credential')) { return 'Google Cloud could not fetch the signing keys from Sim’s issuer. The issuer must be reachable over public HTTPS.' } const code = typeof payload.error === 'string' ? payload.error : '' - return STS_DIAGNOSTIC_ERRORS.has(code) ? `${STS_REFUSED_MESSAGE} (${code})` : STS_REFUSED_MESSAGE + const message = stsRefusedMessage(config) + return STS_DIAGNOSTIC_ERRORS.has(code) ? `${message} (${code})` : message } /** - * Exchanges a Sim token for this subject at Google STS. The audience is the - * credential's own provider; the subject token's `aud` is that provider's - * resource name, which a provider with no allowed audiences accepts. + * Exchanges a Sim token for this subject at Google STS. The STS audience is + * always the credential's own provider. The subject token's `aud` is the + * credential's custom audience, which the provider must list in its allowed + * audiences, or else that provider's resource name, which a provider with no + * allowed audiences accepts. */ async function exchangeSubjectToken( config: GoogleWorkloadIdentityConfig, @@ -328,7 +354,7 @@ async function exchangeSubjectToken( ): Promise { const subjectToken = await mintWorkloadIdentityToken({ subject: config.subject, - audience: googleWorkloadIdentityAudience(config), + audience: config.audience ?? googleWorkloadIdentityAudience(config), owner, }) // Google asks that this request carry no Authorization header. @@ -345,7 +371,7 @@ async function exchangeSubjectToken( {} ) if (status !== 200) { - throw new GoogleWorkloadIdentityError(stsFailureMessage(status, payload), status) + throw new GoogleWorkloadIdentityError(stsFailureMessage(status, payload, config), status) } if (typeof payload.access_token !== 'string' || !payload.access_token) { throw new GoogleWorkloadIdentityError('Google Cloud STS answered without a token.', 502) @@ -662,16 +688,33 @@ export interface GoogleStoredDelegation { } /** - * The Workspace user and delegated services a stored keyless secret holds, so - * a reconnect keeps them instead of silently dropping them. Neither is secret. - * Undefined for a JSON key, a credential without delegation, or one whose - * services this release does not know. + * The non-secret fields a reconnect keeps, from one parse of the decrypted secret: + * its mode, the domain-wide delegation (Workspace user and the services this + * release knows), and a custom audience. A JSON key reads as `json_key` alone. */ -export function storedGoogleDelegation(decrypted: string): GoogleStoredDelegation | undefined { - const delegation = parseDecryptedConfig(decrypted)?.delegation - if (!delegation) return undefined - const services = knownDelegatedServices(delegation) - return services.length > 0 ? { user: delegation.user, services } : undefined +export function storedGoogleReconnectFields(decrypted: string): { + authType: 'workload_identity' | 'json_key' + delegation?: GoogleStoredDelegation + audience?: string +} { + let secret: unknown + try { + secret = JSON.parse(decrypted) + } catch { + return { authType: 'json_key' } + } + if (toRecord(secret).type !== GOOGLE_WORKLOAD_IDENTITY_SECRET_TYPE) { + return { authType: 'json_key' } + } + const config = parseStoredConfig(secret) + const services = config?.delegation ? knownDelegatedServices(config.delegation) : [] + return { + authType: 'workload_identity', + ...(config?.delegation && services.length > 0 + ? { delegation: { user: config.delegation.user, services } } + : {}), + ...(config?.audience ? { audience: config.audience } : {}), + } } function bindSubject( @@ -728,6 +771,11 @@ export async function verifyAndEncryptGoogleWorkloadIdentityCredential( serviceAccountEmail: submitted.serviceAccountEmail, subject: bindSubject(submitted.subject, submitted.subjectProof, owner), ...(submitted.delegation ? { delegation: submitted.delegation } : {}), + ...storedAudienceField( + submitted.audience, + googleWorkloadIdentityAudience(submitted), + googleStsAudience(submitted) + ), } if (!subjectSchema.safeParse(config.subject).success) { throw new GoogleWorkloadIdentityError('This credential’s subject is too long for Google Cloud.') @@ -771,9 +819,9 @@ export async function verifyAndEncryptGoogleWorkloadIdentityCredential( /** * Minted tokens per credential, stored-secret version, delegated user (if any), - * and scopes: a reconnect re-encrypts the secret and so never reuses the old - * configuration's token, and a delegated token never answers for the service - * account's own. + * and scopes: a reconnect (a changed audience included) re-encrypts the secret + * and so never reuses the old configuration's token, and a delegated token never + * answers for the service account's own. */ const tokenCache = new LRUCache({ max: TOKEN_CACHE_MAX }) diff --git a/apps/sim/lib/credentials/google-workload-identity.test.ts b/apps/sim/lib/credentials/google-workload-identity.test.ts index 38abd86567d..19a66aa7009 100644 --- a/apps/sim/lib/credentials/google-workload-identity.test.ts +++ b/apps/sim/lib/credentials/google-workload-identity.test.ts @@ -138,4 +138,20 @@ describe('keyless setup commands', () => { '--member="principal://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/sim-pool/subject/sim:workspace:ws-1:credential:abc"' ) }) + + it('lists a custom audience in the provider’s allowed audiences', () => { + const commands = googleWorkloadIdentitySetupCommands({ ...input, audience: 'acme-prod' }) + expect(commands).toContain('--allowed-audiences="acme-prod" \\') + }) + + it.each([ + undefined, + '', + 'https://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/sim-pool/providers/sim-oidc', + '//iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/sim-pool/providers/sim-oidc', + ])('sets no allowed audiences for the default audience (%s)', (audience) => { + expect(googleWorkloadIdentitySetupCommands({ ...input, audience })).not.toContain( + '--allowed-audiences' + ) + }) }) diff --git a/apps/sim/lib/credentials/google-workload-identity.ts b/apps/sim/lib/credentials/google-workload-identity.ts index 8d68e387732..bef2a4d2f31 100644 --- a/apps/sim/lib/credentials/google-workload-identity.ts +++ b/apps/sim/lib/credentials/google-workload-identity.ts @@ -17,6 +17,7 @@ */ import { impersonateEmailSchema } from '@/lib/api/contracts/oauth-connections' +import { storedAudienceField } from '@/lib/credentials/workload-identity/audiences' import { getProviderIdFromServiceId, getScopesForService } from '@/lib/oauth/utils' /** @@ -266,8 +267,9 @@ export function googleWorkloadIdentityRole(domainWideDelegation: boolean): strin * The gcloud commands that set up trust for one credential: the pool, an OIDC * provider for Sim's issuer that accepts only this credential's subject, and * the binding that lets that subject impersonate the service account (and, with - * domain-wide delegation, sign for it). The provider sets no allowed audiences, - * so it accepts its own resource name. + * domain-wide delegation, sign for it). Without a custom audience the provider + * sets no allowed audiences, so it accepts its own resource name; with one, it + * allows only that audience. */ export function googleWorkloadIdentitySetupCommands(input: { ref: GoogleWorkloadIdentityProviderRef @@ -275,8 +277,15 @@ export function googleWorkloadIdentitySetupCommands(input: { issuer: string subject: string domainWideDelegation?: boolean + /** The audience Sim's tokens carry; the provider's own name or none sets no allowed audiences. */ + audience?: string }): string { const { ref, serviceAccountEmail, issuer, subject } = input + const { audience: customAudience } = storedAudienceField( + input.audience?.trim(), + googleWorkloadIdentityAudience(ref), + googleStsAudience(ref) + ) return [ `gcloud iam workload-identity-pools create ${ref.poolId} \\`, ` --project="${ref.projectNumber}" \\`, @@ -288,6 +297,7 @@ export function googleWorkloadIdentitySetupCommands(input: { ' --location="global" \\', ` --workload-identity-pool="${ref.poolId}" \\`, ` --issuer-uri="${issuer}" \\`, + ...(customAudience ? [` --allowed-audiences="${customAudience}" \\`] : []), ' --attribute-mapping="google.subject=assertion.sub" \\', ` --attribute-condition="assertion.sub=='${subject}'"`, '', diff --git a/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts b/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts index 72126c379d7..9fe5f1bed7c 100644 --- a/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts +++ b/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts @@ -323,6 +323,61 @@ describe('saving a Microsoft app credential', () => { /No federated credential on this app matches/ ) }) + + const CUSTOM_AUDIENCE = 'api://contoso-sim-exchange' + const storedConfig = () => + JSON.parse(String(encryptionMockFns.mockEncryptSecret.mock.calls[0]?.[0])).config + + it('verifies and probes the subject pin with a custom audience, and stores it', async () => { + const tokens = fakeMicrosoft(fetch, { + acceptAssertion: (assertion) => assertion === `sim-oidc|${CUSTOM_AUDIENCE}|${SUBJECT}`, + }) + + await save({ authType: 'federated', audience: ` ${CUSTOM_AUDIENCE} ` }, reconnectOwner) + + const assertions = tokens.map((request) => request.form.client_assertion) + expect(assertions).toHaveLength(2) + for (const assertion of assertions) { + expect(assertion.startsWith(`sim-oidc|${CUSTOM_AUDIENCE}|`)).toBe(true) + } + expect(storedConfig()).toMatchObject({ audience: CUSTOM_AUDIENCE, subject: SUBJECT }) + }) + + it('stores no audience when the default is submitted, so earlier releases still read it', async () => { + const tokens = fakeMicrosoft(fetch, { + acceptAssertion: (assertion) => assertion.endsWith(`|${SUBJECT}`), + }) + + await save({ authType: 'federated', audience: 'api://AzureADTokenExchange' }, reconnectOwner) + + expect(tokens.map((request) => request.form.client_assertion)).toContain( + `sim-oidc|api://AzureADTokenExchange|${SUBJECT}` + ) + expect(storedConfig()).toEqual({ + authType: 'federated', + tenantId: TENANT, + clientId: CLIENT_ID, + subject: SUBJECT, + }) + }) + + it('refuses another cloud’s default audience without contacting Entra', async () => { + fakeMicrosoft(fetch) + + await expect( + save({ authType: 'federated', audience: 'sts.amazonaws.com' }, reconnectOwner) + ).rejects.toThrow(/belongs to another cloud/) + expect(fetch).not.toHaveBeenCalled() + expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled() + }) + + it('names the audience it presented when no federated credential matches', async () => { + fakeMicrosoft(fetch, { acceptAssertion: () => false }) + + await expect( + save({ authType: 'federated', audience: CUSTOM_AUDIENCE }, reconnectOwner) + ).rejects.toThrow(CUSTOM_AUDIENCE) + }) }) it('verifies the Dataverse environment with its own token and WhoAmI', async () => { @@ -781,6 +836,26 @@ describe('getting a Microsoft app token for a run', () => { ).rejects.toMatchObject({ code: 'provider_unavailable', status: 502 }) }) + describe('a keyless credential at run time', () => { + const SUBJECT = 'sim:workspace:ws-1:credential:abcdef0123456789' + + it('presents the default audience, then the stored custom one, never a token cached for the old one', async () => { + const tokens = fakeMicrosoft(fetch) + storeCredential({ authType: 'federated', subject: SUBJECT }, 'ciphertext-aud-before') + await resolveMicrosoftAppCredentialToken('cred-aud-change', 'microsoft-ad') + storeCredential( + { authType: 'federated', subject: SUBJECT, audience: 'api://contoso-sim' }, + 'ciphertext-aud-after' + ) + await resolveMicrosoftAppCredentialToken('cred-aud-change', 'microsoft-ad') + + expect(tokens.map((request) => request.form.client_assertion)).toEqual([ + `sim-oidc|api://AzureADTokenExchange|${SUBJECT}`, + `sim-oidc|api://contoso-sim|${SUBJECT}`, + ]) + }) + }) + describe('while a new federated credential propagates', () => { const SUBJECT = 'sim:workspace:ws-1:credential:abcdef0123456789' const refusal = (code: number) => diff --git a/apps/sim/lib/credentials/microsoft-app-service-account.server.ts b/apps/sim/lib/credentials/microsoft-app-service-account.server.ts index 0a3d9128ab3..9a56cb309da 100644 --- a/apps/sim/lib/credentials/microsoft-app-service-account.server.ts +++ b/apps/sim/lib/credentials/microsoft-app-service-account.server.ts @@ -22,7 +22,12 @@ import { providerFailureCode, TokenServiceAccountValidationError, } from '@/lib/credentials/token-service-accounts/errors' -import { WORKLOAD_IDENTITY_AUDIENCES } from '@/lib/credentials/workload-identity/audiences' +import { + storedAudienceField, + storedWorkloadIdentityAudienceSchema, + WORKLOAD_IDENTITY_AUDIENCES, + workloadIdentityAudienceSchema, +} from '@/lib/credentials/workload-identity/audiences' import { isWorkloadIdentityConfigured, mintWorkloadIdentityToken, @@ -166,6 +171,8 @@ const federatedShape = { ...baseShape, authType: z.literal('federated'), subject: z.string().min(1).max(512), + /** The `aud` Sim's token carries; absent for the default, so earlier releases still read it. */ + audience: storedWorkloadIdentityAudienceSchema, } const storedConfigSchema = z.discriminatedUnion('authType', [ @@ -184,6 +191,7 @@ const submittedConfigSchema = z.discriminatedUnion('authType', [ ...federatedShape, subject: federatedShape.subject.optional(), subjectProof: z.string().min(1).max(512).optional(), + audience: workloadIdentityAudienceSchema('microsoft').optional(), }) .strict(), ]) @@ -210,8 +218,13 @@ export class MicrosoftAppCredentialError extends Error { } } -const FEDERATION_MISMATCH_MESSAGE = - 'No federated credential on this app matches the issuer, subject, and audience Sim presents. Add it with the exact values from Sim (the connect dialog or `sim credentials keyless-subject`) (matching is case-sensitive). A new federated credential can take a few minutes to take effect.' +function federationMismatchMessage(config: MicrosoftAppConfig): string { + const audience = + config.authType === 'federated' + ? ` (${config.audience ?? WORKLOAD_IDENTITY_AUDIENCES.microsoft})` + : '' + return `No federated credential on this app matches the issuer, subject, and audience${audience} Sim presents. Add it with the exact values from Sim (the connect dialog or \`sim credentials keyless-subject\`) (matching is case-sensitive). A new federated credential can take a few minutes to take effect.` +} /** * Actionable copy for the Entra errors a person saving the credential can fix. @@ -243,10 +256,10 @@ function entraFailureMessage(aadsts: number | undefined, config: MicrosoftAppCon : 'Microsoft Entra does not recognize the requested resource in this tenant.' case 70021: case 700213: - return FEDERATION_MISMATCH_MESSAGE + return federationMismatchMessage(config) default: return config.authType === 'federated' - ? `Microsoft Entra refused the keyless sign-in${aadsts ? ` (AADSTS${aadsts})` : ''}. ${FEDERATION_MISMATCH_MESSAGE}` + ? `Microsoft Entra refused the keyless sign-in${aadsts ? ` (AADSTS${aadsts})` : ''}. ${federationMismatchMessage(config)}` : `Microsoft Entra refused the token request${aadsts ? ` (AADSTS${aadsts})` : ''}. Check the tenant ID, client ID, and credential.` } } @@ -349,10 +362,13 @@ async function signCertificateAssertion( .sign(key) } -function mintFederatedAssertion(subject: string, owner: WorkloadIdentityOwner): Promise { +function mintFederatedAssertion( + config: Extract, + owner: WorkloadIdentityOwner +): Promise { return mintWorkloadIdentityToken({ - subject, - audience: WORKLOAD_IDENTITY_AUDIENCES.microsoft, + subject: config.subject, + audience: config.audience ?? WORKLOAD_IDENTITY_AUDIENCES.microsoft, owner, }) } @@ -365,7 +381,7 @@ async function clientAuthentication( const assertion = config.authType === 'certificate' ? await signCertificateAssertion(config) - : await mintFederatedAssertion(config.subject, owner) + : await mintFederatedAssertion(config, owner) return { client_assertion_type: CLIENT_ASSERTION_TYPE, client_assertion: assertion } } @@ -498,18 +514,24 @@ async function verifyDataverseApplicationUser(environmentUrl: string, token: str } /** - * The auth mode and national cloud a decrypted Microsoft app credential secret holds, for a - * reconnect to reopen on: empty when it is not one, and no cloud for a global credential. + * The auth mode, national cloud, and custom keyless audience a decrypted Microsoft app credential + * secret holds, for a reconnect to reopen on: empty when it is not one, no cloud for a global + * credential, and no audience for one that uses the default. */ export function storedMicrosoftAppReconnectFields(decrypted: string): { authType?: MicrosoftAppConfig['authType'] cloud?: MicrosoftCloudId + audience?: string } { try { const parsed = storedSecretSchema.safeParse(JSON.parse(decrypted)) if (!parsed.success) return {} - const { authType, cloud } = parsed.data.config - return cloud ? { authType, cloud } : { authType } + const { config } = parsed.data + return { + authType: config.authType, + ...(config.cloud ? { cloud: config.cloud } : {}), + ...(config.authType === 'federated' && config.audience ? { audience: config.audience } : {}), + } } catch { return {} } @@ -605,8 +627,13 @@ export async function verifyAndEncryptMicrosoftAppCredential( if (!(await isWorkloadIdentityConfigured())) { throw new WorkloadIdentityNotConfiguredError('Use a client secret or certificate.') } - const { subject, subjectProof, ...rest } = submitted - config = { ...rest, ...cloudField, subject: bindSubject(subject, subjectProof, owner) } + const { subject, subjectProof, audience: submittedAudience, ...rest } = submitted + config = { + ...rest, + ...cloudField, + subject: bindSubject(subject, subjectProof, owner), + ...storedAudienceField(submittedAudience, WORKLOAD_IDENTITY_AUDIENCES.microsoft), + } } else { config = { ...submitted, ...cloudField } } diff --git a/apps/sim/lib/credentials/snowflake-service-account.server.test.ts b/apps/sim/lib/credentials/snowflake-service-account.server.test.ts index db025952031..5e4ac3eb2e2 100644 --- a/apps/sim/lib/credentials/snowflake-service-account.server.test.ts +++ b/apps/sim/lib/credentials/snowflake-service-account.server.test.ts @@ -20,7 +20,7 @@ import { env } from '@/lib/core/config/env' import { resolveSnowflakeCredentialToken, SnowflakeCredentialError, - storedSnowflakeAuthType, + storedSnowflakeReconnectFields, verifyAndEncryptSnowflakeConnection, verifyAndEncryptSnowflakeKeylessCredential, } from '@/lib/credentials/snowflake-service-account.server' @@ -48,7 +48,7 @@ function setEnv(key: string, value: string | undefined) { * and the JWT verifies against Sim's published keys with Snowflake's audience, * Sim's issuer, an `iat`, and the subject a service user was created with. */ -function snowflakeAccountTrusting(trustedSubject: string) { +function snowflakeAccountTrusting(trustedSubject: string, audience = 'snowflakecomputing.com') { secureFetch.mockImplementation( async (_url: string, init: { headers: Record }) => { const authorization = init.headers.Authorization ?? '' @@ -62,7 +62,7 @@ function snowflakeAccountTrusting(trustedSubject: string) { createLocalJWKSet(await getWorkloadIdentityJwks()), { issuer: getWorkloadIdentityIssuer(), - audience: 'snowflakecomputing.com', + audience, requiredClaims: ['iat'], } ) @@ -206,6 +206,60 @@ describe('saving a keyless Snowflake connection', () => { }) }) +describe('a keyless Snowflake connection with its own audience', () => { + it('signs in with a token for the custom audience the user lists in OIDC_AUDIENCE_LIST, and keeps it', async () => { + const grant = issueWorkloadIdentitySubject('snowflake', owner, owner.actorUserId) + snowflakeAccountTrusting(grant.subject, 'myorg-myaccount') + + await verifyAndEncryptSnowflakeKeylessCredential( + keylessConfig({ + subject: grant.subject, + subjectProof: grant.subjectProof, + audience: ' myorg-myaccount ', + }), + owner + ) + + const stored = JSON.parse(encryptionMockFns.mockEncryptSecret.mock.calls[0][0]) + expect(stored.config.audience).toBe('myorg-myaccount') + expect(storedSnowflakeReconnectFields(JSON.stringify(stored)).audience).toBe('myorg-myaccount') + }) + + it('stores no audience when the default is submitted, so earlier releases still read the credential', async () => { + const grant = issueWorkloadIdentitySubject('snowflake', owner, owner.actorUserId) + snowflakeAccountTrusting(grant.subject) + + await verifyAndEncryptSnowflakeKeylessCredential( + keylessConfig({ + subject: grant.subject, + subjectProof: grant.subjectProof, + audience: 'snowflakecomputing.com', + }), + owner + ) + + const stored = JSON.parse(encryptionMockFns.mockEncryptSecret.mock.calls[0][0]) + expect(Object.keys(stored.config).sort()).toEqual(['authType', 'host', 'subject']) + expect(storedSnowflakeReconnectFields(JSON.stringify(stored)).audience).toBeUndefined() + }) + + it('refuses another cloud’s default audience before sending a token anywhere', async () => { + const grant = issueWorkloadIdentitySubject('snowflake', owner, owner.actorUserId) + + await expect( + verifyAndEncryptSnowflakeKeylessCredential( + keylessConfig({ + subject: grant.subject, + subjectProof: grant.subjectProof, + audience: 'sts.amazonaws.com', + }), + owner + ) + ).rejects.toThrow(/belongs to another cloud/) + expect(secureFetch).not.toHaveBeenCalled() + }) +}) + describe('getting a Snowflake token for a run', () => { function storedCredential(secret: Record) { resetDbChainMock() @@ -233,6 +287,18 @@ describe('getting a Snowflake token for a run', () => { expect(payload.sub).toBe(subject) }) + it('mints a run token for a keyless credential’s custom audience', async () => { + const subject = 'sim:workspace:ws-1:credential:abcdef0123456789' + storedCredential({ + type: 'snowflake_workload_identity_v1', + config: { authType: 'workload_identity', host: HOST, subject, audience: 'myorg-myaccount' }, + }) + + const resolved = await resolveSnowflakeCredentialToken('cred-1') + + expect(decodeJwt(resolved.accessToken).aud).toBe('myorg-myaccount') + }) + it('resolves a programmatic access token exactly as before, with no token type', async () => { storedCredential({ type: TOKEN_SERVICE_ACCOUNT_SECRET_TYPE, @@ -413,6 +479,6 @@ describe('a Snowflake key-pair connection', () => { iss: `MYORG-MYACCOUNT.SIM_WORKFLOWS.${FINGERPRINT}`, sub: 'MYORG-MYACCOUNT.SIM_WORKFLOWS', }) - expect(storedSnowflakeAuthType(JSON.stringify(secret))).toBe('key_pair') + expect(storedSnowflakeReconnectFields(JSON.stringify(secret))).toEqual({ authType: 'key_pair' }) }) }) diff --git a/apps/sim/lib/credentials/snowflake-service-account.server.ts b/apps/sim/lib/credentials/snowflake-service-account.server.ts index 63505c57bab..8c288d256bb 100644 --- a/apps/sim/lib/credentials/snowflake-service-account.server.ts +++ b/apps/sim/lib/credentials/snowflake-service-account.server.ts @@ -16,7 +16,12 @@ import { validateSnowflakeKeyPair, validateSnowflakeWorkloadIdentity, } from '@/lib/credentials/token-service-accounts/validators/snowflake' -import { WORKLOAD_IDENTITY_AUDIENCES } from '@/lib/credentials/workload-identity/audiences' +import { + storedAudienceField, + storedWorkloadIdentityAudienceSchema, + WORKLOAD_IDENTITY_AUDIENCES, + workloadIdentityAudienceSchema, +} from '@/lib/credentials/workload-identity/audiences' import { isWorkloadIdentityConfigured, mintWorkloadIdentityToken, @@ -49,6 +54,9 @@ import { * Sim signs a short-lived JWT with it for every run * (https://docs.snowflake.com/en/developer-guide/sql-api/authenticating). * + * The token's `aud` is `snowflakecomputing.com` unless the credential sets its + * own `audience`, which the user then lists in `OIDC_AUDIENCE_LIST`. + * * Programmatic-access-token credentials share the provider and keep their * token-paste blob; only the blob type tells the three modes apart. */ @@ -94,6 +102,7 @@ const submittedConfigSchema = z host: hostSchema, subject: z.string().min(1).max(512).optional(), subjectProof: z.string().min(1).max(512).optional(), + audience: workloadIdentityAudienceSchema('snowflake').optional(), }) .strict() @@ -129,6 +138,8 @@ const storedSecretSchema = z authType: z.literal('workload_identity'), host: z.string().min(1), subject: z.string().min(1), + /** Absent for the default audience, so earlier releases' strict schema still reads it. */ + audience: storedWorkloadIdentityAudienceSchema, }) .strict(), metadata: z.record(z.string(), z.string()).optional(), @@ -162,18 +173,27 @@ export class SnowflakeCredentialError extends Error { } } -/** The auth mode a decrypted Snowflake secret holds: keyless, a key pair, a token, or undefined when none. */ -export function storedSnowflakeAuthType( - decrypted: string -): 'workload_identity' | 'key_pair' | 'programmatic_access_token' | undefined { +/** + * The auth mode and custom keyless audience a decrypted Snowflake secret holds, for a + * reconnect to reopen on: empty when it is none of the three modes, and no audience for + * one that uses the default. + */ +export function storedSnowflakeReconnectFields(decrypted: string): { + authType?: 'workload_identity' | 'key_pair' | 'programmatic_access_token' + audience?: string +} { try { const parsed: unknown = JSON.parse(decrypted) - if (storedSecretSchema.safeParse(parsed).success) return 'workload_identity' - if (storedKeyPairSecretSchema.safeParse(parsed).success) return 'key_pair' + const keyless = storedSecretSchema.safeParse(parsed) + if (keyless.success) { + const { audience } = keyless.data.config + return { authType: 'workload_identity', ...(audience ? { audience } : {}) } + } + if (storedKeyPairSecretSchema.safeParse(parsed).success) return { authType: 'key_pair' } parseTokenServiceAccountSecretBlob(decrypted, SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID) - return 'programmatic_access_token' + return { authType: 'programmatic_access_token' } } catch { - return undefined + return {} } } @@ -182,10 +202,13 @@ function readStoredSubject(storedSecret: Record | null | undefi return parsed.success ? parsed.data.config.subject : undefined } -function mintSnowflakeToken(subject: string, owner: WorkloadIdentityOwner): Promise { +function mintSnowflakeToken( + config: Pick, + owner: WorkloadIdentityOwner +): Promise { return mintWorkloadIdentityToken({ - subject, - audience: SNOWFLAKE_WORKLOAD_IDENTITY_AUDIENCE, + subject: config.subject, + audience: config.audience ?? SNOWFLAKE_WORKLOAD_IDENTITY_AUDIENCE, owner, ttlSeconds: SNOWFLAKE_TOKEN_TTL_SECONDS, }) @@ -278,7 +301,7 @@ export async function verifyAndEncryptSnowflakeKeylessCredential( if (!(await isWorkloadIdentityConfigured())) { throw new WorkloadIdentityNotConfiguredError('Use a programmatic access token.') } - const { host, subject: submittedSubject, subjectProof } = parsed.data + const { host, subject: submittedSubject, subjectProof, audience } = parsed.data const subject = bindWorkloadIdentitySubject({ provider: 'snowflake', owner, @@ -287,14 +310,16 @@ export async function verifyAndEncryptSnowflakeKeylessCredential( subjectProof, }) + const audienceField = storedAudienceField(audience, SNOWFLAKE_WORKLOAD_IDENTITY_AUDIENCE) const validation = await validateSnowflakeWorkloadIdentity({ domain: host, - subjectToken: await mintSnowflakeToken(subject, owner), + subjectToken: await mintSnowflakeToken({ subject, ...audienceField }, owner), }) const config: StoredConfig = { authType: 'workload_identity', host: validation.normalizedDomain ?? host, subject, + ...audienceField, } const principalMetadata = serviceAccountPrincipalMetadata(validation.principal) const { encrypted } = await encryptSecret( @@ -421,13 +446,13 @@ export async function resolveSnowflakeCredentialToken(credentialId: string): Pro providerWorkspaceId: secret.providerWorkspaceId, } } - const { host, subject } = keyless.data.config + const { config } = keyless.data const owner: WorkloadIdentityOwner = row.workspaceId ? { workspaceId: row.workspaceId } : { organizationId: row.organizationId ?? undefined } return { - accessToken: await mintSnowflakeToken(subject, owner), - domain: host, + accessToken: await mintSnowflakeToken(config, owner), + domain: config.host, authStyle: SNOWFLAKE_WORKLOAD_IDENTITY_AUTH_STYLE, } } diff --git a/apps/sim/lib/credentials/workload-identity/audiences.test.ts b/apps/sim/lib/credentials/workload-identity/audiences.test.ts new file mode 100644 index 00000000000..e8749be42be --- /dev/null +++ b/apps/sim/lib/credentials/workload-identity/audiences.test.ts @@ -0,0 +1,38 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { workloadIdentityAudienceSchema } from '@/lib/credentials/workload-identity/audiences' + +describe('workloadIdentityAudienceSchema', () => { + it.each([ + ['databricks', 'sts.amazonaws.com'], + ['aws', 'api://AzureADTokenExchange'], + ['microsoft', 'snowflakecomputing.com'], + ['snowflake', 'sim-databricks'], + ['google', 'sts.amazonaws.com'], + ] as const)('refuses another cloud’s default audience for %s', (provider, audience) => { + expect(workloadIdentityAudienceSchema(provider).safeParse(audience).success).toBe(false) + }) + + it('refuses a Google provider audience outside Google, and allows it for Google', () => { + const googleAudience = + '//iam.googleapis.com/projects/1/locations/global/workloadIdentityPools/p/providers/q' + expect(workloadIdentityAudienceSchema('aws').safeParse(googleAudience).success).toBe(false) + expect(workloadIdentityAudienceSchema('google').safeParse(googleAudience).success).toBe(true) + }) + + it.each([ + ['has space'], + [''], + ['tab\there'], + ['a'.repeat(256)], + ['ünicode'], + ["it's"], + ['a,b'], + ['back\\slash'], + ['$HOME'], + ])('refuses %j', (audience) => { + expect(workloadIdentityAudienceSchema('aws').safeParse(audience).success).toBe(false) + }) +}) diff --git a/apps/sim/lib/credentials/workload-identity/audiences.ts b/apps/sim/lib/credentials/workload-identity/audiences.ts index 53f2b8ac646..350f82db45f 100644 --- a/apps/sim/lib/credentials/workload-identity/audiences.ts +++ b/apps/sim/lib/credentials/workload-identity/audiences.ts @@ -1,3 +1,4 @@ +import { z } from 'zod' import { SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/token-service-accounts/descriptors' import { AWS_SERVICE_ACCOUNT_PROVIDER_ID, @@ -68,23 +69,27 @@ export const WORKLOAD_IDENTITY_KEYLESS_CONFIGS: Record< aws: { authType: 'oidc_role', requiredFields: ['roleArn'], - optionalFields: ['stsRegion', 'sessionDurationSeconds'], + optionalFields: ['stsRegion', 'sessionDurationSeconds', 'audience'], }, databricks: { authType: 'oidc_federation', requiredFields: ['host', 'clientId'], - optionalFields: [], + optionalFields: ['audience'], + }, + snowflake: { + authType: 'workload_identity', + requiredFields: ['host'], + optionalFields: ['audience'], }, - snowflake: { authType: 'workload_identity', requiredFields: ['host'], optionalFields: [] }, microsoft: { authType: 'federated', requiredFields: ['tenantId', 'clientId'], - optionalFields: ['environmentUrl', 'cloud'], + optionalFields: ['environmentUrl', 'cloud', 'audience'], }, google: { authType: 'workload_identity', requiredFields: ['projectNumber', 'poolId', 'providerId', 'serviceAccountEmail'], - optionalFields: ['delegation'], + optionalFields: ['delegation', 'audience'], scope: 'Keyless works for Google Cloud services such as BigQuery and Vertex AI. For Gmail, Google Drive, Google Docs, Google Sheets, and Google Calendar, add `delegation` as {"user": "", "services": ["gmail", "google-drive", "google-docs", "google-sheets", "google-calendar"]} (the services you need) for domain-wide delegation: grant the subject roles/iam.serviceAccountTokenCreator instead of roles/iam.workloadIdentityUser, authorize the service account’s client ID for those services’ scopes in the Google Workspace Admin console, and workflows act only as that user. Other Google Workspace services need a JSON key.', }, @@ -127,9 +132,10 @@ export function workloadIdentityProviderFor( type FixedAudienceWorkloadIdentityProvider = Exclude /** - * The `aud` claim each relying party expects. AWS STS requires its own client - * ID; Databricks federation policies list whatever audience the customer - * enters, so Sim publishes one fixed value for them to paste. Snowflake accepts + * The default `aud` claim for each relying party, used when a keyless credential + * sets no `audience` of its own (see `workloadIdentityAudienceSchema`). AWS STS + * conventionally uses its own client ID; Databricks federation policies list + * whatever audience the customer enters, so Sim publishes one default to paste. Snowflake accepts * `snowflakecomputing.com` without any `OIDC_AUDIENCE_LIST` on the user, so the * customer's `CREATE USER` names only the issuer and subject. Microsoft Entra * federated identity credentials take one audience, and Microsoft recommends @@ -143,9 +149,89 @@ export const WORKLOAD_IDENTITY_AUDIENCES: Record other !== provider).flatMap((other) => { + const audience = fixedWorkloadIdentityAudience(other) + return audience ? [audience] : [] + }) + ) + return z + .string() + .trim() + .min(1, 'Audience cannot be empty') + .max( + MAX_WORKLOAD_IDENTITY_AUDIENCE_LENGTH, + `Audience must be at most ${MAX_WORKLOAD_IDENTITY_AUDIENCE_LENGTH} characters` + ) + .regex( + AUDIENCE_PATTERN, + 'Audience can use only letters, digits, and the characters . _ ~ : / ? # @ & + = % -' + ) + .refine((audience) => !othersDefaults.has(audience), { + message: 'That audience belongs to another cloud. Use the default or your own value.', + }) + .refine((audience) => provider === 'google' || !isGoogleProviderAudience(audience), { + message: 'That audience belongs to Google Cloud. Use the default or your own value.', + }) +} + +const AUDIENCE_SCHEMAS = Object.fromEntries( + WORKLOAD_IDENTITY_PROVIDERS.map((provider) => [provider, buildAudienceSchema(provider)]) +) as Record> + +/** + * A keyless credential's own `aud` claim, so a customer can pin their trust + * configuration to an audience no other Sim credential is issued for (their + * Databricks account ID, say). Another relying party's default is refused: a + * credential for one cloud never carries a token another cloud would accept. + * Built once per provider, so the connect dialog can check each keystroke. + */ +export function workloadIdentityAudienceSchema(provider: WorkloadIdentityProvider) { + return AUDIENCE_SCHEMAS[provider] +} + +/** A Google workload identity provider's resource-name audience. */ +function isGoogleProviderAudience(audience: string): boolean { + return /^(https:)?\/\/iam\.googleapis\.com\//i.test(audience) +} + +/** + * The optional `audience` field of a stored keyless config. Bounded by length only, + * so a credential saved before a submit-time rule tightened still reads. + */ +export const storedWorkloadIdentityAudienceSchema = z + .string() + .min(1) + .max(MAX_WORKLOAD_IDENTITY_AUDIENCE_LENGTH) + .optional() + +/** + * The audience field to spread into a stored keyless config: empty when none was + * submitted or it is a default, so a default-audience credential keeps the exact + * stored shape earlier releases read. A reconnect that submits none therefore + * returns to the default. + */ +export function storedAudienceField( + submitted: string | undefined, + ...defaultAudiences: string[] +): { audience?: string } { + return submitted && !defaultAudiences.includes(submitted) ? { audience: submitted } : {} +} diff --git a/packages/sim-cli/src/commands/credentials.ts b/packages/sim-cli/src/commands/credentials.ts index 87988f47e8b..1f706ad2345 100644 --- a/packages/sim-cli/src/commands/credentials.ts +++ b/packages/sim-cli/src/commands/credentials.ts @@ -67,6 +67,8 @@ const KEYLESS_FLOW_HELP = [ '', "Step 1: run `sim credentials keyless-subject --output json` and configure your cloud to trust the printed issuer, audience, and subject. Its requiredConfigFields lists the values your provider's keylessConfig still needs.", '', + "The printed audience is the default. To have Sim sign tokens for your own audience instead, set audience in keylessConfig and trust that value. Google Cloud prints no audience: its default is the credential's own workload identity pool provider.", + '', 'Step 2: add those values to keylessConfig and pass it to `sim credentials create --credentials @file`.', '', `The proof expires after 24 hours and works only for the user who requested it, with the same providerId. If creation reports ${[...KEYLESS_RETRY_CODES].join(', ')}, run step 1 again, trust the new subject, and retry.`,