From 804f968584398e2d49539a30d5b56a803c3dbe95 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 10 Nov 2026 19:32:07 -0800 Subject: [PATCH] feat(credentials): set up keyless credentials from the v2 API, CLI and Sim MCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keyless (workload identity) credentials could only be set up in the web connect dialog. Platform teams scripting their cloud trust now get the same two steps as an API: 1. POST /api/v2/credentials/workload-identity/subjects (`sim credentials keyless-subject `) issues a random, per-credential subject and a 24-hour proof bound to the caller, plus the keylessConfig to fill in and the provider's required fields. 2. The existing create (`sim credentials create … --credentials @keyless.json`) submits the completed config; Sim verifies the trust before saving. - The existing prepare use case now also admits personal API keys and OAuth tokens with api:write. Workspace keys and delegated callers stay refused, because the subject must belong to a person. - Every keyless refusal carries one stable `error.details.code` (WORKLOAD_IDENTITY_NOT_CONFIGURED, _PROOF_EXPIRED, _PROOF_INVALID, _SUBJECT_REQUIRED, _SUBJECT_IN_USE, _SUBJECT_UNVERIFIABLE) on a single refusal error, from create, reconnect and the subjects route alike. - The provider catalog advertises a `keyless` object (authType and required/optional config fields) only when the deployment can sign, and the CLI reads it instead of keeping its own provider table. Snowflake's keyless config is now accepted by the CLI. - Provider hints, OpenAPI, MCP tool descriptions, CLI help and the five credential docs pages describe the flow the same way. - AWS and Databricks keyless create and reconnect also refuse with WORKLOAD_IDENTITY_NOT_CONFIGURED when the deployment cannot sign, and their verification steps pass keyless refusals through unchanged. Each provider's refusal names a fallback credential type. - The Google Cloud keyless hint says it covers Cloud services (BigQuery, Vertex AI), not Google Workspace. --- apps/docs/content/docs/cli/credentials.mdx | 56 ++ apps/docs/content/docs/cli/reference.mdx | 54 ++ .../docs/integrations/aws-credentials.mdx | 4 + .../integrations/databricks-credentials.mdx | 4 + .../integrations/google-service-account.mdx | 4 + .../microsoft-app-credentials.mdx | 4 + .../snowflake-service-account.mdx | 4 + apps/docs/openapi-v2-resources.json | 283 ++++++- .../scripts/fixtures/content-baseline.json | 724 +++++++++--------- .../credentials/[credentialId]/route.test.ts | 58 +- .../v2/credentials/[credentialId]/route.ts | 32 +- apps/sim/app/api/v2/credentials/route.test.ts | 56 ++ apps/sim/app/api/v2/credentials/route.ts | 8 +- .../workload-identity/subjects/route.test.ts | 216 ++++++ .../workload-identity/subjects/route.ts | 62 ++ apps/sim/lib/api/contracts/credentials.ts | 43 +- apps/sim/lib/api/contracts/v2/credentials.ts | 66 ++ .../lib/api/contracts/v2/openapi/resources.ts | 63 +- .../lib/api/mcp/generated/v2-operations.ts | 14 +- .../server/routes/copilot-refused-routes.json | 5 + .../server/routes/v2-route-table.generated.ts | 4 + ...load-identity-subject-reuse.integration.ts | 1 + .../sim/lib/credentials/api/route-policies.ts | 30 +- apps/sim/lib/credentials/application/crud.ts | 3 + .../lib/credentials/application/operations.ts | 10 +- .../provider-catalog-contract.test.ts | 82 +- .../application/provider-catalog.ts | 103 ++- .../application/service-account.test.ts | 25 + .../application/service-account.ts | 1 + .../aws-service-account.server.test.ts | 32 +- .../credentials/aws-service-account.server.ts | 38 +- .../databricks-service-account.server.test.ts | 17 +- .../databricks-service-account.server.ts | 37 +- .../google-workload-identity.server.ts | 27 +- .../microsoft-app-service-account.server.ts | 27 +- .../orchestration/credential-create.ts | 18 +- .../credentials/orchestration/index.test.ts | 90 +++ .../lib/credentials/orchestration/index.ts | 10 +- .../lib/credentials/service-account-config.ts | 10 + .../lib/credentials/service-account-fields.ts | 29 +- .../service-account-secret.test.ts | 93 +++ .../lib/credentials/service-account-secret.ts | 9 +- .../snowflake-service-account.server.ts | 28 +- .../workload-identity/audiences.ts | 89 +++ .../workload-identity/issuer.test.ts | 2 +- .../credentials/workload-identity/issuer.ts | 12 +- .../workload-identity/subject.test.ts | 6 +- .../credentials/workload-identity/subject.ts | 115 ++- .../sim-cli/src/commands/credentials.test.ts | 226 ++++++ packages/sim-cli/src/commands/credentials.ts | 146 +++- packages/sim-cli/src/contract/commands.ts | 1 + packages/sim-cli/src/generated/v2-api.ts | 65 ++ packages/sim-cli/src/http/client.test.ts | 1 + packages/sim-cli/src/http/client.ts | 17 +- 54 files changed, 2579 insertions(+), 585 deletions(-) create mode 100644 apps/sim/app/api/v2/credentials/workload-identity/subjects/route.test.ts create mode 100644 apps/sim/app/api/v2/credentials/workload-identity/subjects/route.ts diff --git a/apps/docs/content/docs/cli/credentials.mdx b/apps/docs/content/docs/cli/credentials.mdx index f0f98056185..709fef1e244 100644 --- a/apps/docs/content/docs/cli/credentials.mdx +++ b/apps/docs/content/docs/cli/credentials.mdx @@ -87,6 +87,24 @@ sim credentials create [options] +--credentials takes the provider's fields, or the keylessConfig from `sim credentials keyless-subject` (with the required fields filled in). Pass @path or @- to keep secrets out of shell history. + +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. + +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. + +**Examples** + +```bash +sim credentials keyless-subject --output json > subject.json +jq '.keylessConfig + {"roleArn":"arn:aws:iam::123456789012:role/SimWorkflows"}' subject.json > keyless.json +sim credentials create --name "Keyless" --credentials @keyless.json +``` + API request and response schemas: [createServiceAccountCredential](/api-reference/credentials/createServiceAccountCredential). ### Create a short-lived link for connecting an OAuth provider @@ -324,6 +342,44 @@ sim credentials delete [options] API request and response schemas: [deleteCredential](/api-reference/credentials/deleteCredential). +## Other commands + +### Issue the subject and proof for a new keyless (workload identity) credential + +OAuth login or personal API key required. + +```bash +sim credentials keyless-subject +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `providerId` | Yes | Service-account provider for the keyless credential | + + + +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. + +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. + +**Examples** + +```bash +sim credentials keyless-subject --output json > subject.json +jq '.keylessConfig + {"roleArn":"arn:aws:iam::123456789012:role/SimWorkflows"}' subject.json > keyless.json +sim credentials create --name "Keyless" --credentials @keyless.json +``` + +API request and response schemas: [createCredentialWorkloadIdentitySubject](/api-reference/credentials/createCredentialWorkloadIdentitySubject). + ## Related docs - [Credentials](/platform/credentials) diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 3b2b1a26de7..baa6a7167e0 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -773,8 +773,62 @@ sim credentials create [options] +--credentials takes the provider's fields, or the keylessConfig from `sim credentials keyless-subject` (with the required fields filled in). Pass @path or @- to keep secrets out of shell history. + +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. + +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. + +**Examples** + +```bash +sim credentials keyless-subject --output json > subject.json +jq '.keylessConfig + {"roleArn":"arn:aws:iam::123456789012:role/SimWorkflows"}' subject.json > keyless.json +sim credentials create --name "Keyless" --credentials @keyless.json +``` + API request and response schemas: [createServiceAccountCredential](/api-reference/credentials/createServiceAccountCredential). +### sim credentials keyless-subject + +Issue the subject and proof for a new keyless (workload identity) credential (OAuth login or personal API key required) + +```bash +sim credentials keyless-subject +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `providerId` | Yes | Service-account provider for the keyless credential | + + + +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. + +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. + +**Examples** + +```bash +sim credentials keyless-subject --output json > subject.json +jq '.keylessConfig + {"roleArn":"arn:aws:iam::123456789012:role/SimWorkflows"}' subject.json > keyless.json +sim credentials create --name "Keyless" --credentials @keyless.json +``` + +API request and response schemas: [createCredentialWorkloadIdentitySubject](/api-reference/credentials/createCredentialWorkloadIdentitySubject). + ### sim credentials connect Create a short-lived link for connecting an OAuth provider (OAuth login or personal API key required) diff --git a/apps/docs/content/docs/integrations/aws-credentials.mdx b/apps/docs/content/docs/integrations/aws-credentials.mdx index 94954d8983c..6adce90c320 100644 --- a/apps/docs/content/docs/integrations/aws-credentials.mdx +++ b/apps/docs/content/docs/integrations/aws-credentials.mdx @@ -47,6 +47,10 @@ STS **Get Session Token** works only with an **Access keys** credential: AWS ref 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. + ## Use access keys Create an IAM user (or use an existing one) with an access key, choose **Access keys** or **Access keys that assume an IAM role**, and enter the key pair. For an assumed role, add the role ARN and, if the role's trust policy requires one, the external ID. For AWS GovCloud or China keys, set the **STS region** (for example `us-gov-west-1`). Sim checks the keys with `sts:GetCallerIdentity` before saving. diff --git a/apps/docs/content/docs/integrations/databricks-credentials.mdx b/apps/docs/content/docs/integrations/databricks-credentials.mdx index 65ab057d36e..025044b72bd 100644 --- a/apps/docs/content/docs/integrations/databricks-credentials.mdx +++ b/apps/docs/content/docs/integrations/databricks-credentials.mdx @@ -38,6 +38,10 @@ Every option resolves on Sim's servers at run time; tokens never appear in block Reconnecting a keyless connection keeps its subject by default, so the federation policy keeps working. +### 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. + ## Self-hosted deployments Keyless connections need `WORKLOAD_IDENTITY_SIGNING_KEY` set and an `https` issuer Databricks can reach over the public internet. See [AWS credentials](/integrations/aws-credentials#self-hosted-deployments) for the same settings and for rotating the signing key. diff --git a/apps/docs/content/docs/integrations/google-service-account.mdx b/apps/docs/content/docs/integrations/google-service-account.mdx index 1aeb3fe868d..49166a6a974 100644 --- a/apps/docs/content/docs/integrations/google-service-account.mdx +++ b/apps/docs/content/docs/integrations/google-service-account.mdx @@ -231,6 +231,10 @@ Each keyless credential has its own subject; reconnecting keeps it by default so 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. As in the dialog, keyless works only for Google Cloud services such as BigQuery and Vertex AI, not Google Workspace blocks. + ### 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. diff --git a/apps/docs/content/docs/integrations/microsoft-app-credentials.mdx b/apps/docs/content/docs/integrations/microsoft-app-credentials.mdx index caafcf050be..4e91652bc1d 100644 --- a/apps/docs/content/docs/integrations/microsoft-app-credentials.mdx +++ b/apps/docs/content/docs/integrations/microsoft-app-credentials.mdx @@ -54,6 +54,10 @@ Every option resolves on Sim's servers at run time, and tokens never appear in b 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`) 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 - **Client secret:** under **Certificates & secrets** → **Client secrets** → **New client secret**, copy the secret's **Value** (not the Secret ID). diff --git a/apps/docs/content/docs/integrations/snowflake-service-account.mdx b/apps/docs/content/docs/integrations/snowflake-service-account.mdx index f513f3bee7a..509aac502ba 100644 --- a/apps/docs/content/docs/integrations/snowflake-service-account.mdx +++ b/apps/docs/content/docs/integrations/snowflake-service-account.mdx @@ -48,6 +48,10 @@ CREATE USER SIM_WORKFLOWS Snowflake requires each issuer and subject pair to be unique in an account, and matches the subject exactly, so the service user trusts this connection and no other Sim credential. Reconnecting a keyless connection keeps its subject by default, so the service user keeps working; if you choose **Issue a new subject**, update the user with the new one before saving. +### 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. + ### Self-hosted deployments Keyless connections need `WORKLOAD_IDENTITY_SIGNING_KEY` set and an `https` issuer Snowflake can reach over the public internet. See [AWS credentials](/integrations/aws-credentials#self-hosted-deployments) for the settings. Snowflake caches an issuer's signing keys for up to **60 minutes**, so when rotating the signing key, publish the new key (`WORKLOAD_IDENTITY_PREVIOUS_KEY`) at least 60 minutes before Sim starts signing with it. A shorter wait fails runs until Snowflake refreshes its cache. diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index ed3d70bfc25..e5a56c40eb0 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -3824,7 +3824,7 @@ "post": { "operationId": "createServiceAccountCredential", "summary": "Create service-account credential", - "description": "Verify and store a service-account credential. Use the fields from List credential providers, encoded as a JSON object string in `credentials`. Requires workspace write access. A matching source returns the existing credential with `200`; creation returns `201`. Stored secrets are omitted from the response. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "description": "Verify and store a service-account credential. Use the fields from List credential providers, encoded as a JSON object string in `credentials`. For a keyless AWS, Databricks, Snowflake, Microsoft app, or Google Cloud credential, call Create workload identity subject first, configure the cloud to trust it, then send its `keylessConfig` as `serviceAccountJson`. A refused keyless credential names its cause in `details.code`: `409` for `WORKLOAD_IDENTITY_SUBJECT_IN_USE` or `WORKLOAD_IDENTITY_SUBJECT_UNVERIFIABLE`, otherwise `400`, such as `WORKLOAD_IDENTITY_PROOF_EXPIRED` (request a new subject). Requires workspace write access. A matching source returns the existing credential with `200`; creation returns `201`. Stored secrets are omitted from the response. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", "x-sim-operation": "credentials.service_accounts.create", "x-oauth-scope": "api:write", "tags": ["Credentials"], @@ -3854,6 +3854,16 @@ "displayName": "Zoom automation", "credentials": "{\"clientId\":\"YOUR_CLIENT_ID\",\"clientSecret\":\"YOUR_CLIENT_SECRET\",\"orgId\":\"YOUR_ACCOUNT_ID\"}" } + }, + "example2": { + "summary": "Request example 2", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "type": "service_account", + "providerId": "aws-service-account", + "displayName": "AWS production (keyless)", + "credentials": "{\"serviceAccountJson\":\"{\\\"authType\\\":\\\"oidc_role\\\",\\\"roleArn\\\":\\\"arn:aws:iam::123456789012:role/SimWorkflows\\\",\\\"subject\\\":\\\"sim:workspace::credential:\\\",\\\"subjectProof\\\":\\\"\\\"}\"}" + } } } } @@ -4252,6 +4262,115 @@ } } }, + "/api/v2/credentials/workload-identity/subjects": { + "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`.", + "x-sim-operation": "credentials.workload_identity.prepare", + "x-oauth-scope": "api:write", + "tags": ["Credentials"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:write"] + } + ], + "requestBody": { + "required": true, + "description": "Workspace that will own the keyless credential and the service-account provider it will be created under.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCredentialWorkloadIdentitySubjectRequest" + }, + "examples": { + "example1": { + "summary": "Request example 1", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "providerId": "aws-service-account" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "A subject and proof for one new keyless credential.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCredentialWorkloadIdentitySubjectResponse" + }, + "examples": { + "example1": { + "summary": "Response example 1", + "value": { + "data": { + "issuer": "https://www.sim.ai/oidc/workload", + "audience": "sts.amazonaws.com", + "subject": "sim:workspace::credential:", + "subjectProof": "", + "expiresAt": "2026-06-21T14:17:11.000Z", + "keylessConfig": { + "authType": "oidc_role", + "subject": "sim:workspace::credential:", + "subjectProof": "" + }, + "requiredConfigFields": ["roleArn"], + "optionalConfigFields": ["stsRegion", "sessionDurationSeconds"] + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, "/api/v2/credentials/{credentialId}": { "delete": { "operationId": "deleteCredential", @@ -17812,6 +17931,32 @@ "additionalProperties": false }, "description": "Create-body fields accepted by this provider. Secret fields are write-only." + }, + "keyless": { + "description": "Present when this deployment can create a keyless credential for the provider. Start one with Create workload identity subject.", + "type": "object", + "properties": { + "authType": { + "type": "string", + "description": "The `authType` of the keyless `serviceAccountJson`." + }, + "requiredConfigFields": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Fields you add to `keylessConfig`." + }, + "optionalConfigFields": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Fields `keylessConfig` may also carry." + } + }, + "required": ["authType", "requiredConfigFields", "optionalConfigFields"], + "additionalProperties": false } }, "required": [ @@ -18272,6 +18417,142 @@ } ] }, + "V2CredentialWorkloadIdentitySubject": { + "type": "object", + "properties": { + "issuer": { + "type": "string", + "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.", + "type": "string" + }, + "subject": { + "type": "string", + "description": "The exact `sub` claim the trust configuration must allow; unique to one credential." + }, + "subjectProof": { + "type": "string", + "description": "Opaque proof submitted with `subject` when the credential is created. Bound to the requesting user, workspace, and provider." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp after which `subjectProof` can no longer create a credential." + }, + "keylessConfig": { + "type": "object", + "properties": { + "authType": { + "type": "string", + "description": "The provider's keyless authentication type." + }, + "subject": { + "type": "string", + "description": "The exact `sub` claim the trust configuration must allow; unique to one credential." + }, + "subjectProof": { + "type": "string", + "description": "Opaque proof submitted with `subject` when the credential is created. Bound to the requesting user, workspace, and provider." + } + }, + "required": ["authType", "subject", "subjectProof"], + "additionalProperties": false, + "description": "Prefilled keyless `serviceAccountJson`: add `requiredConfigFields`, then send it to Create service-account credential." + }, + "requiredConfigFields": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Fields from your cloud setup that `keylessConfig` still needs, such as roleArn or host." + }, + "optionalConfigFields": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Fields `keylessConfig` may also carry." + } + }, + "required": [ + "issuer", + "subject", + "subjectProof", + "expiresAt", + "keylessConfig", + "requiredConfigFields", + "optionalConfigFields" + ], + "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." + }, + "CreateCredentialWorkloadIdentitySubjectResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CredentialWorkloadIdentitySubject" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create workload identity subject response", + "description": "Trust values to configure in the cloud, and the proof to submit when creating the credential.", + "examples": [ + { + "data": { + "issuer": "https://www.sim.ai/oidc/workload", + "audience": "sts.amazonaws.com", + "subject": "sim:workspace::credential:", + "subjectProof": "", + "expiresAt": "2026-06-21T14:17:11.000Z", + "keylessConfig": { + "authType": "oidc_role", + "subject": "sim:workspace::credential:", + "subjectProof": "" + }, + "requiredConfigFields": ["roleArn"], + "optionalConfigFields": ["stsRegion", "sessionDurationSeconds"] + } + } + ] + }, + "CreateCredentialWorkloadIdentitySubjectRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that will own the keyless credential." + }, + "providerId": { + "type": "string", + "enum": [ + "aws-service-account", + "databricks-service-account", + "snowflake-service-account", + "microsoft-app-service-account", + "google-service-account" + ], + "description": "Service-account provider the keyless credential will be created under; pass the same value to Create service-account credential. The response and List credential providers name the fields its keyless configuration needs." + } + }, + "required": ["workspaceId", "providerId"], + "additionalProperties": false, + "title": "Create workload identity subject request", + "description": "Workspace that will own the keyless credential and the service-account provider it will be created under.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "providerId": "aws-service-account" + } + ] + }, "V2CredentialDeleteData": { "type": "object", "properties": { diff --git a/apps/docs/scripts/fixtures/content-baseline.json b/apps/docs/scripts/fixtures/content-baseline.json index 189985604ba..546d9d353ae 100644 --- a/apps/docs/scripts/fixtures/content-baseline.json +++ b/apps/docs/scripts/fixtures/content-baseline.json @@ -2997,70 +2997,91 @@ "meta": null, "sha256": "e407b3388ac13c30e985d64e41a9fb8f68c52e6e7a020bc3db79ce9473944724", "bytes": 40, - "line": 16 + "line": 20 }, { "lang": "bash", "meta": null, "sha256": "cb3d67be4a05a8cc95870cadb5ac682c16e80889db6606e388dd48651bfc4af4", "bytes": 30, - "line": 34 + "line": 38 }, { "lang": "bash", "meta": null, "sha256": "3ce249d6b52442bdf5df53436bae6b9718064994b4b26ecf25372ce53606db08", "bytes": 45, - "line": 59 + "line": 63 + }, + { + "lang": "bash", + "meta": null, + "sha256": "e7dfe3c54d5966f79a4c8fcc7287fe9c29e893326977e4264a7cdc35822c076f", + "bytes": 262, + "line": 102 }, { "lang": "bash", "meta": null, "sha256": "e0ac83da55217195ac0ca3bd1a41314be433d0a62428f36eb9666df607e8322c", "bytes": 46, - "line": 92 + "line": 114 }, { "lang": "bash", "meta": null, "sha256": "664dfa9d010be73d8c8d4c1b079e946a646cc4677891d5fd34df9e5817cabfed", "bytes": 40, - "line": 122 + "line": 144 }, { "lang": "bash", "meta": null, "sha256": "f7e2cb3ed34992b91cdcbd2fa65eadb48134c31e0d295519421644ad0668f20e", "bytes": 47, - "line": 142 + "line": 164 }, { "lang": "bash", "meta": null, "sha256": "4051f5bb25072a25196ba8e847ab22b9d42aeb0cbb36186b5e7cc051d77c00e0", "bytes": 53, - "line": 196 + "line": 219 }, { "lang": "bash", "meta": null, "sha256": "a74b652635bc27ca58af7b080b78d983469558a2668f0b3f033a1b5b2f4cef10", "bytes": 55, - "line": 228 + "line": 251 }, { "lang": "bash", "meta": null, "sha256": "2b6d0db0f06b8f51733c7da29a0c183aab73a6da83b787db76e7c3064fc9c78c", "bytes": 64, - "line": 261 + "line": 284 }, { "lang": "bash", "meta": null, "sha256": "ef5100ffa39072ae2ba9bb021d546036b95ded8da431fad855f809550c8913fb", "bytes": 47, - "line": 296 + "line": 319 + }, + { + "lang": "bash", + "meta": null, + "sha256": "57a7270a0eaa11ca8fe83eedaf7846ab9193ecbc2e2a1d2189f5a350d7881fdc", + "bytes": 44, + "line": 351 + }, + { + "lang": "bash", + "meta": null, + "sha256": "e7dfe3c54d5966f79a4c8fcc7287fe9c29e893326977e4264a7cdc35822c076f", + "bytes": 262, + "line": 375 } ], "links": [ @@ -10961,2317 +10982,2338 @@ "meta": null, "sha256": "e89f5163bfcc02bcec8be44abb82d7807ccaeecc397ea8b3d8575e189f05a46a", "bytes": 19, - "line": 36 + "line": 40 }, { "lang": "bash", "meta": null, "sha256": "3ce59639490b19f90613bf0b002f0d5f747ae6bf89110a31aaad132f4a5d56df", "bytes": 20, - "line": 58 + "line": 62 }, { "lang": "bash", "meta": null, "sha256": "7be8dbf1bad323118735b5a0b4cdf49c653cc20508516225fe058776f0421e2e", "bytes": 20, - "line": 76 + "line": 80 }, { "lang": "bash", "meta": null, "sha256": "5b13d0a219062e58827d50697e4cec707e48ebef466a5058abb6e524376cc18e", "bytes": 23, - "line": 96 + "line": 100 }, { "lang": "bash", "meta": null, "sha256": "fac9741c4ccc5f0596cba54e6e2ecc4997e489032315eb4c80dd872c941dd0ae", "bytes": 20, - "line": 117 + "line": 121 }, { "lang": "bash", "meta": null, "sha256": "cc2a546bb69dcb90170c414517e67c86b5a20c7918c28999cb06d15f19ad4774", "bytes": 28, - "line": 135 + "line": 139 }, { "lang": "bash", "meta": null, "sha256": "f22525d91b0a915d24281bdac40a70c066449d9310582f844446e4fb2d9b347f", "bytes": 181, - "line": 163 + "line": 167 }, { "lang": "bash", "meta": null, "sha256": "21d922f40e072101cbcfdfcd4ee3c6e9ca22fc35bc4828a37f79f57dafec8d02", "bytes": 17, - "line": 181 + "line": 185 }, { "lang": "bash", "meta": null, "sha256": "56b302d091c4b589dde86d24739634d8f7d560873ca281d9962939aa6ff46ac1", "bytes": 33, - "line": 189 + "line": 193 }, { "lang": "bash", "meta": null, "sha256": "ce318a355b5dabeedbde09c7ddeb4f9132a4a57501b15fd3c2d23b3b8c75fd7f", "bytes": 20, - "line": 223 + "line": 227 }, { "lang": "bash", "meta": null, "sha256": "c74ad02c1945ec9c5cb8316f5ffeb64cc0b9dde01530570343e2dd2ee5c2ec23", "bytes": 20, - "line": 233 + "line": 237 }, { "lang": "bash", "meta": null, "sha256": "abe19c862f574d48aa91f6a506496b6f6c414d03fab399a6c86e1c12b2f8302a", "bytes": 21, - "line": 241 + "line": 245 }, { "lang": "bash", "meta": null, "sha256": "5c4299cf8aa6117a3a1a3c728d72c2a86f9627a5766d47be8ae1f03088642346", "bytes": 25, - "line": 253 + "line": 257 }, { "lang": "bash", "meta": null, "sha256": "cf03b326f03d9bcb4260e2be1ca5bcb89551a8f7f2bb1ba85744f50a9a84b434", "bytes": 73, - "line": 271 + "line": 275 }, { "lang": "bash", "meta": null, "sha256": "1350c2e26838e2b4a68726a51de8a06e2a5d32cc9714e0171c0dafd867db1d90", "bytes": 41, - "line": 288 + "line": 292 }, { "lang": "bash", "meta": null, "sha256": "8b747ef16c178bb05ca7171ec48e801f20a250e774a44d2d3b9753bd69aa3ec2", "bytes": 29, - "line": 318 + "line": 322 }, { "lang": "bash", "meta": null, "sha256": "88526e37fa1577427e6126306dc69d3afa94894d3dd313ae31286bff5eadafa4", "bytes": 28, - "line": 353 + "line": 357 }, { "lang": "bash", "meta": null, "sha256": "437c1deab5031ec9ce664b5429f7b62c062fd592469411b7f61cc16a9e21aa15", "bytes": 26, - "line": 373 + "line": 377 }, { "lang": "bash", "meta": null, "sha256": "575f3eca46c258a161e39e0bb8154f4e8ea02ed85bc48ba09e313bad08b2bdd7", "bytes": 24, - "line": 405 + "line": 409 }, { "lang": "bash", "meta": null, "sha256": "ecc11df2b60a4b0dc7d6b4e95f6fbee1f43554a8e32ee7232d50543a94e22273", "bytes": 25, - "line": 425 + "line": 429 }, { "lang": "bash", "meta": null, "sha256": "451b8c0648b69af0d37673c7b6c4bd98eb8ce4baca0c902038fc1460938adf88", "bytes": 35, - "line": 457 + "line": 461 }, { "lang": "bash", "meta": null, "sha256": "5037ed0778643f28636735f01bb24cd5f938bdc923f02abfacf6e37840a822f7", "bytes": 34, - "line": 488 + "line": 492 }, { "lang": "bash", "meta": null, "sha256": "ef5100ffa39072ae2ba9bb021d546036b95ded8da431fad855f809550c8913fb", "bytes": 47, - "line": 521 + "line": 525 }, { "lang": "bash", "meta": null, "sha256": "4051f5bb25072a25196ba8e847ab22b9d42aeb0cbb36186b5e7cc051d77c00e0", "bytes": 53, - "line": 551 + "line": 555 }, { "lang": "bash", "meta": null, "sha256": "2b6d0db0f06b8f51733c7da29a0c183aab73a6da83b787db76e7c3064fc9c78c", "bytes": 64, - "line": 585 + "line": 589 }, { "lang": "bash", "meta": null, "sha256": "a74b652635bc27ca58af7b080b78d983469558a2668f0b3f033a1b5b2f4cef10", "bytes": 55, - "line": 616 + "line": 620 }, { "lang": "bash", "meta": null, "sha256": "e407b3388ac13c30e985d64e41a9fb8f68c52e6e7a020bc3db79ce9473944724", "bytes": 40, - "line": 647 + "line": 651 }, { "lang": "bash", "meta": null, "sha256": "cb3d67be4a05a8cc95870cadb5ac682c16e80889db6606e388dd48651bfc4af4", "bytes": 30, - "line": 667 + "line": 671 }, { "lang": "bash", "meta": null, "sha256": "f7e2cb3ed34992b91cdcbd2fa65eadb48134c31e0d295519421644ad0668f20e", "bytes": 47, - "line": 692 + "line": 696 }, { "lang": "bash", "meta": null, "sha256": "3ce249d6b52442bdf5df53436bae6b9718064994b4b26ecf25372ce53606db08", "bytes": 45, - "line": 744 + "line": 749 + }, + { + "lang": "bash", + "meta": null, + "sha256": "e7dfe3c54d5966f79a4c8fcc7287fe9c29e893326977e4264a7cdc35822c076f", + "bytes": 262, + "line": 788 + }, + { + "lang": "bash", + "meta": null, + "sha256": "57a7270a0eaa11ca8fe83eedaf7846ab9193ecbc2e2a1d2189f5a350d7881fdc", + "bytes": 44, + "line": 800 + }, + { + "lang": "bash", + "meta": null, + "sha256": "e7dfe3c54d5966f79a4c8fcc7287fe9c29e893326977e4264a7cdc35822c076f", + "bytes": 262, + "line": 824 }, { "lang": "bash", "meta": null, "sha256": "e0ac83da55217195ac0ca3bd1a41314be433d0a62428f36eb9666df607e8322c", "bytes": 46, - "line": 777 + "line": 836 }, { "lang": "bash", "meta": null, "sha256": "664dfa9d010be73d8c8d4c1b079e946a646cc4677891d5fd34df9e5817cabfed", "bytes": 40, - "line": 807 + "line": 866 }, { "lang": "bash", "meta": null, "sha256": "55d8bb7d28001fafb0a76ccaf87863cb67f0150ffef73889e5c363ce3775131b", "bytes": 33, - "line": 833 + "line": 892 }, { "lang": "bash", "meta": null, "sha256": "02ff8ad73ece2f21edb90fc1a69904f322032da6d90a23d28d2d490beb55ab53", "bytes": 48, - "line": 857 + "line": 916 }, { "lang": "bash", "meta": null, "sha256": "06a7f6e71ab68db991126dcf2d660866aec7b89d27f751d082d2f90030a19b00", "bytes": 35, - "line": 887 + "line": 946 }, { "lang": "bash", "meta": null, "sha256": "3c9e74b86271246976e186b32c311a50f367fcd701f0506a56c8c8150c01b400", "bytes": 31, - "line": 907 + "line": 966 }, { "lang": "bash", "meta": null, "sha256": "c0bbfff139a69379e188cfe4bbd3393285fce565dd83f7098eb2d387356da9c4", "bytes": 48, - "line": 930 + "line": 989 }, { "lang": "bash", "meta": null, "sha256": "c569c5e38502a1c1e8f6078954bc90b559dd80adb577bb4cf7b63ec7bbdb3aa7", "bytes": 46, - "line": 970 + "line": 1029 }, { "lang": "bash", "meta": null, "sha256": "939a624f32ea05ac4cddd6037a27cdc7829018f0ac8096e4afd85aa9fab93ea2", "bytes": 24, - "line": 991 + "line": 1060 }, { "lang": "bash", "meta": null, "sha256": "428eca668e88f6323ab89a2e488c5fd403b8b04064b26b55a3b8ca7ddb966be5", "bytes": 26, - "line": 1010 + "line": 1079 }, { "lang": "bash", "meta": null, "sha256": "66b86826e7fbeb5cc1b35c54dcbf9727264c2b6d50528e8c21accaff3a708806", "bytes": 31, - "line": 1034 + "line": 1103 }, { "lang": "bash", "meta": null, "sha256": "a461b9c9fef3090d3367148a2a183410e81624e42e340c21a4138c30c6b4409c", "bytes": 41, - "line": 1056 + "line": 1125 }, { "lang": "bash", "meta": null, "sha256": "8c86de167a3b73c5430d2b15f558b7c0b2a82cd347b4e85907862196cf8e16bc", "bytes": 32, - "line": 1087 + "line": 1156 }, { "lang": "bash", "meta": null, "sha256": "8a9eb6688329292f21fecba059890d65dab0a136e86f26d38987c27d68013ad8", "bytes": 43, - "line": 1115 + "line": 1184 }, { "lang": "bash", "meta": null, "sha256": "be3c635d1b158a14e8e00c6e88d84e4126e29d89f0464ebf0b296f19b2bdf59b", "bytes": 32, - "line": 1138 + "line": 1207 }, { "lang": "bash", "meta": null, "sha256": "993262e619d4470867077b7a4e0c528f715be468511bf9e072e8a9429ef250a1", "bytes": 35, - "line": 1160 + "line": 1229 }, { "lang": "bash", "meta": null, "sha256": "adc4973b83b313bcaf5f9058297e030a9c272cb1b5612fe8465b02edf2ad1b73", "bytes": 54, - "line": 1192 + "line": 1261 }, { "lang": "bash", "meta": null, "sha256": "48fbd97b704f5e02a593cbf0020bc3c6600ff1b4d349ae80b3cba39225a4b9fc", "bytes": 46, - "line": 1223 + "line": 1292 }, { "lang": "bash", "meta": null, "sha256": "49e712965d1a8af321563bc382bb448e0b68067c3c68861f84896b5f97e70314", "bytes": 42, - "line": 1244 + "line": 1313 }, { "lang": "bash", "meta": null, "sha256": "e463cbe0c80554eaa7fe151c51523307240c2226a54924a3d984cdc0a84f529b", "bytes": 52, - "line": 1277 + "line": 1346 }, { "lang": "bash", "meta": null, "sha256": "23bede1f0d68221f4da857a62e0594d031e40e5a794bd96c79eba2de2924072a", "bytes": 54, - "line": 1310 + "line": 1379 }, { "lang": "bash", "meta": null, "sha256": "ad1df0d9a424569351d851507b674bb104654e1251d7bee11ebc6955e0f2be6c", "bytes": 56, - "line": 1342 + "line": 1411 }, { "lang": "bash", "meta": null, "sha256": "37cdd4ceab29424161734300286176b9971cd7d6c134022308d5b21e4fa9e714", "bytes": 33, - "line": 1374 + "line": 1443 }, { "lang": "bash", "meta": null, "sha256": "0bc8335422d07dfbc74c55102518d6d2965a5aa27be6994b8b4317478e13ef3d", "bytes": 37, - "line": 1405 + "line": 1474 }, { "lang": "bash", "meta": null, "sha256": "863b992687ec7121ef44d5680b290502052bfe51e7e43aa24376ddba51fc3841", "bytes": 28, - "line": 1435 + "line": 1504 }, { "lang": "bash", "meta": null, "sha256": "9d7e15ac24392a0771cf7857c3f110be1ede32e18427995923edc5978fdcaa31", "bytes": 38, - "line": 1455 + "line": 1524 }, { "lang": "bash", "meta": null, "sha256": "15849bbb5c9f4f1c0754c0c99d3048b910f6fdcbb02ab84a4e34d7ed1930cd15", "bytes": 24, - "line": 1488 + "line": 1557 }, { "lang": "bash", "meta": null, "sha256": "31cbe1979c2c645d78646d966ac83fd45e6b556d56ab0d8fc47a7abd281e524d", "bytes": 24, - "line": 1515 + "line": 1584 }, { "lang": "bash", "meta": null, "sha256": "1a728e81af7425e00df25a70a5819d272cbddcab487426cebc94de7834509999", "bytes": 33, - "line": 1538 + "line": 1607 }, { "lang": "bash", "meta": null, "sha256": "f7a8e9f75a2ea3d88d6d12b097f20a94e2b915741cc10f805b070c6ad4261a7e", "bytes": 35, - "line": 1570 + "line": 1639 }, { "lang": "bash", "meta": null, "sha256": "94537050876dfc16340c4db7965fcc2a098f09fcb9911d5e154c11721d212e6a", "bytes": 26, - "line": 1600 + "line": 1669 }, { "lang": "bash", "meta": null, "sha256": "cdee40f5dc3195f9db4462ddd0e5b7bc03b44f600a82f357d6b651ecee770840", "bytes": 26, - "line": 1620 + "line": 1689 }, { "lang": "bash", "meta": null, "sha256": "bda181f24ee73df10ced9c179ddb81b115d62b4da46eb64b63dd23e7f4c51a96", "bytes": 34, - "line": 1647 + "line": 1716 }, { "lang": "bash", "meta": null, "sha256": "14e217553dedf133ef014fd989058ee281844a55293e05a9dddf044947c058fe", "bytes": 40, - "line": 1677 + "line": 1746 }, { "lang": "bash", "meta": null, "sha256": "30f91370cbb0ddf76900ab363e83bb26c0a95cb81518de875885f66d4c9df4a3", "bytes": 33, - "line": 1709 + "line": 1778 }, { "lang": "bash", "meta": null, "sha256": "1c143b6c0cd2e76ce8c74504ecb5ee2560b147eb299d8f0d3d956854b706958c", "bytes": 32, - "line": 1740 + "line": 1809 }, { "lang": "bash", "meta": null, "sha256": "b36a5102aee98e664a1ca4cc19d91310af00b2a4afbb04c78589bb4c3a2936a9", "bytes": 29, - "line": 1771 + "line": 1840 }, { "lang": "bash", "meta": null, "sha256": "62a17f5231fba3f732cba025c16c8b6706411b9267e6abd58e4e51aff8777043", "bytes": 22, - "line": 1802 + "line": 1871 }, { "lang": "bash", "meta": null, "sha256": "760a88e6d91ed147f40d39808ae39d178b6c38b61a5c69dd37755079cff376c2", "bytes": 69, - "line": 1828 + "line": 1897 }, { "lang": "bash", "meta": null, "sha256": "e0173107cc10779c1b96d55c66a3bd628e9bc21b39234101e2f9d7519087780e", "bytes": 51, - "line": 1858 + "line": 1927 }, { "lang": "bash", "meta": null, "sha256": "71bb24f623f1958775fe0f9df499ad3f8241c3132ea8f8a40d5f26bd2041b65e", "bytes": 53, - "line": 1888 + "line": 1957 }, { "lang": "bash", "meta": null, "sha256": "51d1aa93ed9262fa8f275fbd24e805ad9a3a075ed2f5c3888d57ecf909426c3e", "bytes": 61, - "line": 1922 + "line": 1991 }, { "lang": "bash", "meta": null, "sha256": "4782c15ea248b0cb3a7cffa4242b54f9bea866ecf26f63ada4ed921455699cb3", "bytes": 54, - "line": 1955 + "line": 2024 }, { "lang": "bash", "meta": null, "sha256": "c8f14f40bbc787d13778c342211d73c9f0b118a8d37d3c82a87c72b42b9d5d93", "bytes": 56, - "line": 1987 + "line": 2056 }, { "lang": "bash", "meta": null, "sha256": "913db46a26314244a1e4c903adb6772a8e405da8cba64f7218ff3683d7daa72a", "bytes": 41, - "line": 2017 + "line": 2086 }, { "lang": "bash", "meta": null, "sha256": "7cda9f00d098a51943b10891142d7e6a8d4cb46d752e1cdae5825ab6c56b8aa9", "bytes": 42, - "line": 2037 + "line": 2106 }, { "lang": "bash", "meta": null, "sha256": "e09f666b66b4d7acdcd6aa26831c7f258ff49baa0fd762ea920b90fd39846582", "bytes": 61, - "line": 2057 + "line": 2126 }, { "lang": "bash", "meta": null, "sha256": "8b204243cf243c64557adb522d2bfd72be53400afab3c03f4c314117ed37655d", "bytes": 74, - "line": 2091 + "line": 2160 }, { "lang": "bash", "meta": null, "sha256": "cf87bc61bba385818ebc622f9b9f481213e1d86f65df7ab1a193749ab929a651", "bytes": 68, - "line": 2124 + "line": 2193 }, { "lang": "bash", "meta": null, "sha256": "2d57fee1358f9ce5a3f166aed1400940cbe737b04d8f487a16eb35bc3bb5a6d4", "bytes": 78, - "line": 2159 + "line": 2228 }, { "lang": "bash", "meta": null, "sha256": "7f02636a02ece5cd252aad171e251b18e1c29207d62dfcc962485052b799fdc4", "bytes": 65, - "line": 2191 + "line": 2260 }, { "lang": "bash", "meta": null, "sha256": "e6a2660057e8859cebd226a4420672f12beed4e274bdc57a4b70c96a0142b1b6", "bytes": 66, - "line": 2213 + "line": 2282 }, { "lang": "bash", "meta": null, "sha256": "54b00a2d3830092a8fe552bba29419710da6647ac3cc96d6a0a9ffbef596c440", "bytes": 78, - "line": 2249 + "line": 2318 }, { "lang": "bash", "meta": null, "sha256": "a0a28be794c70e6366719b49f3ad627faa087181960d287d6a17bc44d7a8bd9d", "bytes": 64, - "line": 2283 + "line": 2352 }, { "lang": "bash", "meta": null, "sha256": "2a48509e548ba0f0d2bb97bfc37ac1807f3b34cc5c44cbd1c5ca57735614fc24", "bytes": 71, - "line": 2318 + "line": 2387 }, { "lang": "bash", "meta": null, "sha256": "ea154f4a00845d5c60d54aff14d7173c8de383ec43e13454a8c326459ed7695f", "bytes": 58, - "line": 2349 + "line": 2418 }, { "lang": "bash", "meta": null, "sha256": "f14623530e7aeb20de131c59208269f9703e85ab6a89cd4368fb3c2ff7c36863", "bytes": 56, - "line": 2370 + "line": 2439 }, { "lang": "bash", "meta": null, "sha256": "cdf3ade3d9fa9f1f3a191265a2b0de8092eaaa49bd961d94eccb09355dfddb26", "bytes": 71, - "line": 2406 + "line": 2475 }, { "lang": "bash", "meta": null, "sha256": "2f509b586cc62ff6bb0b5c1ee7e5062689b1cfd987acc2c18dee6b10bc3a194f", "bytes": 65, - "line": 2460 + "line": 2529 }, { "lang": "bash", "meta": null, "sha256": "6546c4a95a217203c622f0e62f945cbe2863d42d02a5229621f43f12b68bc063", "bytes": 39, - "line": 2494 + "line": 2563 }, { "lang": "bash", "meta": null, "sha256": "e9f1685d2a50e4bd554c7daf19621a7704aa5b1aea3540af28884066ae413c69", "bytes": 36, - "line": 2514 + "line": 2583 }, { "lang": "bash", "meta": null, "sha256": "ad0c1107caee2427422e0a52a6da2df0a037010f93467fd957fb0136f938d675", "bytes": 30, - "line": 2534 + "line": 2603 }, { "lang": "bash", "meta": null, "sha256": "fae160a2cd7e296a9f9b00fa789ba95a2f1e507713a469902de21ed945d9eb25", "bytes": 59, - "line": 2557 + "line": 2626 }, { "lang": "bash", "meta": null, "sha256": "abdae7f49c77877550cfc2d7195132c12fb0117a0471faf1c525d44ad2de1321", "bytes": 73, - "line": 2593 + "line": 2662 }, { "lang": "bash", "meta": null, "sha256": "d11c4c59feac4345ba7dfb02edc2780adae89cf702e570f32e1ccc8555bdb169", "bytes": 60, - "line": 2626 + "line": 2695 }, { "lang": "bash", "meta": null, "sha256": "af533689c0b530ff902ae36c2cac7198f43ff6968d168a94da153d81fe8020fd", "bytes": 81, - "line": 2647 + "line": 2716 }, { "lang": "bash", "meta": null, "sha256": "f907eb4bee803d3f45d4abc9baae106aaec0249143dfd9cf56aa17285776a430", "bytes": 83, - "line": 2681 + "line": 2750 }, { "lang": "bash", "meta": null, "sha256": "28c4e709c778f27d924fbe49edb4bf198b7c73e9b65619e6fad43d1fb9e2cfb7", "bytes": 57, - "line": 2713 + "line": 2782 }, { "lang": "bash", "meta": null, "sha256": "0a7262cac83ce7c16b17a1364a123764e5fa8b645dd60fd5b394b3f74dd0e1fa", "bytes": 71, - "line": 2745 + "line": 2814 }, { "lang": "bash", "meta": null, "sha256": "a25c869b7add79bfe6f194ec72190bb6b4957bb303fe6b4c31c9455f4a2e11a9", "bytes": 73, - "line": 2777 + "line": 2846 }, { "lang": "bash", "meta": null, "sha256": "b3f9e190362420216749c9924e83a35c5a5c2414ab03ff89601a6b9501494bf9", "bytes": 35, - "line": 2810 + "line": 2879 }, { "lang": "bash", "meta": null, "sha256": "ae5dcbc80aeb125607ebd763669a1f335d6ddf087a6baf1853453867675ce865", "bytes": 45, - "line": 2832 + "line": 2901 }, { "lang": "bash", "meta": null, "sha256": "c91b2cb83c28571b965071d6643d955366931a6ca3dd340e54d8536ea90cefde", "bytes": 36, - "line": 2863 + "line": 2932 }, { "lang": "bash", "meta": null, "sha256": "de622393fa33cc9b0002c7d670d9f45c2bcd9fbfe3f0c99be71ae96969a005dd", "bytes": 47, - "line": 2888 + "line": 2957 }, { "lang": "bash", "meta": null, "sha256": "356cb12341ce90e24b765d02a997421c0469a3b26509c90e42a17af4dd3f9e95", "bytes": 48, - "line": 2913 + "line": 2982 }, { "lang": "bash", "meta": null, "sha256": "a611d4f425ac65781756225557a7e8f8559faccaa4c6466ef23fa78cd2a2226c", "bytes": 35, - "line": 2943 + "line": 3012 }, { "lang": "bash", "meta": null, "sha256": "8c7bab52ede04cca1dd71b466bd76c7f5b67c6b7f0fed08e14ee7656d6b2b160", "bytes": 28, - "line": 2963 + "line": 3032 }, { "lang": "bash", "meta": null, "sha256": "063e71c7485fe7d3657e692841a2f0629e6a31b48e8501282b0456202eefaa5d", "bytes": 39, - "line": 2988 + "line": 3057 }, { "lang": "bash", "meta": null, "sha256": "4f9e7b46b98628ddded377f3a73cb2e9b4751023b7b1fc90c1cefc90555c80c4", "bytes": 30, - "line": 3008 + "line": 3077 }, { "lang": "bash", "meta": null, "sha256": "efaaf65fd8057cd592d386d0e0c800eef4e71d649a1f011fd2f20495d8d942ab", "bytes": 48, - "line": 3036 + "line": 3105 }, { "lang": "bash", "meta": null, "sha256": "5303a1a6194097b3acc7546ccafb0faced44b084951b7e14ff1029cf0fdff957", "bytes": 43, - "line": 3069 + "line": 3138 }, { "lang": "bash", "meta": null, "sha256": "a9ba0ea93105e0f224ed0c3e661adf5fe706a875fb9741348134814d65c6d882", "bytes": 48, - "line": 3090 + "line": 3159 }, { "lang": "bash", "meta": null, "sha256": "a721f46390cb321c013b700b13f46f2e9833616be44a3ee8e0971c3e5c0f945b", "bytes": 37, - "line": 3122 + "line": 3191 }, { "lang": "bash", "meta": null, "sha256": "9f9b6ea8992e8fcd2bdb540857d42288ba0ecedb959ff5c1c56de708e65db79e", "bytes": 33, - "line": 3153 + "line": 3222 }, { "lang": "bash", "meta": null, "sha256": "1478c9884eb8c9bc2dd64170d3fb101030957399aab5da911d5f77d46b662c73", "bytes": 26, - "line": 3184 + "line": 3253 }, { "lang": "bash", "meta": null, "sha256": "51b81826c6f4b7933c1c802c82baca186c67efe8e59e69a6a7754360fe8675da", "bytes": 30, - "line": 3210 + "line": 3279 }, { "lang": "bash", "meta": null, "sha256": "8aeb5fc735c8037c480f3c8220149fb6f811de62c946584523d50d1cd1811a53", "bytes": 24, - "line": 3242 + "line": 3311 }, { "lang": "bash", "meta": null, "sha256": "a1bf4221604cc962c677313543850fe6cd140476ef52de3a9e29b305865ae8f0", "bytes": 23, - "line": 3271 + "line": 3340 }, { "lang": "bash", "meta": null, "sha256": "5215bc6f0c098350b8067dd283226f3444cf73599ab1989c5dfae5c563069a04", "bytes": 25, - "line": 3315 + "line": 3384 }, { "lang": "bash", "meta": null, "sha256": "98f84efeb01c77bc8c1aadb62fbd989c5ae41de97d07fd9b59e8b2b53cafe06f", "bytes": 145, - "line": 3339 + "line": 3408 }, { "lang": "bash", "meta": null, "sha256": "baad97733d922f4ef4c068577ef9f0f855c44f3b54e9b80a61733a4fecfd350d", "bytes": 32, - "line": 3359 + "line": 3428 }, { "lang": "bash", "meta": null, "sha256": "fdb563355cb89d0b6f534e825e70973c85326879a7d4f0adaee447ed78db5260", "bytes": 46, - "line": 3392 + "line": 3461 }, { "lang": "bash", "meta": null, "sha256": "8409df5d28808a04cbea45c4f0162a7a283a0d4eee0fc3391d82a8663121a495", "bytes": 33, - "line": 3422 + "line": 3491 }, { "lang": "bash", "meta": null, "sha256": "c3189f21b856e8c158f2a39d3260d7c0ac93ba2c0c73494c7e1374d45a51346f", "bytes": 30, - "line": 3442 + "line": 3511 }, { "lang": "bash", "meta": null, "sha256": "066839a4599d90ccd88f845122e61c608a8ea120e9f1476857f0a2eb8c723de7", "bytes": 50, - "line": 3465 + "line": 3534 }, { "lang": "bash", "meta": null, "sha256": "209da9ab7f502fbcaec3297113212d496eca57b124e273dc8080efa6c4e0de71", "bytes": 46, - "line": 3496 + "line": 3565 }, { "lang": "bash", "meta": null, "sha256": "c01ee6bd733ccd61abdb244baa88a17758166a1eacaa4456d3a8bbe56e6ff00f", "bytes": 15, - "line": 3541 + "line": 3610 }, { "lang": "bash", "meta": null, "sha256": "e2c2a5ffcc518a49f6cc038c691b068769101b4bf94905eb0a472299ab563620", "bytes": 39, - "line": 3555 + "line": 3624 }, { "lang": "bash", "meta": null, "sha256": "ccaaa978abfa31e70784eda567c50bec64a5cf485a205fa545e71f46c1821f3a", "bytes": 40, - "line": 3576 + "line": 3645 }, { "lang": "bash", "meta": null, "sha256": "7e6eac72f3011e3714d4eeb9914601dbe344ff899504c1324ff55c2b6eed60d5", "bytes": 53, - "line": 3601 + "line": 3670 }, { "lang": "bash", "meta": null, "sha256": "d557d9bb45aa7b4ee10d2d502d92710aafe9fcbc7b12d0a219c3eff49863e1f7", "bytes": 53, - "line": 3632 + "line": 3701 }, { "lang": "bash", "meta": null, "sha256": "6f4dc8213285da7c740b7f183f519a14d88e853125347cf4b3585c153f3d6c95", "bytes": 62, - "line": 3662 + "line": 3731 }, { "lang": "bash", "meta": null, "sha256": "c7934b9e19cf7a0ff636ecdb465b1a6727ad23d36b695eb0b08783efa08698d3", "bytes": 50, - "line": 3692 + "line": 3761 }, { "lang": "bash", "meta": null, "sha256": "c6c30823efb18475aef576f5eac37ab1a319e1de7b2462fccb62b249396e3d9f", "bytes": 52, - "line": 3714 + "line": 3783 }, { "lang": "bash", "meta": null, "sha256": "8567533e04160a832142ff673958ff2456ea163f86df91685ba5da691604a882", "bytes": 56, - "line": 3741 + "line": 3810 }, { "lang": "bash", "meta": null, "sha256": "f8809dfed9678563c959c376cf67dcc022eeee8e5e4384e8aa46ff6470838960", "bytes": 59, - "line": 3761 + "line": 3830 }, { "lang": "bash", "meta": null, "sha256": "c4202b9b75440588bb6562fce248037a4425448373f72f9ee3ad9ade135fab34", "bytes": 48, - "line": 3782 + "line": 3851 }, { "lang": "bash", "meta": null, "sha256": "711c4598e2329b7c5079f9cc2569196c4af7ebfa267e38ab904ff60e579e5169", "bytes": 48, - "line": 3807 + "line": 3876 }, { "lang": "bash", "meta": null, "sha256": "ef9a879b046809272052e1804f9ebe98b60cd0cdf476251b599ca44bb3ae1b11", "bytes": 63, - "line": 3833 + "line": 3902 }, { "lang": "bash", "meta": null, "sha256": "b07d1aa721f8a38cfc8e79bc68497e76240430fcc1fec0818294974f67755a1d", "bytes": 63, - "line": 3863 + "line": 3932 }, { "lang": "bash", "meta": null, "sha256": "6231d67b8fca5d05ef85468209b86d04c0c016014f46f2f444da5321bd655941", "bytes": 46, - "line": 3897 + "line": 3966 }, { "lang": "bash", "meta": null, "sha256": "30a1944929f1b7cdc8d8866735022851ef133f4160fc018f736201f1531e7907", "bytes": 58, - "line": 3919 + "line": 3988 }, { "lang": "bash", "meta": null, "sha256": "6ce2fc39c89d2789f343b2590c28ff069d4bcf685241cdfd645d579b886625a5", "bytes": 44, - "line": 3949 + "line": 4018 }, { "lang": "bash", "meta": null, "sha256": "edbc6c008ae5039761ca84f5af6c63c15aab125fe0bc7b890362159598318cf6", "bytes": 65, - "line": 3975 + "line": 4044 }, { "lang": "bash", "meta": null, "sha256": "cdeb9be48212517be6994c625844b9761842bd3822c74e7d86970a022811a07d", "bytes": 61, - "line": 4010 + "line": 4079 }, { "lang": "bash", "meta": null, "sha256": "7f3233f2465f84ad271fbe67411e1651f83d9dd39e15128c477276a103443baa", "bytes": 61, - "line": 4042 + "line": 4111 }, { "lang": "bash", "meta": null, "sha256": "af80c56179adf35f94c4c1c3eb3676cf2ca68b61af3c1b43a9ac020c4205f4a2", "bytes": 61, - "line": 4075 + "line": 4144 }, { "lang": "bash", "meta": null, "sha256": "48fced6ea852f10aa3620044ca010c0bf50acfb3b743012f836a98be0d7cdf08", "bytes": 58, - "line": 4106 + "line": 4175 }, { "lang": "bash", "meta": null, "sha256": "6d9a078ab511579f7f7234fa28d72ae92f7df84c9f9957aff14951a2fe1b08f3", "bytes": 46, - "line": 4136 + "line": 4205 }, { "lang": "bash", "meta": null, "sha256": "88b2622d7dc41ea41543f26e4dd1b513c271cb0e0452b937033088f7ae80484f", "bytes": 46, - "line": 4161 + "line": 4230 }, { "lang": "bash", "meta": null, "sha256": "757036af4cd38ae573a0589ddcd08b179542a30bfa802f88ff3debb198baae47", "bytes": 62, - "line": 4209 + "line": 4278 }, { "lang": "bash", "meta": null, "sha256": "7311b5a978eda8140f8e314d709e565a535c76064199595eb139c015f16312d5", "bytes": 42, - "line": 4239 + "line": 4308 }, { "lang": "bash", "meta": null, "sha256": "c3410310e5eccb2e0a7440b8d3cbfc2508270890a08ec5a85c5b8cc5c5a3b8b8", "bytes": 45, - "line": 4259 + "line": 4328 }, { "lang": "bash", "meta": null, "sha256": "08f1a585205b784ac0527c4ffb9aae95a4d8bd1702abddb87d1aa25415de45d2", "bytes": 38, - "line": 4280 + "line": 4349 }, { "lang": "bash", "meta": null, "sha256": "e4502c5ec3c9643358cf434f3271f7f39ee39f899661c58e7af68b98f42cbcb1", "bytes": 60, - "line": 4300 + "line": 4369 }, { "lang": "bash", "meta": null, "sha256": "59e8699f41a3f06be75f1eb4addb041b762d100d6e15f61788e729a8a277f55f", "bytes": 63, - "line": 4330 + "line": 4399 }, { "lang": "bash", "meta": null, "sha256": "9a525f2d35624ea70a558a4081c20c98418fb371cce3ac2d70cb01462271f1c6", "bytes": 40, - "line": 4361 + "line": 4430 }, { "lang": "bash", "meta": null, "sha256": "5db8d4c149bb0b8378ac584947f1a8d037aa2375312e098338ee1566030988e4", "bytes": 51, - "line": 4387 + "line": 4456 }, { "lang": "bash", "meta": null, "sha256": "cba02b44f48f44f069fb55f48ec4e680a49b71ab99d8e7013ff4113236cd6e19", "bytes": 51, - "line": 4418 + "line": 4487 }, { "lang": "bash", "meta": null, "sha256": "1825feb4954c2f39280e98a8ab8f8784e097c9823d04cbccf616c28f24eeee4b", "bytes": 43, - "line": 4449 + "line": 4518 }, { "lang": "bash", "meta": null, "sha256": "0bd4be17187ea4870dfddcf74c03492e4ff3e4eb734d8e38fc710a0afd70e771", "bytes": 41, - "line": 4475 + "line": 4544 }, { "lang": "bash", "meta": null, "sha256": "e26b81a721ffd90b20b61be8168187fc20333274f6c3e537eb6cc44f87397f8b", "bytes": 40, - "line": 4499 + "line": 4568 }, { "lang": "bash", "meta": null, "sha256": "229448ff3c47ffdfdd720fb88b38edcf58f51d063f7b7836d4f4fdc478f99b5c", "bytes": 69, - "line": 4543 + "line": 4597 }, { "lang": "bash", "meta": null, "sha256": "7863a0cbd4e2cb3715358cca55047765a210f9a42a3dd171e42f574185fe0ab9", "bytes": 73, - "line": 4575 + "line": 4629 }, { "lang": "bash", "meta": null, "sha256": "a85bb64841a5626286099eb6175af5f8d0b0c8f19d71275c41ba144d5ce6d75b", "bytes": 32, - "line": 4528 + "line": 4662 }, { "lang": "bash", "meta": null, "sha256": "c8500badf466ac4cddb64017931e69aedba037eb5c44d0be411f3e2368254f87", "bytes": 38, - "line": 4551 + "line": 4685 }, { "lang": "bash", "meta": null, "sha256": "c5da7d1cca60bf076fb11e3a830b1c639ae02b8b2413e4956b12b54d4af7869d", "bytes": 43, - "line": 4583 + "line": 4717 }, { "lang": "bash", "meta": null, "sha256": "ee42a8df21f3cb29767908892e907b58edc31fccac8414e646690b238373feac", "bytes": 49, - "line": 4605 + "line": 4739 }, { "lang": "bash", "meta": null, "sha256": "3aa75d103d8d87811ca0f13f238ef5bfc84debf02826b8883eb0a09b372b7230", "bytes": 44, - "line": 4628 + "line": 4762 }, { "lang": "bash", "meta": null, "sha256": "c97bf15cbec26edcc86e8e3a908361b4c12a7d4b5578d5ea685d4fc68993c3f9", "bytes": 55, - "line": 4654 + "line": 4788 }, { "lang": "bash", "meta": null, "sha256": "971979fe121168021fc32881c5e8807f271cbaee608d70cf22b709662e0aae21", "bytes": 38, - "line": 4686 + "line": 4820 }, { "lang": "bash", "meta": null, "sha256": "76f320882518b53d2a6055f48c06b0deba00efa8baa1c1932ec6f1c598868a82", "bytes": 48, - "line": 4714 + "line": 4848 }, { "lang": "bash", "meta": null, "sha256": "1c902a61277e58e20d330fd4f7510a922639122f3e26126aeff5a404579d210d", "bytes": 45, - "line": 4745 + "line": 4879 }, { "lang": "bash", "meta": null, "sha256": "3e09d80993bd2231d73817167d0a3e7d022d9bb5fadbf1563a854531a3c22ced", "bytes": 36, - "line": 4775 + "line": 4909 }, { "lang": "bash", "meta": null, "sha256": "39050b42d7a1e9da84fac3fa817aa0635c47c28f32bceabcaf8811283ad7d7ce", "bytes": 48, - "line": 4799 + "line": 4933 }, { "lang": "bash", "meta": null, "sha256": "a4d3e32ee5d44b474b2fe040b4be5d32c0ad725d1b3c3e4342dc2ca08fa0a157", "bytes": 30, - "line": 4841 + "line": 4975 }, { "lang": "bash", "meta": null, "sha256": "37f58f218a36b8141bd5c32fb2774d731631774d332ce2fe9f7a3af158f122c1", "bytes": 42, - "line": 4867 + "line": 5001 }, { "lang": "bash", "meta": null, "sha256": "992f83d520658780ee956610f7965ecaad8d3c782b452d78d619edccd3afa26f", "bytes": 29, - "line": 4897 + "line": 5031 }, { "lang": "bash", "meta": null, "sha256": "594f0358c607649151209b234a12f44d7a125fa9150668fbf46ad475c36e52c9", "bytes": 28, - "line": 4917 + "line": 5051 }, { "lang": "bash", "meta": null, "sha256": "1f2babf217eecbcd3edee20e939434b8b177c454ae3edad1ed0a79b24bf0929f", "bytes": 42, - "line": 4940 + "line": 5074 }, { "lang": "bash", "meta": null, "sha256": "aa98bb7f2995f97bb02887ae3591ff0609686e634f9ad60068fbe12f38a4a026", "bytes": 35, - "line": 4984 + "line": 5118 }, { "lang": "bash", "meta": null, "sha256": "e421d326ab72354b8ec1d5711570d31868223c50da978b51961f9896acae5f35", "bytes": 26, - "line": 5015 + "line": 5149 }, { "lang": "bash", "meta": null, "sha256": "65c4b3a60ee39d43b253afd6818ed08dd93a5297f82b4648e4a3d5b0002731e4", "bytes": 32, - "line": 5039 + "line": 5173 }, { "lang": "bash", "meta": null, "sha256": "e2f60ffbb1bae7635a1e9ba79c228b19464a582144e1be50bfaba8d2c2db30c8", "bytes": 27, - "line": 5081 + "line": 5215 }, { "lang": "bash", "meta": null, "sha256": "afa0b971e60daad0dddc74b93f44fac1f51be91d47626ed4942b465fc6eba770", "bytes": 28, - "line": 5103 + "line": 5237 }, { "lang": "bash", "meta": null, "sha256": "4a0236d60519b55978f69d4eaa3ac6b8de2b354a7e63a14255abb64862d0456e", "bytes": 27, - "line": 5133 + "line": 5267 }, { "lang": "bash", "meta": null, "sha256": "ebd45772f099ce26c6315895068a1703d8e757f43daf35f6d3618c408319b37b", "bytes": 37, - "line": 5157 + "line": 5291 }, { "lang": "bash", "meta": null, "sha256": "e6e8ba88a5ede0c440346b124c101ccd53241c492f6dc28c3453fd23b8917cb3", "bytes": 24, - "line": 5187 + "line": 5321 }, { "lang": "bash", "meta": null, "sha256": "c19501e59583b680d6ce36b1133f3850ef1c96d05e7e5985b6cb8b1c5e19ff46", "bytes": 45, - "line": 5207 + "line": 5341 }, { "lang": "bash", "meta": null, "sha256": "4b84b742cd46fa81f9dc42d64c66c4b801b9fc6c381c1fda6690647d082639e3", "bytes": 43, - "line": 5237 + "line": 5371 }, { "lang": "bash", "meta": null, "sha256": "b9eefbf636684f9388fb43e708ad6dd2326b629f85a95e039f69e13651993c41", "bytes": 45, - "line": 5271 + "line": 5405 }, { "lang": "bash", "meta": null, "sha256": "bf2cc4f904e1d46c6ad5fb9c85a1a77b75062b61997add02c5c8d5b4cdcda262", "bytes": 25, - "line": 5302 + "line": 5436 }, { "lang": "bash", "meta": null, "sha256": "eef07048b814eb3b00f997c8b0907d1e21817abad072f7e8502087fdbe9a0188", "bytes": 37, - "line": 5325 + "line": 5459 }, { "lang": "bash", "meta": null, "sha256": "f35ce94c4d5abe5378ba2b6095a786d9989540caefe3b2248b43ec375b69d212", "bytes": 45, - "line": 5363 + "line": 5497 }, { "lang": "bash", "meta": null, "sha256": "f8a9f16a31241f7bed8f14a44cbc6ba4c13707b0bbc71f95abaded420f98cae8", "bytes": 45, - "line": 5395 + "line": 5529 }, { "lang": "bash", "meta": null, "sha256": "6eee9d069d9053a29e2bd61c5a85821fead20cc3af306f55e3597f9931588d32", "bytes": 45, - "line": 5426 + "line": 5560 }, { "lang": "bash", "meta": null, "sha256": "b0485a54f218265aaf8ef27b26af37e80603744b4cd13af804b9460ded6c769c", "bytes": 44, - "line": 5457 + "line": 5591 }, { "lang": "bash", "meta": null, "sha256": "eff43eafc0b5c9829de1e71e899e82b6db9bf5a3ba4da138fe2608ecf2ad96ea", "bytes": 44, - "line": 5492 + "line": 5626 }, { "lang": "bash", "meta": null, "sha256": "598ec0411dcfb12246c2ad819aeb9083cfd0dcf6a894aec2a0e908d82226e85f", "bytes": 32, - "line": 5523 + "line": 5657 }, { "lang": "bash", "meta": null, "sha256": "2869d4bb0b7552d346eca2e8bfe110dccdb3a13073c0e5a531ed34e8d111459f", "bytes": 44, - "line": 5543 + "line": 5677 }, { "lang": "bash", "meta": null, "sha256": "faa95847578604b50b3c020d50d94589351f7b1ec36e28cac7fe2140de6c4549", "bytes": 33, - "line": 5586 + "line": 5720 }, { "lang": "bash", "meta": null, "sha256": "25e24485a99181672176131b6ac2152347841a1a567430fbb98a553a929770f3", "bytes": 47, - "line": 5608 + "line": 5742 }, { "lang": "bash", "meta": null, "sha256": "9720d5ef881383f1c6da7a8a3a008f3afd615af99678d1bb81118511ff84dfcc", "bytes": 42, - "line": 5638 + "line": 5772 }, { "lang": "bash", "meta": null, "sha256": "3b9ac116c754c7e2bcf7f077f6061399dbfff4238408c4ef0d048fb442c3baf8", "bytes": 50, - "line": 5671 + "line": 5805 }, { "lang": "bash", "meta": null, "sha256": "3487041dfbdb03a03ebed0e308da4117442785d83c8158248fbad5df46f35fb2", "bytes": 48, - "line": 5704 + "line": 5838 }, { "lang": "bash", "meta": null, "sha256": "25f7e3715a24bd9657f90a5d9bc466cfe7b121a2660596c62c08a6ae0c4834f2", "bytes": 47, - "line": 5737 + "line": 5871 }, { "lang": "bash", "meta": null, "sha256": "8a310ebdd720a3d0e2fffb7db415873a53befe47ea4d638fca4ecd59ac27595e", "bytes": 40, - "line": 5769 + "line": 5903 }, { "lang": "bash", "meta": null, "sha256": "d3dba384fd5ce87e831b8454186e60d5e1cd6ae404b6324009adf4a961d85cfe", "bytes": 41, - "line": 5802 + "line": 5936 }, { "lang": "bash", "meta": null, "sha256": "4bec76dae83cf450410fd42df2a56ef2b20e69143f9631539a58f363631cb986", "bytes": 41, - "line": 5837 + "line": 5971 }, { "lang": "bash", "meta": null, "sha256": "ad373ebb46a54ef7fead24880468d70c4a415003db879817f5d78e717994573c", "bytes": 50, - "line": 5867 + "line": 6001 }, { "lang": "bash", "meta": null, "sha256": "9f85f14e7c0f00e23c82b9a097ab414df558c764e4171f8bdd6771386b02677c", "bytes": 42, - "line": 5889 + "line": 6023 }, { "lang": "bash", "meta": null, "sha256": "856a70bda3a296ed9e7d3de557191906106fd51da9fab3d2372bd4a3d0115ae1", "bytes": 48, - "line": 5923 + "line": 6057 }, { "lang": "bash", "meta": null, "sha256": "12b44782510d874df9900581914d66e8e8a0708ea6e1f8cab799c1187d4778f2", "bytes": 50, - "line": 5956 + "line": 6090 }, { "lang": "bash", "meta": null, "sha256": "908dcdcee6aa38f9ed48e63e5a70bba22dc7aaa603dfb8c62f0999259a1b9eb1", "bytes": 61, - "line": 5989 + "line": 6123 }, { "lang": "bash", "meta": null, "sha256": "55ea841db41ce49eb504636a54c29187e16b403b686059375abfa8aa51d01f7d", "bytes": 48, - "line": 6020 + "line": 6154 }, { "lang": "bash", "meta": null, "sha256": "c414854798bccb75c4b223fbed069ed445872158dbfb2b23093e140827d1e838", "bytes": 48, - "line": 6055 + "line": 6189 }, { "lang": "bash", "meta": null, "sha256": "9f45f6e6567f09f49ec40484721420a71a15d42f7ab92d3a3cfa1124ab8419fd", "bytes": 36, - "line": 6076 + "line": 6210 }, { "lang": "bash", "meta": null, "sha256": "862b4e873ebd70c303716275ebf30b3822c9c9e7f261a78e27b18f9585d798b0", "bytes": 46, - "line": 6096 + "line": 6230 }, { "lang": "bash", "meta": null, "sha256": "bb65d63f55930b4ea7b86e0475159b98a53e0525015508285c90425f28d53a55", "bytes": 45, - "line": 6117 + "line": 6251 }, { "lang": "bash", "meta": null, "sha256": "18d5a60f518896e595f00666ed45fdd624c789287809e18798e0d9f5610d48c8", "bytes": 43, - "line": 6147 + "line": 6281 }, { "lang": "bash", "meta": null, "sha256": "e44d003a6ba11690de0cc469320be892fae3b09033939a46724fccfb0aac4ab0", "bytes": 48, - "line": 6168 + "line": 6302 }, { "lang": "bash", "meta": null, "sha256": "bee16895751b8f386d2188e18ae402e521187d2bb7da9af709c2432ba9634209", "bytes": 46, - "line": 6191 + "line": 6325 }, { "lang": "bash", "meta": null, "sha256": "d3dede19db079b39e165a036fac3dd3376044ada7849f3ec70220ae188ac28e5", "bytes": 33, - "line": 6221 + "line": 6355 }, { "lang": "bash", "meta": null, "sha256": "0ac8381a83158432224d7b6bead7492d25f36e68eadc15c18e9bf84eddef77d0", "bytes": 42, - "line": 6243 + "line": 6377 }, { "lang": "bash", "meta": null, "sha256": "57308ec7b05d6b120ff6841edb51d8159d84444d6eaf36b3bccb9d1b2bb385e6", "bytes": 27, - "line": 6277 + "line": 6411 }, { "lang": "bash", "meta": null, "sha256": "90b5b274482044f6d036867177353233bf58d00f5dd345f76c0866a23be10343", "bytes": 32, - "line": 6300 + "line": 6434 }, { "lang": "bash", "meta": null, "sha256": "0636f27b84a87525a97146f65475905163efcae563920102110bdaef39d0cbe2", "bytes": 42, - "line": 6322 + "line": 6456 }, { "lang": "bash", "meta": null, "sha256": "36c08c95e7a531c73736416e90aea14eb199ad1746f90a4ecc78db9c037fd03e", "bytes": 33, - "line": 6353 + "line": 6487 }, { "lang": "bash", "meta": null, "sha256": "ab978cbdc23f7d851799251cfe01863187b2cd628b05c3c750ad1db2d26fb5fc", "bytes": 44, - "line": 6378 + "line": 6512 }, { "lang": "bash", "meta": null, "sha256": "2333fb674a47979dc56cf4e10c852516a7d179ddc234cc184130c6e82ae5811b", "bytes": 33, - "line": 6401 + "line": 6535 }, { "lang": "bash", "meta": null, "sha256": "d301cb24422359056dad68402e9f54a8ed15e21c0f183fb4c8eb937738f155dc", "bytes": 43, - "line": 6421 + "line": 6555 }, { "lang": "bash", "meta": null, "sha256": "bd3c423a63ecf70ee28e0c4aa2fe7b9e6078179ec2577ba80004cdfaf527843a", "bytes": 52, - "line": 6454 + "line": 6588 }, { "lang": "bash", "meta": null, "sha256": "a064ea603a971a2a3582988297d7293e6e0c6cabedfad714f2af97c0ff1884e7", "bytes": 39, - "line": 6485 + "line": 6619 }, { "lang": "bash", "meta": null, "sha256": "20d848044f1a1ac09fe6621d3e3f7fe7e2a6271c08e23f5eaea2c4c4b3f21c9d", "bytes": 31, - "line": 6506 + "line": 6640 }, { "lang": "bash", "meta": null, "sha256": "b19fe44255306f492be72ced4183894fe6ab47c5e87ab02cf909c5a6e89ab964", "bytes": 52, - "line": 6526 + "line": 6660 }, { "lang": "bash", "meta": null, "sha256": "0a8251333833585605881b98f9d121fa0e5669e58ba11cb583f69a333cea2ad6", "bytes": 37, - "line": 6563 + "line": 6697 }, { "lang": "bash", "meta": null, "sha256": "3f1de42768c4151cd364eb2534408179e93ca92dda918a493c9d05d4c8f03209", "bytes": 53, - "line": 6593 + "line": 6727 }, { "lang": "bash", "meta": null, "sha256": "60b57e472e506c0d6430f325c0e05c6cdfe9a62b4051a9f371929799ec7cfdef", "bytes": 24, - "line": 6615 + "line": 6749 }, { "lang": "bash", "meta": null, "sha256": "aeea8ffe933fcbb749950596d5c83192d1c42e2dac8f9532625c97afd7fe18a2", "bytes": 25, - "line": 6635 + "line": 6769 }, { "lang": "bash", "meta": null, "sha256": "3010a87af250eea55a7de4dcdda1fdcef515e73b7d2f5977d955f9d66ca4e130", "bytes": 25, - "line": 6660 + "line": 6794 }, { "lang": "bash", "meta": null, "sha256": "4cff190ec4b1d449d089493b45b00f986e3d17e563bca1816769ee3a1cfea2f2", "bytes": 28, - "line": 6682 + "line": 6816 }, { "lang": "bash", "meta": null, "sha256": "b7a3a8b8a114216dbdb506005496e5510feb227b0b6e39ba2b5145743e852577", "bytes": 37, - "line": 6702 + "line": 6836 }, { "lang": "bash", "meta": null, "sha256": "36e0677e842c003940707713b5334c6f2cabdfe6548790bc0c1b8546b0f820b6", "bytes": 32, - "line": 6734 + "line": 6868 }, { "lang": "bash", "meta": null, "sha256": "a9a07815e06f5bab4b2d1fc80a606227a8a617c105c56680c4bef68a520970c1", "bytes": 37, - "line": 6755 + "line": 6889 }, { "lang": "bash", "meta": null, "sha256": "79420742f040a499740d4b465a8e8fdbc628c275fbd2880182637c5917b67b9e", "bytes": 34, - "line": 6792 + "line": 6926 }, { "lang": "bash", "meta": null, "sha256": "a6b4fb5e2728894616f9d11df544879c4786b80458d5905a67ffc2224022ec1e", "bytes": 30, - "line": 6831 + "line": 6965 }, { "lang": "bash", "meta": null, "sha256": "1aa431dc5b8d1fdc3d54a77c56f15a1b9ac5896e343818d3d0ad2edbabc541db", "bytes": 23, - "line": 6862 + "line": 6996 }, { "lang": "bash", "meta": null, "sha256": "262692daffbb55d5cbbf1717fb2c8ff08bc009514bae8ff83ee756d4caba08d1", "bytes": 36, - "line": 6888 + "line": 7022 }, { "lang": "bash", "meta": null, "sha256": "1e9b19a7ea2fdbf0902ed74b48cecc710d7fa2272d4dbed9172af1ae0258310d", "bytes": 22, - "line": 6920 + "line": 7054 }, { "lang": "bash", "meta": null, "sha256": "734b96827127375d652bc125b1c9a243946cf5f8c251b43a7097656fcc2ce544", "bytes": 24, - "line": 6940 + "line": 7074 }, { "lang": "bash", "meta": null, "sha256": "c6544463a5be88fa1bb81e80633a6e4bf70e99d424d408c75930046e74c1dc5f", "bytes": 41, - "line": 6971 + "line": 7105 }, { "lang": "bash", "meta": null, "sha256": "2ba0e68b06d7be939be3058e281cd495a16a41611ae4dadb91c97dea4b95fe29", "bytes": 52, - "line": 6997 + "line": 7131 }, { "lang": "bash", "meta": null, "sha256": "f0f241b97b09236a5fcdc9527cff2f76cf382270caf4315aa8ae11f8ca919932", "bytes": 58, - "line": 7027 + "line": 7161 }, { "lang": "bash", "meta": null, "sha256": "fde5893586a0c2c7cbd2ab3976d7d9ef35e861b56eab07191919891e09528bd4", "bytes": 46, - "line": 7060 + "line": 7194 }, { "lang": "bash", "meta": null, "sha256": "3d28258fd09b2c94b7cb24cb37035ce71d3777b58b2270998781761f8957081e", "bytes": 71, - "line": 7082 + "line": 7216 }, { "lang": "bash", "meta": null, "sha256": "45cdb94cf834d1950ab049a6a65c398b4917312897fdcaa3f25fa0081e707912", "bytes": 39, - "line": 7113 + "line": 7247 }, { "lang": "bash", "meta": null, "sha256": "ac7f120b7e8c39cb3de7b0936dfd2f6d8f38ece480b0523518883cef7b5b643c", "bytes": 39, - "line": 7133 + "line": 7267 }, { "lang": "bash", "meta": null, "sha256": "7994ad60b43332fb5bc583bf1ea5599fa2f659cd8ad811105837cf9e87fa28ed", "bytes": 52, - "line": 7155 + "line": 7289 }, { "lang": "bash", "meta": null, "sha256": "5ed096b4dcf0efad18c54d322d528b7bb0e80336d061ee7eaa5d39d3bd505a4d", "bytes": 62, - "line": 7196 + "line": 7330 }, { "lang": "bash", "meta": null, "sha256": "daf3ccbc5a308bc4c62d1a761e01fac3d40ff67cc256c4c9624d0cd00a0072c4", "bytes": 53, - "line": 7229 + "line": 7363 }, { "lang": "bash", "meta": null, "sha256": "48ecf1013a285c0de3e23936d53a850d85af0564a7e089de2eba1ac7f8881a44", "bytes": 53, - "line": 7268 + "line": 7402 }, { "lang": "bash", "meta": null, "sha256": "653a412a8eeaffac8afbbbb2533375f02df67fe7ef882ca53fc7ca2755d0bcec", "bytes": 43, - "line": 7301 + "line": 7435 }, { "lang": "bash", "meta": null, "sha256": "bfb90a10455a59ef26068d176221816b9c374c4001d386ffdbb0140c4cfe40ca", "bytes": 40, - "line": 7331 + "line": 7465 }, { "lang": "bash", "meta": null, "sha256": "d683e8c6e5a1d063ea9f6c74fd9020d48aede0d5af7031f6f48bd5a173ad2d3d", "bytes": 33, - "line": 7366 + "line": 7500 }, { "lang": "bash", "meta": null, "sha256": "96abc89ee23cb4a7e2fc3caca2afbd2dd57d2734ca9e73816c412a71c5c5eb9d", "bytes": 43, - "line": 7393 + "line": 7527 }, { "lang": "bash", "meta": null, "sha256": "d63bf37625acc32ec1ee6e59a56f57469b9cb4dab03e23067a54d2f7d8abd3a3", "bytes": 41, - "line": 7425 + "line": 7559 }, { "lang": "bash", "meta": null, "sha256": "eb79c561c688b305a39f834bb39d74f65095db957e337d8e6081bbc00545bb08", "bytes": 53, - "line": 7458 + "line": 7592 }, { "lang": "bash", "meta": null, "sha256": "f30977b7b952e6a369b72b828256fac3f9ede78ce6e7c45560a8d09989bd4db5", "bytes": 49, - "line": 7489 + "line": 7623 }, { "lang": "bash", "meta": null, "sha256": "7d00d2a238c8794c5479e28ef14e6e104bcaff1aaaef70a094a2a51c55b6420c", "bytes": 50, - "line": 7510 + "line": 7644 }, { "lang": "bash", "meta": null, "sha256": "f34921d79bc4b608cb3e3c3f882729808afe1d8cf95ca45c63cbdf00ab494a3e", "bytes": 62, - "line": 7541 + "line": 7675 }, { "lang": "bash", "meta": null, "sha256": "ebfb60766394d803a01326b040ac37c5f122134555f1b968aeb6a79b10d74fc7", "bytes": 30, - "line": 7573 + "line": 7707 }, { "lang": "bash", "meta": null, "sha256": "432348582be57741628ca2b6a762c385ed57bbbfd4e4794b914409b7a89db582", "bytes": 35, - "line": 7595 + "line": 7729 }, { "lang": "bash", "meta": null, "sha256": "25d75743717b8e1c96f6b78e334e3e3cbc6dac5e395068308a4e358e06a0bac6", "bytes": 45, - "line": 7617 + "line": 7751 }, { "lang": "bash", "meta": null, "sha256": "2fc4fd97c2a62f635b42d646e481b18b0402ec0d051e7532988c026d838f0534", "bytes": 36, - "line": 7648 + "line": 7782 }, { "lang": "bash", "meta": null, "sha256": "3da31453cf86b97920f8d013f00122dee830af847c064e30ed3e158bd8f642cb", "bytes": 47, - "line": 7673 + "line": 7807 }, { "lang": "bash", "meta": null, "sha256": "d4de05e9142f532574133568e3fe2965c7fd82338e5b54ba0d8ae5713639eaca", "bytes": 43, - "line": 7698 + "line": 7832 }, { "lang": "bash", "meta": null, "sha256": "e63db5a4bab57e3229127bb086d0c5429dfe20de83eaf5097e8943f90520b9b3", "bytes": 51, - "line": 7730 + "line": 7864 }, { "lang": "bash", "meta": null, "sha256": "b130d4ed01c735074275b1505e7959e6dd3ae89c6b7ac159d17ccd495bec8f38", "bytes": 38, - "line": 7760 + "line": 7894 }, { "lang": "bash", "meta": null, "sha256": "32709306f0caabb4378fce9efba6b9c60a3985bb3500402d9a7f9e89fe35e00e", "bytes": 49, - "line": 7784 + "line": 7918 }, { "lang": "bash", "meta": null, "sha256": "a09f283134986b066a49a41c5ef250ff1502ae2adbc3316baacf92712112cc2d", "bytes": 43, - "line": 7826 + "line": 7960 }, { "lang": "bash", "meta": null, "sha256": "d941e4f91a379820ba5d640e7ceffd42c6fdd70b5b19da9c508c259b40ce14f7", "bytes": 53, - "line": 7857 + "line": 7991 }, { "lang": "bash", "meta": null, "sha256": "fb10d8958f9f1b03ab76713459abf552755dae3334103a451c387c9d1c7b3605", "bytes": 40, - "line": 7888 + "line": 8022 }, { "lang": "bash", "meta": null, "sha256": "b5dc1e771889fb1bf5f4d81d340cfd35d3a2263c87a725341d1d5c70405eb78d", "bytes": 43, - "line": 7937 + "line": 8071 }, { "lang": "bash", "meta": null, "sha256": "736889b5b5a59bc4fe7965922999b5cbec295acad8313c1e486d5668c7da961e", "bytes": 30, - "line": 7969 + "line": 8103 }, { "lang": "bash", "meta": null, "sha256": "4d6c0bc0130fd4a83c7b2fcac6f5b4b7bc2f275c05fa6101551ac61ce1405aca", "bytes": 44, - "line": 7989 + "line": 8123 }, { "lang": "bash", "meta": null, "sha256": "62d28b9be74c32c4ba00fefe878c492a6a5c671cc4a500373d7f2bdba9f7ef79", "bytes": 54, - "line": 8009 + "line": 8143 }, { "lang": "bash", "meta": null, "sha256": "7115696baaba8ca1ecd8f7cb22f215b7a53bf13d5757bd6932267644a0377807", "bytes": 36, - "line": 8039 + "line": 8173 }, { "lang": "bash", "meta": null, "sha256": "10330e610f59c2fb169fcff11cb8147563efbcc109f875222ecbfaaa48c05f31", "bytes": 50, - "line": 8061 + "line": 8195 }, { "lang": "bash", "meta": null, "sha256": "e88ab81833a4d529271c80e2cdf0e7ff5037a60066a918a9702a28ceca73a846", "bytes": 30, - "line": 8098 + "line": 8232 }, { "lang": "bash", "meta": null, "sha256": "f094df6732656b52792e5e766a69079c3e8bd929326bda7284e0bcb760bedd79", "bytes": 28, - "line": 8128 + "line": 8262 }, { "lang": "bash", "meta": null, "sha256": "7579bd6ac197afa0afa5f62d9b412a7e265725bd507c1c63b438a60fd72ce1d0", "bytes": 40, - "line": 8235 + "line": 8289 }, { "lang": "bash", "meta": null, "sha256": "63aac11606dc0eeb6cc452c20c405806840960425372cf2cf7c8b4b90a7de878", "bytes": 62, - "line": 8255 + "line": 8309 }, { "lang": "bash", "meta": null, "sha256": "52be4758f4289e9f75875d8401cf57fda54283b2df00e413921d07303e728638", "bytes": 28, - "line": 8155 + "line": 8340 }, { "lang": "bash", "meta": null, "sha256": "4c51312bac2ca9ea2dba0e7cf27f06d5e99502bab35ab569064057a85514cc38", "bytes": 38, - "line": 8176 + "line": 8361 }, { "lang": "bash", "meta": null, "sha256": "2b26aa6f404ab19bda2d846871eb04b732f6397d1899fc28d89a29706fc0a670", "bytes": 34, - "line": 8202 + "line": 8387 }, { "lang": "bash", "meta": null, "sha256": "47af2a240e0fa4bca92d786d1fea2d4b6cd7ee8810c4540b619271457739f829", "bytes": 60, - "line": 8224 + "line": 8409 }, { "lang": "bash", "meta": null, "sha256": "f4a5d9b54707e03cc845a3ffc99493f9990d7c0a0aa96a48a492b303ef1959b4", "bytes": 45, - "line": 8257 + "line": 8442 }, { "lang": "bash", "meta": null, "sha256": "4a4ff033e75c32373422638e4f8079487d9cc37fa2b250cc2907584e690e2699", "bytes": 45, - "line": 8290 + "line": 8475 }, { "lang": "bash", "meta": null, "sha256": "66d1f7b4fc772fcb1ac84bcba2c1aa0e1a26105dda64b564c40dec35fc5fdaf2", "bytes": 43, - "line": 8320 + "line": 8505 }, { "lang": "bash", "meta": null, "sha256": "5fc8d60c19e8e3fc1c640ea87e091d0b95642a210a7e1a7d9d19e527b6f11207", "bytes": 38, - "line": 8352 + "line": 8537 }, { "lang": "bash", "meta": null, "sha256": "a7acb92552eae9b29dd7b973751bb7f3c2f7eb1dc00f36e84c594cb88dd9fc1c", "bytes": 33, - "line": 8373 + "line": 8558 }, { "lang": "bash", "meta": null, "sha256": "bb84857c237d2bfae0c6f63653597fe55263147428a5e8d36ceacfcda6bd6246", "bytes": 26, - "line": 8404 + "line": 8589 }, { "lang": "bash", "meta": null, "sha256": "3e634421662d08fe73882dd6a0d428545c33597790333f83b4c5086c8d494a1e", "bytes": 49, - "line": 8430 + "line": 8615 }, { "lang": "bash", "meta": null, "sha256": "c48dfec9df0cebb16aa976f859770a8b5beb67e2cf01dc5472dd8b3727ea164d", "bytes": 47, - "line": 8450 + "line": 8635 }, { "lang": "bash", "meta": null, "sha256": "8fd3a890425cf05e630bcd969b2aff57b3993d6632d905cab5b23cfce60e425c", "bytes": 49, - "line": 8471 + "line": 8656 }, { "lang": "bash", "meta": null, "sha256": "bd32d826b077176044eabbf175d64a747ba1a826bb36c95efb46925525f75ba9", "bytes": 45, - "line": 8497 + "line": 8682 }, { "lang": "bash", "meta": null, "sha256": "ee94be8e7fcb3c1bdb0abd6991992563371ba113b80b433cce0c71ac499f1d48", "bytes": 43, - "line": 8521 + "line": 8706 }, { "lang": "bash", "meta": null, "sha256": "bbaecaea783d60d4e8cbaad1c6951d2aa5fde5772f050166a24415162d9de3bc", "bytes": 29, - "line": 8543 + "line": 8728 }, { "lang": "bash", "meta": null, "sha256": "627ab83bbee7d7a608e8771c98556a00cb9df200a40fe9b68d79f93c322de59d", "bytes": 18, - "line": 8568 + "line": 8753 }, { "lang": "bash", "meta": null, "sha256": "9396c5d2aba2c0ff0d484a0a778dc38368154eb19edca9372cf576f8abedac90", "bytes": 32, - "line": 8578 + "line": 8763 }, { "lang": "bash", "meta": null, "sha256": "456a3638df3f1208cc5620fe2f7744e60215930e90cdada8c6d3f3041844b81c", "bytes": 22, - "line": 8588 + "line": 8773 }, { "lang": "bash", "meta": null, "sha256": "80c6f38f04b6c653c8de67e79ba7b6ca1a5ec208e8765a036048fd0adc4b4522", "bytes": 37, - "line": 8598 + "line": 8783 }, { "lang": "bash", "meta": null, "sha256": "3be0a5bf2f996779b39f57c35c08a046709ad91ffbb1ecd700c2d0f8a1c9b554", "bytes": 40, - "line": 8623 + "line": 8808 }, { "lang": "bash", "meta": null, "sha256": "94cdaecf523029bf20f25cf278f76142041e4ad9ce6980a0de225f6dd0b93a8f", "bytes": 43, - "line": 8645 + "line": 8830 }, { "lang": "bash", "meta": null, "sha256": "1731734821194e9c9ab5d0e866031dff102eab3c67b81c83780dce706461c802", "bytes": 40, - "line": 8665 + "line": 8850 }, { "lang": "bash", "meta": null, "sha256": "466187fc1a5788ab09d7700cf7cd19c9c541a571d0bc86d70fe52a1eb831b851", "bytes": 54, - "line": 8687 + "line": 8872 }, { "lang": "bash", "meta": null, "sha256": "e10bc7e084ad3b9231262208e0ac216aee47dc0ec7b1507214690ea9c098d5c9", "bytes": 32, - "line": 8719 + "line": 8904 }, { "lang": "bash", "meta": null, "sha256": "6bb0cf09f6bd8ba02e1f24a5eed89b35c0226ec7eefce07502015a455520fd0f", "bytes": 33, - "line": 8729 + "line": 8914 }, { "lang": "bash", "meta": null, "sha256": "23acce8016e243ab651bcc860cd8f1b1d236e6a5b7951abc76282cd674b2a25e", "bytes": 39, - "line": 8752 + "line": 8937 }, { "lang": "bash", "meta": null, "sha256": "210e8ab3d8af173ce648501ca50c828325ac1f4452006ad4485cb7a24d760ae4", "bytes": 37, - "line": 8852 + "line": 8961 }, { "lang": "bash", "meta": null, "sha256": "cdff2d4272d5bc3a18729e63e5ece9053d33ac0f8faf35cfaa3a289baa2dccc1", "bytes": 48, - "line": 8874 + "line": 8983 }, { "lang": "bash", "meta": null, "sha256": "c307203dd609208b90cbc0372e3401a75e943421bdf77e4e10779ed2c89ca4ff", "bytes": 48, - "line": 8904 + "line": 9013 }, { "lang": "bash", "meta": null, "sha256": "f6340c3d5dc4eedb98435df593ff277d0e44fbcb4c61e71a05123267b5352e78", "bytes": 29, - "line": 8796 + "line": 9043 }, { "lang": "bash", "meta": null, "sha256": "ca6e405dc70365db67ec66e6c2499fedf89ae3d7a90f4b5d044a2aab13bb8f37", "bytes": 37, - "line": 8818 + "line": 9065 }, { "lang": "bash", "meta": null, "sha256": "52918df5b4d4003d22322d5785d8a0202a2b66d39a3c71171f92a65545e28788", "bytes": 37, - "line": 8839 + "line": 9086 }, { "lang": "bash", "meta": null, "sha256": "28e77f2a35930390cc7de2fc5abdce7809311c33b10eb749941d5c44b9de0cfa", "bytes": 37, - "line": 8864 + "line": 9111 }, { "lang": "bash", "meta": null, "sha256": "088f9018d5639eac73c710bf1ef4fd134b0cd4c290587a161af1322b415a013f", "bytes": 29, - "line": 8891 + "line": 9138 }, { "lang": "bash", "meta": null, "sha256": "67384a12d7642ea8a82e807a58e42f921ea37cb496e2315ac87368ae0e297e4d", "bytes": 29, - "line": 8923 + "line": 9170 }, { "lang": "bash", "meta": null, "sha256": "4c9e46c0b1c48cb348bfb37e4c705e4dfe1cd5f221d667ebd02dd7325fce7e29", "bytes": 38, - "line": 8955 + "line": 9202 }, { "lang": "bash", "meta": null, "sha256": "20fbb381c0b4ef786f755f485789ecb9e76538ec6394956bb6153ee4a79547ca", "bytes": 31, - "line": 8978 + "line": 9225 }, { "lang": "bash", "meta": null, "sha256": "54ffdfd98a1fc3aad38e602c1d02336a81fe97d48e6732402a7980d0f55cb20d", "bytes": 40, - "line": 8999 + "line": 9246 } ], "links": [ diff --git a/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts b/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts index ea38360e830..ab46828a7a0 100644 --- a/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts +++ b/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts @@ -22,29 +22,20 @@ vi.mock('@/lib/credentials/application/service-account', () => ({ }, })) -import { CredentialProviderOperationError } from '@/lib/credentials/application/crud' +import { + CredentialProviderOperationError, + throwCredentialMutationFailure, +} from '@/lib/credentials/application/crud' +import { WorkloadIdentitySubjectError } from '@/lib/credentials/workload-identity/subject' import { PATCH } from '@/app/api/v2/credentials/[credentialId]/route' -vi.mock('@/lib/credentials/application/crud', async () => { - const { OrchestrationError: BaseError } = await import('@/lib/core/orchestration/types') - class MockCredentialProviderOperationError extends BaseError { - constructor( - message: string, - readonly providerErrorCode: string, - readonly providerUnavailable: boolean - ) { - super('validation', message) - this.name = 'CredentialProviderOperationError' - } - } - return { - CredentialProviderOperationError: MockCredentialProviderOperationError, - updateWorkspaceCredentialUseCase: { - operation: { id: 'credentials.update' }, - execute: mocks.update, - }, - } -}) +vi.mock('@/lib/credentials/application/crud', async (importOriginal) => ({ + ...(await importOriginal()), + updateWorkspaceCredentialUseCase: { + operation: { id: 'credentials.update' }, + execute: mocks.update, + }, +})) const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' const CREDENTIAL_ID = '7c9e6679-7425-40de-944b-e07fc1f90ae7' @@ -201,4 +192,29 @@ describe('PATCH /api/v2/credentials/[credentialId]', () => { }, }) }) + + it('reports a reconnect whose subject another credential holds with the create code', async () => { + mocks.update.mockImplementation(() => + throwCredentialMutationFailure({ + success: false, + error: 'Another credential already uses this trust setup.', + errorCode: 'conflict', + keylessRefusal: new WorkloadIdentitySubjectError( + 'Another credential already uses this trust setup.', + 'WORKLOAD_IDENTITY_SUBJECT_IN_USE' + ), + }) + ) + + const response = await PATCH(patchRequest({ serviceAccountJson: '{}' }), context) + + expect(response.status).toBe(409) + await expect(response.json()).resolves.toEqual({ + error: { + code: 'CONFLICT', + message: 'Another credential already uses this trust setup.', + details: { code: 'WORKLOAD_IDENTITY_SUBJECT_IN_USE' }, + }, + }) + }) }) diff --git a/apps/sim/app/api/v2/credentials/[credentialId]/route.ts b/apps/sim/app/api/v2/credentials/[credentialId]/route.ts index aa0d7c2eb97..e6a939ac71c 100644 --- a/apps/sim/app/api/v2/credentials/[credentialId]/route.ts +++ b/apps/sim/app/api/v2/credentials/[credentialId]/route.ts @@ -8,43 +8,19 @@ import { v2ApiKeyAuth, v2RateLimits, } from '@/lib/api/server/routes' -import { - CredentialProviderOperationError, - updateWorkspaceCredentialUseCase, -} from '@/lib/credentials/application/crud' +import { renderV2CredentialProviderError } from '@/lib/credentials/api/route-policies' +import { updateWorkspaceCredentialUseCase } from '@/lib/credentials/application/crud' import { credentialOperations } from '@/lib/credentials/application/operations' import { toV2Credential } from '@/lib/credentials/application/presentation' import { deleteCredentialUseCase } from '@/lib/credentials/application/service-account' -import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' +import { v2CaughtOrchestrationError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * Separates a provider rejecting the submitted secret from the provider being - * unreachable while it is verified. - * - * `CredentialProviderOperationError` extends `OrchestrationError('validation')`, - * so the default projection renders both as `400`. That tells a caller whose - * provider is merely down that its secret material is permanently wrong, which - * invites it to revoke a credential that is fine. `503` says the opposite, and - * `v2Error` attaches `Retry-After` from its status table. The outage message is - * deliberately generic — the underlying value is a provider transport failure, - * not anything the caller submitted — while a genuine rejection keeps the - * provider's own code in `details.providerErrorCode` so a client can map it. - */ -function renderCredentialProviderError(error: unknown) { - if (!(error instanceof CredentialProviderOperationError)) return null - return error.providerUnavailable - ? v2Error('SERVICE_UNAVAILABLE', 'Credential provider is temporarily unavailable') - : v2Error('BAD_REQUEST', error.message, { - details: { providerErrorCode: error.providerErrorCode }, - }) -} - const credentialErrorPolicy = createV2ResourceConcealmentPolicy({ notFoundMessage: 'Credential not found', - render: (error) => renderCredentialProviderError(error) ?? v2CaughtOrchestrationError(error), + render: (error) => renderV2CredentialProviderError(error) ?? v2CaughtOrchestrationError(error), }) /** diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index 56abc1b16f1..3e3c3ec86b7 100644 --- a/apps/sim/app/api/v2/credentials/route.test.ts +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -31,6 +31,7 @@ vi.mock('@/lib/credentials/application/service-account', () => ({ })) import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' +import { KeylessCredentialRefusedError } from '@/lib/credentials/workload-identity/subject' import { GET, POST } from '@/app/api/v2/credentials/route' const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' @@ -421,4 +422,59 @@ describe('POST /api/v2/credentials', () => { }) expect(mocks.create).not.toHaveBeenCalled() }) + function createKeyless(providerId: string, serviceAccountJson: object) { + return POST( + new NextRequest('http://localhost:3000/api/v2/credentials', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId, + credentials: JSON.stringify({ serviceAccountJson: JSON.stringify(serviceAccountJson) }), + }), + }) + ) + } + + it('accepts a keyless Snowflake configuration in place of the token fields', async () => { + const response = await createKeyless('snowflake-service-account', { + authType: 'workload_identity', + host: 'myorg-myaccount.snowflakecomputing.com', + subject: 'sim:workspace:ws:credential:abcdef0123456789', + subjectProof: 'proof', + }) + + expect(response.status).toBe(201) + expect(mocks.create).toHaveBeenCalledOnce() + }) + + it.each([ + ['WORKLOAD_IDENTITY_NOT_CONFIGURED', 400, 'BAD_REQUEST'], + ['WORKLOAD_IDENTITY_PROOF_EXPIRED', 400, 'BAD_REQUEST'], + ['WORKLOAD_IDENTITY_PROOF_INVALID', 400, 'BAD_REQUEST'], + ['WORKLOAD_IDENTITY_SUBJECT_IN_USE', 409, 'CONFLICT'], + ['WORKLOAD_IDENTITY_SUBJECT_UNVERIFIABLE', 409, 'CONFLICT'], + ] as const)( + 'names a refused keyless credential %s in details.code', + async (detailCode, status, code) => { + mocks.create.mockRejectedValue( + new KeylessCredentialRefusedError('Start the keyless connection again.', detailCode) + ) + + const response = await createKeyless('aws-service-account', { + authType: 'oidc_role', + roleArn: 'arn:aws:iam::123456789012:role/Sim', + subject: 'sim:workspace:ws:credential:abcdef0123456789', + subjectProof: 'proof', + }) + + expect(response.status).toBe(status) + expect((await response.json()).error).toEqual({ + code, + message: 'Start the keyless connection again.', + details: { code: detailCode }, + }) + } + ) }) diff --git a/apps/sim/app/api/v2/credentials/route.ts b/apps/sim/app/api/v2/credentials/route.ts index 3f925f6db9e..7caa16ea94f 100644 --- a/apps/sim/app/api/v2/credentials/route.ts +++ b/apps/sim/app/api/v2/credentials/route.ts @@ -9,17 +9,23 @@ import { v2ApiKeyAuth, v2RateLimits, } from '@/lib/api/server/routes' +import { renderV2CredentialProviderError } from '@/lib/credentials/api/route-policies' import { listWorkspaceCredentials } from '@/lib/credentials/application/list-workspace-credentials' import { credentialOperations } from '@/lib/credentials/application/operations' import { toV2Credential } from '@/lib/credentials/application/presentation' import { createServiceAccountCredentialUseCase } from '@/lib/credentials/application/service-account' -import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' +import { + readSortedCursor, + v2CaughtOrchestrationError, + writeSortedCursor, +} from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 const credentialWorkspaceErrorPolicy = createV2ResourceConcealmentPolicy({ notFoundMessage: 'Workspace not found', + render: (error) => renderV2CredentialProviderError(error) ?? v2CaughtOrchestrationError(error), }) /** Every param that changes which credentials, in which order, this list returns. */ 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 new file mode 100644 index 00000000000..00f6c61d333 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/workload-identity/subjects/route.test.ts @@ -0,0 +1,216 @@ +/** + * @vitest-environment node + */ +import { generateKeyPairSync } from 'node:crypto' +import type { Principal } from '@sim/auth/principal' +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { + createPersonalApiKeyPrincipal, + createWorkspaceApiKeyPrincipal, +} from '@sim/testing/factories/principal.factory' +import { workspaceAuthzMock, workspaceAuthzMockFns } from '@sim/testing/mocks/workspace-authz.mock' +import { + workspaceContextMock, + workspaceContextMockFns, +} from '@sim/testing/mocks/workspace-context.mock' +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/lib/workspaces/application/context', () => workspaceContextMock) +vi.mock('@sim/platform-authz/workspace', () => workspaceAuthzMock) + +import { env } from '@/lib/core/config/env' +import { verifyWorkloadIdentitySubject } from '@/lib/credentials/workload-identity/subject' +import { POST } from '@/app/api/v2/credentials/workload-identity/subjects/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const SIGNING_KEY = generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ + type: 'pkcs8', + format: 'pem', +}) + +function authenticateAs(principal: Principal) { + v2RouteMocks.authenticate.mockResolvedValue({ + principal, + rateLimitSubjectIds: ['subject-1'], + rateLimitSubscription: null, + keyType: principal.kind === 'workspace_api_key' ? 'workspace' : 'personal', + }) +} + +function issue(body: Record) { + return POST( + new NextRequest('http://localhost:3000/api/v2/credentials/workload-identity/subjects', { + method: 'POST', + body: JSON.stringify(body), + headers: { 'content-type': 'application/json', 'x-api-key': 'sk-sim-test' }, + }) + ) +} + +describe('POST /api/v2/credentials/workload-identity/subjects', () => { + beforeEach(() => { + Reflect.set(env, 'WORKLOAD_IDENTITY_SIGNING_KEY', SIGNING_KEY) + Reflect.set(env, 'WORKLOAD_IDENTITY_ISSUER', 'https://sim.example.com/oidc/workload') + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + workspaceContextMockFns.mockLoadActiveWorkspaceApplicationContext.mockResolvedValue({ + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'user-1', + }) + workspaceAuthzMockFns.mockResolveEffectiveWorkspacePermission.mockResolvedValue('write') + authenticateAs(createPersonalApiKeyPrincipal({ userId: 'user-1' })) + }) + + afterEach(() => { + Reflect.set(env, 'WORKLOAD_IDENTITY_SIGNING_KEY', undefined) + Reflect.set(env, 'WORKLOAD_IDENTITY_ISSUER', undefined) + }) + + it('issues a personal key a proof only that key’s user can create the credential with', async () => { + const response = await issue({ workspaceId: WORKSPACE_ID, providerId: 'aws-service-account' }) + + expect(response.status).toBe(200) + const { data } = await response.json() + expect(data).toEqual({ + issuer: 'https://sim.example.com/oidc/workload', + audience: 'sts.amazonaws.com', + subject: expect.stringMatching(`^sim:workspace:${WORKSPACE_ID}:credential:`), + subjectProof: expect.any(String), + expiresAt: expect.any(String), + keylessConfig: { + authType: 'oidc_role', + subject: data.subject, + subjectProof: data.subjectProof, + }, + requiredConfigFields: ['roleArn'], + optionalConfigFields: ['stsRegion', 'sessionDurationSeconds'], + }) + + const owner = { workspaceId: WORKSPACE_ID } + expect(() => + verifyWorkloadIdentitySubject('aws', owner, 'user-1', data.subject, data.subjectProof) + ).not.toThrow() + expect(() => + verifyWorkloadIdentitySubject('aws', owner, 'user-2', data.subject, data.subjectProof) + ).toThrow(expect.objectContaining({ detailCode: 'WORKLOAD_IDENTITY_PROOF_INVALID' })) + expect(() => + verifyWorkloadIdentitySubject('databricks', owner, 'user-1', data.subject, data.subjectProof) + ).toThrow(expect.objectContaining({ detailCode: 'WORKLOAD_IDENTITY_PROOF_INVALID' })) + }) + + it('leaves the audience out for Google Cloud, whose audience is per credential', async () => { + const response = await issue({ + workspaceId: WORKSPACE_ID, + providerId: 'google-service-account', + }) + + const { data } = await response.json() + expect(data).not.toHaveProperty('audience') + expect(data.requiredConfigFields).toEqual([ + 'projectNumber', + 'poolId', + 'providerId', + 'serviceAccountEmail', + ]) + }) + + it('refuses a workspace API key, whose subject would belong to no person', async () => { + authenticateAs(createWorkspaceApiKeyPrincipal({ workspaceId: WORKSPACE_ID })) + + const response = await issue({ workspaceId: WORKSPACE_ID, providerId: 'aws-service-account' }) + + expect(response.status).toBe(403) + expect((await response.json()).error).toMatchObject({ + code: 'FORBIDDEN', + details: { code: 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED' }, + }) + }) + + it('refuses an OAuth token without the api:write scope', async () => { + authenticateAs({ + kind: 'oauth_access_token', + userId: 'user-1', + clientId: 'client-1', + tokenId: 'token-1', + scopes: ['api:read'], + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + }) + + const response = await issue({ workspaceId: WORKSPACE_ID, providerId: 'aws-service-account' }) + + expect(response.status).toBe(403) + expect((await response.json()).error).toMatchObject({ + code: 'FORBIDDEN', + details: { code: 'INSUFFICIENT_SCOPE' }, + }) + }) + + it('rejects a provider with no keyless mode before issuing anything', async () => { + const response = await issue({ workspaceId: WORKSPACE_ID, providerId: 'zoom-service-account' }) + + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + expect(workspaceContextMockFns.mockLoadActiveWorkspaceApplicationContext).not.toHaveBeenCalled() + }) + + it('names the missing deployment support with a stable code', async () => { + Reflect.set(env, 'WORKLOAD_IDENTITY_SIGNING_KEY', undefined) + + const response = await issue({ workspaceId: WORKSPACE_ID, providerId: 'aws-service-account' }) + + expect(response.status).toBe(400) + expect((await response.json()).error).toMatchObject({ + code: 'BAD_REQUEST', + message: + 'Keyless connections are not available on this deployment. Use a key- or token-based credential.', + details: { code: 'WORKLOAD_IDENTITY_NOT_CONFIGURED' }, + }) + }) + + it.each([ + [ + 'the caller cannot reach', + () => workspaceAuthzMockFns.mockResolveEffectiveWorkspacePermission.mockResolvedValue(null), + ], + [ + 'does not exist', + () => + workspaceContextMockFns.mockLoadActiveWorkspaceApplicationContext.mockResolvedValue(null), + ], + ])('answers a workspace that %s as not found', async (_label, arrange) => { + arrange() + + const response = await issue({ workspaceId: WORKSPACE_ID, providerId: 'aws-service-account' }) + + expect(response.status).toBe(404) + expect((await response.json()).error).toEqual({ + code: 'NOT_FOUND', + message: 'Workspace not found', + }) + }) + + it('refuses a caller over that bucket before issuing a subject', async () => { + v2RouteMocks.operationRate.mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: new Date(Date.now() + 30_000), + }) + + const response = await issue({ workspaceId: WORKSPACE_ID, providerId: 'aws-service-account' }) + + expect(response.status).toBe(429) + expect((await response.json()).error.code).toBe('RATE_LIMITED') + expect(workspaceContextMockFns.mockLoadActiveWorkspaceApplicationContext).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/credentials/workload-identity/subjects/route.ts b/apps/sim/app/api/v2/credentials/workload-identity/subjects/route.ts new file mode 100644 index 00000000000..6314776157d --- /dev/null +++ b/apps/sim/app/api/v2/credentials/workload-identity/subjects/route.ts @@ -0,0 +1,62 @@ +import { v2CreateCredentialWorkloadIdentitySubjectContract } from '@/lib/api/contracts/v2/credentials' +import { + createV2ResourceConcealmentPolicy, + defineV2JsonRoute, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' +import { renderV2CredentialProviderError } from '@/lib/credentials/api/route-policies' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { prepareWorkloadIdentity } from '@/lib/credentials/application/prepare-workload-identity' +import { + WORKLOAD_IDENTITY_KEYLESS_CONFIGS, + workloadIdentityProviderFor, +} from '@/lib/credentials/workload-identity/audiences' +import { WorkloadIdentityNotConfiguredError } from '@/lib/credentials/workload-identity/subject' +import { v2CaughtOrchestrationError } from '@/app/api/v2/lib/response' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const workloadIdentitySubjectErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', + render: (error) => renderV2CredentialProviderError(error) ?? v2CaughtOrchestrationError(error), +}) + +/** + * POST /api/v2/credentials/workload-identity/subjects — Issue the subject a + * cloud trust configuration pins, and the proof that attaches it to a new + * keyless credential. A deployment that cannot sign is refused exactly as + * Create service-account credential refuses a keyless configuration there. + */ +export const POST = defineV2JsonRoute({ + contract: v2CreateCredentialWorkloadIdentitySubjectContract, + auth: v2ApiKeyAuth, + operation: credentialOperations.prepareWorkloadIdentity, + rateLimit: v2RateLimits.publicApi, + errorPolicy: workloadIdentitySubjectErrorPolicy, + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + provider: workloadIdentityProviderFor(body.providerId), + }), + useCase: prepareWorkloadIdentity, + present: (result, { body }) => { + if (!result.available) { + throw new WorkloadIdentityNotConfiguredError() + } + const { authType, requiredFields, optionalFields } = + WORKLOAD_IDENTITY_KEYLESS_CONFIGS[workloadIdentityProviderFor(body.providerId)] + return { + data: { + issuer: result.issuer, + ...(result.audience ? { audience: result.audience } : {}), + subject: result.subject, + subjectProof: result.subjectProof, + expiresAt: result.expiresAt, + keylessConfig: { authType, subject: result.subject, subjectProof: result.subjectProof }, + requiredConfigFields: [...requiredFields], + optionalConfigFields: [...optionalFields], + }, + } + }, +}) diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index 4b0ecc6146c..8a586d997b5 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -555,6 +555,36 @@ export const createWorkspaceCredentialContract = defineRouteContract({ }, }) +/** + * The trust setup a keyless credential is created from, shared by the connect + * dialog's contract and the public `createCredentialWorkloadIdentitySubject` + * operation so the two cannot describe the same grant differently. + */ +export const workloadIdentitySubjectGrantShape = { + issuer: z + .string() + .describe('OpenID Connect issuer URL to register as the identity provider the cloud trusts.'), + audience: z + .string() + .optional() + .describe( + "The `aud` claim the cloud must accept. Absent for Google Cloud, whose audience is each credential's own workload identity pool provider." + ), + subject: z + .string() + .describe( + 'The exact `sub` claim the trust configuration must allow; unique to one credential.' + ), + subjectProof: z + .string() + .describe( + 'Opaque proof submitted with `subject` when the credential is created. Bound to the requesting user, workspace, and provider.' + ), + expiresAt: z + .string() + .describe('ISO 8601 timestamp after which `subjectProof` can no longer create a credential.'), +} as const + export const prepareWorkloadIdentityContract = defineRouteContract({ method: 'POST', path: '/api/credentials/workload-identity', @@ -571,18 +601,7 @@ export const prepareWorkloadIdentityContract = defineRouteContract({ z.object({ available: z.literal(false) }), z.object({ available: z.literal(true), - /** Register this URL as the OpenID Connect issuer the cloud trusts. */ - issuer: z.string(), - /** - * The `aud` the cloud must accept (AWS client ID, Databricks policy audience). Absent for - * Google, whose audience is each credential's own workload identity provider. - */ - audience: z.string().optional(), - /** The exact `sub` the role's trust policy must allow. */ - subject: z.string(), - /** Opaque proof submitted with the credential; binds the subject to its creator. */ - subjectProof: z.string(), - expiresAt: z.string(), + ...workloadIdentitySubjectGrantShape, /** The reconnected credential is keyless (Snowflake, Microsoft app, and Google only). */ reconnectsKeyless: z.literal(true).optional(), }), diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index 5a679ceb00e..5441721d578 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { atlassianProductSchema, quickBooksOAuthClientConfigSchema, + workloadIdentitySubjectGrantShape, workspaceCredentialRoleSchema, } from '@/lib/api/contracts/credentials' import { @@ -23,6 +24,7 @@ import { getMissingServiceAccountFields, SERVICE_ACCOUNT_REQUIRED_FIELDS, } from '@/lib/credentials/service-account-fields' +import { WORKLOAD_IDENTITY_SERVICE_ACCOUNT_PROVIDER_ID_LIST } from '@/lib/credentials/workload-identity/audiences' import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' /** Public credentials are authenticated connections, never raw environment secrets. */ @@ -158,6 +160,19 @@ export const v2ServiceAccountCredentialProviderSchema = z .min(1) .max(20) .describe('Create-body fields accepted by this provider. Secret fields are write-only.'), + keyless: z + .object({ + authType: z.string().describe('The `authType` of the keyless `serviceAccountJson`.'), + requiredConfigFields: z.array(z.string()).describe('Fields you add to `keylessConfig`.'), + optionalConfigFields: z + .array(z.string()) + .describe('Fields `keylessConfig` may also carry.'), + }) + .strict() + .optional() + .describe( + 'Present when this deployment can create a keyless credential for the provider. Start one with Create workload identity subject.' + ), }) .strict() @@ -404,6 +419,57 @@ export const v2CreateCredentialConnectionContract = defineRouteContract({ }, }) +const v2CreateCredentialWorkloadIdentitySubjectBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that will own the keyless credential.'), + providerId: z + .enum(WORKLOAD_IDENTITY_SERVICE_ACCOUNT_PROVIDER_ID_LIST) + .describe( + 'Service-account provider the keyless credential will be created under; pass the same value to Create service-account credential. The response and List credential providers name the fields its keyless configuration needs.' + ), + }) + .strict() +const v2CredentialWorkloadIdentitySubjectSchema = z + .object({ + ...workloadIdentitySubjectGrantShape, + expiresAt: v2TimestampSchema.describe( + workloadIdentitySubjectGrantShape.expiresAt.description ?? 'Proof expiry.' + ), + keylessConfig: z + .object({ + authType: z.string().describe("The provider's keyless authentication type."), + subject: workloadIdentitySubjectGrantShape.subject, + subjectProof: workloadIdentitySubjectGrantShape.subjectProof, + }) + .strict() + .describe( + 'Prefilled keyless `serviceAccountJson`: add `requiredConfigFields`, then send it to Create service-account credential.' + ), + requiredConfigFields: z + .array(z.string()) + .describe( + 'Fields from your cloud setup that `keylessConfig` still needs, such as roleArn or host.' + ), + optionalConfigFields: z.array(z.string()).describe('Fields `keylessConfig` may also carry.'), + }) + .meta({ + id: 'V2CredentialWorkloadIdentitySubject', + 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.', + }) + +export const v2CreateCredentialWorkloadIdentitySubjectContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/credentials/workload-identity/subjects', + query: noInputSchema, + body: v2CreateCredentialWorkloadIdentitySubjectBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CredentialWorkloadIdentitySubjectSchema), + }, +}) + const MAX_SERVICE_ACCOUNT_JSON_CHARS = 2 * 1024 * 1024 // JSON escaping can double the configuration; retain room for the other credential fields. const MAX_CREDENTIALS_JSON_CHARS = 2 * MAX_SERVICE_ACCOUNT_JSON_CHARS + 128 * 1024 diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index 4adeaba2f02..0091289bd34 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -9,6 +9,7 @@ import { } from '@/lib/api/contracts/v2/catalog' import { v2CreateCredentialConnectionContract, + v2CreateCredentialWorkloadIdentitySubjectContract, v2CreateServiceAccountCredentialContract, v2DeleteCredentialContract, v2ListCredentialProvidersContract, @@ -606,6 +607,24 @@ const CREDENTIAL_CONNECTION_EXAMPLE = { expiresAt: '2026-06-20T14:17:11.000Z', } as const +const WORKLOAD_IDENTITY_SUBJECT = 'sim:workspace::credential:' +const WORKLOAD_IDENTITY_SUBJECT_PROOF = '' + +const WORKLOAD_IDENTITY_SUBJECT_EXAMPLE = { + issuer: 'https://www.sim.ai/oidc/workload', + audience: 'sts.amazonaws.com', + subject: WORKLOAD_IDENTITY_SUBJECT, + subjectProof: WORKLOAD_IDENTITY_SUBJECT_PROOF, + expiresAt: '2026-06-21T14:17:11.000Z', + keylessConfig: { + authType: 'oidc_role', + subject: WORKLOAD_IDENTITY_SUBJECT, + subjectProof: WORKLOAD_IDENTITY_SUBJECT_PROOF, + }, + requiredConfigFields: ['roleArn'], + optionalConfigFields: ['stsRegion', 'sessionDurationSeconds'], +} as const + const SECRET_EXAMPLE = { name: 'STRIPE_API_KEY', scope: 'workspace', @@ -1686,7 +1705,7 @@ const declaredRoutes = [ applicationOperation: credentialOperations.createServiceAccount, operationId: 'createServiceAccountCredential', summary: 'Create service-account credential', - description: `Verify and store a service-account credential. Use the fields from List credential providers, encoded as a JSON object string in \`credentials\`. Requires workspace write access. A matching source returns the existing credential with \`200\`; creation returns \`201\`. Stored secrets are omitted from the response. ${WORKSPACE_API_KEY_DENIED}`, + description: `Verify and store a service-account credential. Use the fields from List credential providers, encoded as a JSON object string in \`credentials\`. For a keyless AWS, Databricks, Snowflake, Microsoft app, or Google Cloud credential, call Create workload identity subject first, configure the cloud to trust it, then send its \`keylessConfig\` as \`serviceAccountJson\`. A refused keyless credential names its cause in \`details.code\`: \`409\` for \`WORKLOAD_IDENTITY_SUBJECT_IN_USE\` or \`WORKLOAD_IDENTITY_SUBJECT_UNVERIFIABLE\`, otherwise \`400\`, such as \`WORKLOAD_IDENTITY_PROOF_EXPIRED\` (request a new subject). Requires workspace write access. A matching source returns the existing credential with \`200\`; creation returns \`201\`. Stored secrets are omitted from the response. ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_CONFLICT_ERRORS, success: { byStatus: { @@ -1711,6 +1730,20 @@ const declaredRoutes = [ credentials: '{"clientId":"YOUR_CLIENT_ID","clientSecret":"YOUR_CLIENT_SECRET","orgId":"YOUR_ACCOUNT_ID"}', }, + { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'aws-service-account', + displayName: 'AWS production (keyless)', + credentials: JSON.stringify({ + serviceAccountJson: JSON.stringify({ + authType: 'oidc_role', + roleArn: 'arn:aws:iam::123456789012:role/SimWorkflows', + subject: WORKLOAD_IDENTITY_SUBJECT_EXAMPLE.subject, + subjectProof: WORKLOAD_IDENTITY_SUBJECT_EXAMPLE.subjectProof, + }), + }), + }, ] ), response: documentedSchema( @@ -1764,6 +1797,34 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2CreateCredentialWorkloadIdentitySubjectContract, + resourceOperation('Credentials', { + applicationOperation: credentialOperations.prepareWorkloadIdentity, + 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_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: { description: 'A subject and proof for one new keyless credential.' }, + }), + { + query: v2CreateCredentialWorkloadIdentitySubjectContract.query, + body: documentedSchema( + v2CreateCredentialWorkloadIdentitySubjectContract.body, + 'CreateCredentialWorkloadIdentitySubjectRequest', + 'Create workload identity subject request', + 'Workspace that will own the keyless credential and the service-account provider it will be created under.', + [{ workspaceId: WORKSPACE_ID, providerId: 'aws-service-account' }] + ), + response: documentedSchema( + v2CreateCredentialWorkloadIdentitySubjectContract.response.schema, + 'CreateCredentialWorkloadIdentitySubjectResponse', + 'Create workload identity subject response', + 'Trust values to configure in the cloud, and the proof to submit when creating the credential.', + [{ data: WORKLOAD_IDENTITY_SUBJECT_EXAMPLE }] + ), + } + ), defineOpenApiRoute( v2DeleteCredentialContract, resourceOperation('Credentials', { diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index e653f37b816..7984fbe8faa 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -43,6 +43,7 @@ import { } from '@/lib/api/contracts/v2/chat-deployments' import { v2CreateCredentialConnectionContract, + v2CreateCredentialWorkloadIdentitySubjectContract, v2CreateServiceAccountCredentialContract, v2DeleteCredentialContract, v2ListCredentialMembersContract, @@ -656,6 +657,17 @@ export const V2_MCP_OPERATIONS = { workspaceKeyUnsupported: true, handler: () => import('@/app/api/v2/credentials/connections/route').then((route) => route.POST), }, + createCredentialWorkloadIdentitySubject: { + contract: v2CreateCredentialWorkloadIdentitySubjectContract, + 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`.", + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/credentials/workload-identity/subjects/route').then( + (route) => route.POST + ), + }, createCustomTool: { contract: v2CreateCustomToolContract, summary: 'Create custom tool', @@ -823,7 +835,7 @@ export const V2_MCP_OPERATIONS = { contract: v2CreateServiceAccountCredentialContract, summary: 'Create service-account credential', description: - 'Verify and store a service-account credential. Use the fields from List credential providers, encoded as a JSON object string in `credentials`. Requires workspace write access. A matching source returns the existing credential with `200`; creation returns `201`. Stored secrets are omitted from the response. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + 'Verify and store a service-account credential. Use the fields from List credential providers, encoded as a JSON object string in `credentials`. For a keyless AWS, Databricks, Snowflake, Microsoft app, or Google Cloud credential, call Create workload identity subject first, configure the cloud to trust it, then send its `keylessConfig` as `serviceAccountJson`. A refused keyless credential names its cause in `details.code`: `409` for `WORKLOAD_IDENTITY_SUBJECT_IN_USE` or `WORKLOAD_IDENTITY_SUBJECT_UNVERIFIABLE`, otherwise `400`, such as `WORKLOAD_IDENTITY_PROOF_EXPIRED` (request a new subject). Requires workspace write access. A matching source returns the existing credential with `200`; creation returns `201`. Stored secrets are omitted from the response. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', workspaceKeyUnsupported: true, handler: () => import('@/app/api/v2/credentials/route').then((route) => route.POST), }, diff --git a/apps/sim/lib/api/server/routes/copilot-refused-routes.json b/apps/sim/lib/api/server/routes/copilot-refused-routes.json index 399e897eeb8..925d314bdce 100644 --- a/apps/sim/lib/api/server/routes/copilot-refused-routes.json +++ b/apps/sim/lib/api/server/routes/copilot-refused-routes.json @@ -4,6 +4,11 @@ "path": "/api/v2/chat", "operation": "chat.send" }, + { + "method": "POST", + "path": "/api/v2/credentials/workload-identity/subjects", + "operation": "credentials.workload_identity.prepare" + }, { "method": "DELETE", "path": "/api/v2/files/[fileId]/versions/[version]", diff --git a/apps/sim/lib/api/server/routes/v2-route-table.generated.ts b/apps/sim/lib/api/server/routes/v2-route-table.generated.ts index a7ad4c8e5ce..87fba6041b9 100644 --- a/apps/sim/lib/api/server/routes/v2-route-table.generated.ts +++ b/apps/sim/lib/api/server/routes/v2-route-table.generated.ts @@ -70,6 +70,10 @@ export const V2_ROUTES: readonly V2RouteEntry[] = [ pattern: '/api/v2/credentials/providers', load: () => import('@/app/api/v2/credentials/providers/route'), }, + { + pattern: '/api/v2/credentials/workload-identity/subjects', + load: () => import('@/app/api/v2/credentials/workload-identity/subjects/route'), + }, { pattern: '/api/v2/custom-tools', load: () => import('@/app/api/v2/custom-tools/route'), diff --git a/apps/sim/lib/credentials/__integration__/workload-identity-subject-reuse.integration.ts b/apps/sim/lib/credentials/__integration__/workload-identity-subject-reuse.integration.ts index 5108985d368..ed303319c5e 100644 --- a/apps/sim/lib/credentials/__integration__/workload-identity-subject-reuse.integration.ts +++ b/apps/sim/lib/credentials/__integration__/workload-identity-subject-reuse.integration.ts @@ -16,6 +16,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/posthog/server', () => posthogServerMock) vi.mock('@/lib/credentials/workload-identity/issuer', async (importOriginal) => ({ ...(await importOriginal()), + isWorkloadIdentityConfigured: async () => true, mintWorkloadIdentityToken: async ({ subject }: { subject: string }) => `token-for:${subject}`, })) diff --git a/apps/sim/lib/credentials/api/route-policies.ts b/apps/sim/lib/credentials/api/route-policies.ts index bda9ab260a8..bf33a07a527 100644 --- a/apps/sim/lib/credentials/api/route-policies.ts +++ b/apps/sim/lib/credentials/api/route-policies.ts @@ -1,3 +1,4 @@ +import type { NextResponse } from 'next/server' import { extendInternalErrorPolicy, internalErrorResponse, @@ -7,7 +8,7 @@ import { getValidationErrorMessage, validationErrorResponse } from '@/lib/api/se import { ADMISSION_RETRY_AFTER_SECONDS } from '@/lib/core/admission/transient-failure' import { NoWorkspaceAccessError } from '@/lib/core/application' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' -import { OrchestrationError } from '@/lib/core/orchestration/types' +import { OrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { CredentialGroupEnrollmentError } from '@/lib/credential-groups/enrollments' import { CredentialGroupOAuthError, @@ -21,6 +22,27 @@ import { OAuthDisconnectPartialFailureError, OAuthProviderRevocationError, } from '@/lib/credentials/oauth-accounts' +import { KeylessCredentialRefusedError } from '@/lib/credentials/workload-identity/subject' +import { v2Error, v2ErrorForOrchestration } from '@/app/api/v2/lib/response' + +/** + * The v2 rendering of a refused credential. A keyless refusal names its cause + * in `details.code`, the field every v2 application error code uses; a + * provider rejection keeps its published `details.providerErrorCode`, and a + * provider outage is `503`. + */ +export function renderV2CredentialProviderError(error: unknown): NextResponse | null { + if (error instanceof KeylessCredentialRefusedError) { + return v2ErrorForOrchestration(error.code, error.message, { code: error.detailCode }) + } + if (!(error instanceof CredentialProviderOperationError)) return null + if (error.providerUnavailable) { + return v2Error('SERVICE_UNAVAILABLE', 'Credential provider is temporarily unavailable') + } + return v2Error('BAD_REQUEST', error.message, { + details: { providerErrorCode: error.providerErrorCode }, + }) +} export const credentialValidationParseOptions = { validationErrorResponse: (error: Parameters[0]) => @@ -54,6 +76,12 @@ export const internalCredentialErrorPolicy = extendInternalErrorPolicy( { 'Retry-After': ADMISSION_RETRY_AFTER_SECONDS.toString() } ) } + if (error instanceof KeylessCredentialRefusedError) { + return internalErrorResponse(statusForOrchestrationError(error.code), { + error: error.message, + code: error.detailCode, + }) + } if (!(error instanceof CredentialProviderOperationError)) return null if (!error.providerUnavailable) { return internalErrorResponse(400, { diff --git a/apps/sim/lib/credentials/application/crud.ts b/apps/sim/lib/credentials/application/crud.ts index c171fd39910..ceca2e8cf4b 100644 --- a/apps/sim/lib/credentials/application/crud.ts +++ b/apps/sim/lib/credentials/application/crud.ts @@ -37,6 +37,7 @@ import { type WorkspaceCredentialLookup, } from '@/lib/credentials/queries' import { getServiceAccountGatingBlockType } from '@/lib/credentials/service-account-provider-ids' +import type { KeylessCredentialRefusedError } from '@/lib/credentials/workload-identity/subject' import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server' import { allowedIntegrationTypes } from '@/lib/integrations/principal-scope.server' import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions' @@ -61,7 +62,9 @@ export function throwCredentialMutationFailure(result: { errorCode?: PerformCredentialResult['errorCode'] providerErrorCode?: string providerUnavailable?: boolean + keylessRefusal?: KeylessCredentialRefusedError }): never { + if (result.keylessRefusal) throw result.keylessRefusal if (result.providerErrorCode) { throw new CredentialProviderOperationError( result.error ?? result.providerErrorCode, diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index f04697cb65b..e8616242891 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -143,13 +143,19 @@ export const credentialOperations = { principalKinds: ['delegated'], delegatedServices: ['copilot'], }), - /** Issues a signed OIDC subject for a new keyless AWS or Databricks credential (connect modal only). */ + /** + * Issues a signed workload-identity subject for a new keyless credential. The + * proof is bound to the person who asked for it, so only principals that + * carry a person qualify: never a workspace API key, and never a delegated + * service acting for one. + */ prepareWorkloadIdentity: defineWorkspaceOperation({ id: 'credentials.workload_identity.prepare', + oauthScope: 'api:write', minimumRole: 'write', workspaceApiKey: 'deny', capability: 'integrations.manage', - principalKinds: ['session'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], }), createServiceAccount: defineWorkspaceOperation({ id: 'credentials.service_accounts.create', diff --git a/apps/sim/lib/credentials/application/provider-catalog-contract.test.ts b/apps/sim/lib/credentials/application/provider-catalog-contract.test.ts index 85447ca97ec..af9fef25fec 100644 --- a/apps/sim/lib/credentials/application/provider-catalog-contract.test.ts +++ b/apps/sim/lib/credentials/application/provider-catalog-contract.test.ts @@ -1,3 +1,4 @@ +import { generateKeyPairSync } from 'node:crypto' import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' import { blockVisibilityMock } from '@sim/testing/mocks/block-visibility.mock' import { workspaceAuthzMock, workspaceAuthzMockFns } from '@sim/testing/mocks/workspace-authz.mock' @@ -5,7 +6,7 @@ import { workspaceContextMock, workspaceContextMockFns, } from '@sim/testing/mocks/workspace-context.mock' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const hoisted = vi.hoisted(() => ({ allowedIntegrationTypes: vi.fn(), @@ -21,13 +22,24 @@ vi.mock('@/lib/integrations/principal-scope.server', () => ({ })) import { v2ListCredentialProvidersContract } from '@/lib/api/contracts/v2/credentials' +import { env } from '@/lib/core/config/env' import { listCredentialProviders } from '@/lib/credentials/application/list-credential-providers' import { listCredentialProviderCatalog } from '@/lib/credentials/application/provider-catalog' +import { getMissingServiceAccountFields } from '@/lib/credentials/service-account-fields' +import { + isWorkloadIdentityServiceAccountProviderId, + WORKLOAD_IDENTITY_KEYLESS_CONFIGS, + workloadIdentityProviderFor, +} from '@/lib/credentials/workload-identity/audiences' const CLAUDE_PROVIDER_ID = 'claude-platform-service-account' const GITHUB_INSTALLATION_PROVIDER_ID = 'github-app-installation' const context = { workspaceId: 'workspace-1', workspaceOrganizationId: null } const responseSchema = v2ListCredentialProvidersContract.response.schema +const SIGNING_KEY = generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ + type: 'pkcs8', + format: 'pem', +}) async function listProviders(search?: string) { const { providers } = await listCredentialProviders.execute({ @@ -38,6 +50,11 @@ async function listProviders(search?: string) { } describe('v2 credential provider catalog contract', () => { + afterEach(() => { + Reflect.set(env, 'WORKLOAD_IDENTITY_SIGNING_KEY', undefined) + Reflect.set(env, 'WORKLOAD_IDENTITY_ISSUER', undefined) + }) + beforeEach(() => { hoisted.allowedIntegrationTypes.mockResolvedValue(null) workspaceContextMockFns.mockLoadActiveWorkspaceApplicationContext.mockResolvedValue({ @@ -74,6 +91,69 @@ describe('v2 credential provider catalog contract', () => { expect(responseSchema.safeParse({ data: catalog, nextCursor: null }).success).toBe(false) }) + it('advertises a keyless mode the API accepts, on exactly the keyless providers', async () => { + Reflect.set(env, 'WORKLOAD_IDENTITY_SIGNING_KEY', SIGNING_KEY) + Reflect.set(env, 'WORKLOAD_IDENTITY_ISSUER', 'https://sim.example.com/oidc/workload') + const providers = await listProviders() + expect(responseSchema.safeParse({ data: providers, nextCursor: null }).success).toBe(true) + const keyless = providers.flatMap((provider) => + provider.type === 'service_account' && provider.keyless ? [provider] : [] + ) + + expect(keyless.length).toBeGreaterThan(0) + for (const provider of keyless) { + if (!isWorkloadIdentityServiceAccountProviderId(provider.providerId)) { + throw new Error( + `${provider.providerId} advertises keyless without a workload identity provider` + ) + } + expect(provider.keyless).toEqual({ + authType: + WORKLOAD_IDENTITY_KEYLESS_CONFIGS[workloadIdentityProviderFor(provider.providerId)] + .authType, + requiredConfigFields: expect.any(Array), + optionalConfigFields: expect.any(Array), + }) + expect(provider.fields.map((field) => field.id)).toContain('serviceAccountJson') + expect( + getMissingServiceAccountFields(provider.providerId, { serviceAccountJson: '{}' }) + ).toEqual([]) + } + const google = keyless.find((provider) => provider.providerId === 'google-service-account') + expect(google?.description).toContain(WORKLOAD_IDENTITY_KEYLESS_CONFIGS.google.scope) + expect(google?.fields.find((field) => field.id === 'serviceAccountJson')?.hint).toContain( + WORKLOAD_IDENTITY_KEYLESS_CONFIGS.google.scope + ) + const snowflake = keyless.find( + (provider) => provider.providerId === 'snowflake-service-account' + ) + expect(snowflake?.fields.find((field) => field.id === 'serviceAccountJson')).toMatchObject({ + required: false, + secret: true, + }) + }) + + it('offers no keyless mode where this deployment cannot sign', async () => { + const providers = await listProviders() + const keylessProviders = providers.filter( + (provider) => + provider.type === 'service_account' && + (provider.providerId === 'aws-service-account' || + provider.providerId === 'snowflake-service-account' || + provider.providerId === 'google-service-account') + ) + + for (const provider of keylessProviders) { + if (provider.type !== 'service_account') continue + expect(provider.keyless).toBeUndefined() + } + const snowflake = keylessProviders.find( + (provider) => + provider.type === 'service_account' && provider.providerId === 'snowflake-service-account' + ) + expect(snowflake?.fields.map((field) => field.id)).toEqual(['domain', 'apiToken']) + }) + it('lists the native Claude Platform provider when filtered', async () => { const providers = await listProviders('claude') diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts index 3b21c793b67..f66e4fe53f9 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -9,6 +9,12 @@ import { TOKEN_SERVICE_ACCOUNT_DESCRIPTORS, type TokenServiceAccountField, } from '@/lib/credentials/token-service-accounts/descriptors' +import { + isWorkloadIdentityServiceAccountProviderId, + WORKLOAD_IDENTITY_KEYLESS_CONFIGS, + workloadIdentityProviderFor, +} from '@/lib/credentials/workload-identity/audiences' +import { isWorkloadIdentityConfigured } from '@/lib/credentials/workload-identity/issuer' import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server' import { allowedIntegrationTypes, @@ -75,6 +81,14 @@ export interface ServiceAccountCredentialProviderCatalogEntry helpText?: string requiresClientGeneratedCredentialId: boolean fields: CredentialProviderField[] + /** The provider's keyless mode; absent without one, or when this deployment cannot sign. */ + keyless?: CredentialProviderKeylessMode +} + +interface CredentialProviderKeylessMode { + authType: string + requiredConfigFields: string[] + optionalConfigFields: string[] } export type CredentialProviderCatalogEntry = @@ -120,6 +134,72 @@ function providerField( } } +interface KeylessCatalogMode { + keyless: CredentialProviderKeylessMode + /** Where keyless works, when narrower than the provider. */ + scope?: string +} + +function keylessModeFor(providerId: string): KeylessCatalogMode | undefined { + if (!isWorkloadIdentityServiceAccountProviderId(providerId)) return undefined + const { authType, requiredFields, optionalFields, scope } = + WORKLOAD_IDENTITY_KEYLESS_CONFIGS[workloadIdentityProviderFor(providerId)] + return { + keyless: { + authType, + requiredConfigFields: [...requiredFields], + optionalConfigFields: [...optionalFields], + }, + ...(scope ? { scope } : {}), + } +} + +/** + * Adds a provider's keyless mode to its key-based descriptor, the same way for + * every keyless provider: one description suffix, and a hint on the + * `serviceAccountJson` field — which a token provider such as Snowflake gains + * as an optional alternative to its token fields. + */ +function withKeylessMode( + descriptor: ServiceAccountDescriptor, + mode: KeylessCatalogMode | undefined +): ServiceAccountDescriptor & { keyless?: CredentialProviderKeylessMode } { + 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 configurationField = descriptor.fields.find((field) => field.id === 'serviceAccountJson') + const fields = configurationField + ? descriptor.fields.map((field) => + field === configurationField + ? { ...field, hint: field.hint ? `${field.hint} ${hint}` : hint } + : field + ) + : [ + ...descriptor.fields, + { + id: 'serviceAccountJson', + label: 'Keyless configuration', + placeholder: JSON.stringify({ + authType: keyless.authType, + ...Object.fromEntries(keyless.requiredConfigFields.map((field) => [field, '...'])), + subject: '...', + subjectProof: '...', + }), + required: false, + secret: true, + multiline: true, + hint: `Send instead of the other fields. ${hint}`, + }, + ] + return { + ...descriptor, + description: `${descriptor.description} Also supports a keyless credential that trusts Sim through workload identity federation.${scopeNote}`, + fields, + keyless, + } +} + function getServiceAccountDescriptor(providerId: string): ServiceAccountDescriptor { if (providerId === GITHUB_INSTALLATION_PROVIDER_ID) { return { @@ -135,7 +215,7 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript return { name: 'Databricks connection', description: - 'Use the Databricks block with a personal access token, a service principal OAuth secret, or a keyless service principal that trusts Sim through OpenID Connect.', + 'Use the Databricks block with a personal access token or a service principal OAuth secret.', docsUrl: 'https://docs.sim.ai/integrations/databricks-credentials', fields: [ { @@ -146,7 +226,7 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript required: true, secret: true, multiline: true, - hint: 'JSON object with host and authType: "personal_access_token" (token) or "service_principal" (clientId, clientSecret). Keyless "oidc_federation" connections are created from the Databricks connect dialog, which issues the trusted subject.', + hint: `JSON object with host and authType: "personal_access_token" (token) or "service_principal" (clientId, clientSecret).`, }, ], } @@ -155,7 +235,7 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript return { name: 'Microsoft app credential', description: - 'Use Microsoft Entra ID, SharePoint, Dataverse, and Dynamics 365 as an app registration (app-only), with a client secret, a certificate, or a keyless federated credential that trusts Sim.', + 'Use Microsoft Entra ID, SharePoint, Dataverse, and Dynamics 365 as an app registration (app-only), with a client secret or a certificate.', docsUrl: 'https://docs.sim.ai/integrations/microsoft-app-credentials', fields: [ { @@ -166,7 +246,7 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript required: true, secret: true, multiline: true, - hint: 'JSON object with tenantId, clientId, optional environmentUrl (Dataverse and Dynamics 365), and authType: "client_secret" (clientSecret) or "certificate" (privateKey, certificate as PEM). Keyless "federated" credentials are created from the Microsoft connect dialog, which issues the trusted subject.', + hint: `JSON object with tenantId, clientId, optional environmentUrl (Dataverse and Dynamics 365), and authType: "client_secret" (clientSecret) or "certificate" (privateKey, certificate as PEM).`, }, ], } @@ -174,8 +254,7 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript if (providerId === AWS_SERVICE_ACCOUNT_PROVIDER_ID) { return { name: 'AWS credential', - description: - 'Use AWS blocks with saved access keys, access keys that assume an IAM role, or a keyless IAM role that trusts Sim through OpenID Connect.', + description: 'Use AWS blocks with saved access keys, or access keys that assume an IAM role.', docsUrl: 'https://docs.sim.ai/integrations/aws-credentials', fields: [ { @@ -185,7 +264,7 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript required: true, secret: true, multiline: true, - hint: 'JSON object with authType: "access_keys" (accessKeyId, secretAccessKey) or "assume_role" (accessKeyId, secretAccessKey, roleArn, optional externalId). Keyless "oidc_role" credentials are created from the AWS connect dialog, which issues the trusted subject.', + hint: `JSON object with authType: "access_keys" (accessKeyId, secretAccessKey) or "assume_role" (accessKeyId, secretAccessKey, roleArn, optional externalId).`, }, ], } @@ -396,7 +475,7 @@ export async function listCredentialProviderCatalog( const userId = principalUserId(principal) const organizationId = 'organizationId' in context ? context.organizationId : context.workspaceOrganizationId - const [allowedIntegrations, blockVisibility] = await Promise.all([ + const [allowedIntegrations, blockVisibility, keylessAvailable] = await Promise.all([ 'organizationId' in context ? allowedOrganizationIntegrationTypes(context.organizationId) : allowedIntegrationTypes(principal, context.workspaceId), @@ -404,6 +483,8 @@ export async function listCredentialProviderCatalog( ...(userId ? { userId } : {}), ...(organizationId ? { orgId: organizationId } : {}), }), + // Keyless subjects are issued for workspaces only, and only where Sim can sign. + 'organizationId' in context ? false : isWorkloadIdentityConfigured(), ]) const services = getAllOAuthServices() const oauthServices = services.filter((service) => service.authType === 'oauth') @@ -468,7 +549,10 @@ export async function listCredentialProviderCatalog( const serviceAccountEntries: ServiceAccountCredentialProviderCatalogEntry[] = [ ...serviceAccountOwners, ].map(([providerId, owner]) => { - const descriptor = getServiceAccountDescriptor(providerId) + const descriptor = withKeylessMode( + getServiceAccountDescriptor(providerId), + keylessAvailable ? keylessModeFor(providerId) : undefined + ) return { type: 'service_account', serviceId: providerId, @@ -481,6 +565,7 @@ export async function listCredentialProviderCatalog( ...(descriptor.helpText ? { helpText: descriptor.helpText } : {}), requiresClientGeneratedCredentialId: descriptor.requiresClientGeneratedCredentialId === true, fields: descriptor.fields, + ...(descriptor.keyless ? { keyless: descriptor.keyless } : {}), } }) diff --git a/apps/sim/lib/credentials/application/service-account.test.ts b/apps/sim/lib/credentials/application/service-account.test.ts index 0db61e5fff0..ee1eb8f047c 100644 --- a/apps/sim/lib/credentials/application/service-account.test.ts +++ b/apps/sim/lib/credentials/application/service-account.test.ts @@ -46,6 +46,7 @@ import { createServiceAccountCredentialUseCase, deleteCredentialUseCase, } from '@/lib/credentials/application/service-account' +import { KeylessCredentialRefusedError } from '@/lib/credentials/workload-identity/subject' import { executeCopilotCredentialUseCase } from '@/lib/mothership/application/execute-credential-use-case' const mocks = { @@ -126,6 +127,30 @@ describe('credential service-account application operations', () => { expect(mocks.create).not.toHaveBeenCalled() }) + it('rethrows the keyless refusal the create result carries, with its code intact', async () => { + const refusal = new KeylessCredentialRefusedError( + 'Another credential already uses this trust setup.', + 'WORKLOAD_IDENTITY_SUBJECT_IN_USE' + ) + mocks.create.mockResolvedValue({ + success: false, + error: refusal.message, + errorCode: 'conflict', + keylessRefusal: refusal, + }) + + await expect( + createServiceAccountCredentialUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + providerId: 'aws-service-account', + serviceAccountJson: '{}', + }, + }) + ).rejects.toBe(refusal) + }) + const storedInput = { workspaceId: WORKSPACE_ID, displayName: 'Support bot', diff --git a/apps/sim/lib/credentials/application/service-account.ts b/apps/sim/lib/credentials/application/service-account.ts index 25f099c0302..94f272174b1 100644 --- a/apps/sim/lib/credentials/application/service-account.ts +++ b/apps/sim/lib/credentials/application/service-account.ts @@ -107,6 +107,7 @@ export const createServiceAccountCredentialUseCase = defineAuthorizedWorkspaceUs }) if (!result.success) { if (result.providerUnavailable) throw new CredentialProviderUnavailableError() + if (result.keylessRefusal) throw result.keylessRefusal switch (result.errorCode) { case 'validation': case 'not_found': 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 7d386e4763b..97b4a329854 100644 --- a/apps/sim/lib/credentials/aws-service-account.server.test.ts +++ b/apps/sim/lib/credentials/aws-service-account.server.test.ts @@ -5,11 +5,15 @@ import { queueTableRows, resetDbChainMock } from '@sim/testing' import { encryptionMock, encryptionMockFns } from '@sim/testing/mocks/encryption.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' -const hoisted = vi.hoisted(() => ({ send: vi.fn() })) +const hoisted = vi.hoisted(() => ({ + send: vi.fn(), + mint: vi.fn(async ({ subject }: { subject: string }) => `token-for:${subject}`), +})) vi.mock('@/lib/core/security/encryption', () => encryptionMock) vi.mock('@/lib/credentials/workload-identity/issuer', () => ({ - mintWorkloadIdentityToken: async ({ subject }: { subject: string }) => `token-for:${subject}`, + isWorkloadIdentityConfigured: async () => true, + mintWorkloadIdentityToken: hoisted.mint, })) vi.mock('@aws-sdk/client-sts', async (importActual) => ({ ...(await importActual()), @@ -24,6 +28,7 @@ import { getAwsCredentialConfig, verifyAndEncryptAwsCredential, } from '@/lib/credentials/aws-service-account.server' +import { WorkloadIdentityNotConfiguredError } from '@/lib/credentials/workload-identity/subject' import { AWS_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' const ROLE_ARN = 'arn:aws:iam::123456789012:role/sim-workflows' @@ -87,6 +92,29 @@ describe('saving a keyless AWS role', () => { expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled() }) + it('reads a provider message that ends in a period as one sentence', async () => { + hoisted.send.mockRejectedValue( + Object.assign(new Error('Not authorized to perform sts:AssumeRoleWithWebIdentity.'), { + name: 'AccessDenied', + }) + ) + + await expect( + verifyAndEncryptAwsCredential(keylessReconnect.serialized, keylessReconnect.owner) + ).rejects.toThrow( + 'Sim could not assume this role: Not authorized to perform sts:AssumeRoleWithWebIdentity. Check that' + ) + }) + + it('reconnects to a keyless refusal with its code when signing stops mid-save', async () => { + roleTrusting(true) + hoisted.mint.mockRejectedValueOnce(new WorkloadIdentityNotConfiguredError()) + + await expect( + verifyAndEncryptAwsCredential(keylessReconnect.serialized, keylessReconnect.owner) + ).rejects.toMatchObject({ detailCode: 'WORKLOAD_IDENTITY_NOT_CONFIGURED' }) + }) + it('does not save when the any-subject check cannot be completed', async () => { roleTrusting(true) const pinned = hoisted.send.getMockImplementation() diff --git a/apps/sim/lib/credentials/aws-service-account.server.ts b/apps/sim/lib/credentials/aws-service-account.server.ts index 6468e2a3d9f..eb6b13afec5 100644 --- a/apps/sim/lib/credentials/aws-service-account.server.ts +++ b/apps/sim/lib/credentials/aws-service-account.server.ts @@ -7,22 +7,23 @@ import { } from '@aws-sdk/client-sts' import { db } from '@sim/db' import { credential } from '@sim/db/schema' -import { getErrorMessage } from '@sim/utils/errors' import { and, eq, isNull, or } from 'drizzle-orm' import { z } from 'zod' import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' -import { parseServiceAccountConfig } from '@/lib/credentials/service-account-config' +import { failureClause, parseServiceAccountConfig } from '@/lib/credentials/service-account-config' import { WORKLOAD_IDENTITY_AUDIENCES } from '@/lib/credentials/workload-identity/audiences' import { + isWorkloadIdentityConfigured, mintWorkloadIdentityToken, type WorkloadIdentityOwner, } from '@/lib/credentials/workload-identity/issuer' import { bindWorkloadIdentitySubject, + KeylessCredentialRefusedError, newWorkloadIdentitySubject, type WorkloadIdentityCredentialOwner, - WorkloadIdentitySubjectError, + WorkloadIdentityNotConfiguredError, } from '@/lib/credentials/workload-identity/subject' import { AWS_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' @@ -227,22 +228,19 @@ function bindSubject( storedSubject: string | undefined, owner: WorkloadIdentityCredentialOwner ): string { - try { - return bindWorkloadIdentitySubject({ - provider: 'aws', - owner, - storedSubject, - subject, - subjectProof, - }) - } catch (error) { - if (error instanceof WorkloadIdentitySubjectError) throw new AwsCredentialError(error.message) - throw error - } + return bindWorkloadIdentitySubject({ + provider: 'aws', + owner, + storedSubject, + subject, + subjectProof, + }) } -function verificationFailure(config: AwsCredentialConfig, error: unknown): AwsCredentialError { - const detail = getErrorMessage(error, 'Unknown error') +function verificationFailure(config: AwsCredentialConfig, error: unknown): Error { + // A keyless refusal already names its cause and code; wrapping it would lose both. + if (error instanceof KeylessCredentialRefusedError) return 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}.` @@ -271,6 +269,9 @@ export async function verifyAndEncryptAwsCredential( const submitted = parsed.data let config: AwsCredentialConfig if (submitted.authType === 'oidc_role') { + if (!(await isWorkloadIdentityConfigured())) { + throw new WorkloadIdentityNotConfiguredError('Use access keys.') + } const { subject, subjectProof, ...rest } = submitted config = { ...rest, @@ -339,8 +340,9 @@ async function assertRolePinsSubject( }) } catch (error) { if (error instanceof Error && error.name === 'AccessDenied') return + if (error instanceof KeylessCredentialRefusedError) throw error throw new AwsCredentialError( - `Sim could not confirm the role's trust policy limits it to this credential: ${getErrorMessage(error, 'Unknown error')}. Try again.` + `Sim could not confirm the role's trust policy limits it to this credential: ${failureClause(error)}. Try again.` ) } throw new AwsCredentialError( 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 87681df75a8..26b610ba9e5 100644 --- a/apps/sim/lib/credentials/databricks-service-account.server.test.ts +++ b/apps/sim/lib/credentials/databricks-service-account.server.test.ts @@ -7,10 +7,15 @@ import { jsonResponse } from '@sim/testing/helpers/http' import { encryptionMock, encryptionMockFns } from '@sim/testing/mocks/encryption.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' +const hoisted = vi.hoisted(() => ({ + mint: vi.fn(async ({ subject }: { subject: string }) => `token-for:${subject}`), +})) + vi.mock('@/lib/core/security/encryption', () => encryptionMock) vi.mock('@/lib/credentials/workload-identity/issuer', () => ({ + isWorkloadIdentityConfigured: async () => true, MAX_WORKLOAD_IDENTITY_TOKEN_TTL_SECONDS: 3600, - mintWorkloadIdentityToken: async ({ subject }: { subject: string }) => `token-for:${subject}`, + mintWorkloadIdentityToken: hoisted.mint, })) import { credential } from '@sim/db/schema' @@ -18,6 +23,7 @@ import { resolveDatabricksCredentialToken, verifyAndEncryptDatabricksCredential, } from '@/lib/credentials/databricks-service-account.server' +import { WorkloadIdentityNotConfiguredError } from '@/lib/credentials/workload-identity/subject' import { DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' const owner = { workspaceId: 'ws-1', actorUserId: 'user-1' } @@ -125,6 +131,15 @@ describe('saving a keyless Databricks connection', () => { }) }) + it('reconnects to a keyless refusal with its code when signing stops mid-save', async () => { + principalTrusting(true) + hoisted.mint.mockRejectedValueOnce(new WorkloadIdentityNotConfiguredError()) + + await expect( + verifyAndEncryptDatabricksCredential(JSON.stringify(config), reconnectOwner) + ).rejects.toMatchObject({ detailCode: 'WORKLOAD_IDENTITY_NOT_CONFIGURED' }) + }) + it('refuses a federation policy that trusts any Sim subject, so no other workspace can use it', async () => { principalTrusting(false) diff --git a/apps/sim/lib/credentials/databricks-service-account.server.ts b/apps/sim/lib/credentials/databricks-service-account.server.ts index ca1d56df7fb..d456af2797b 100644 --- a/apps/sim/lib/credentials/databricks-service-account.server.ts +++ b/apps/sim/lib/credentials/databricks-service-account.server.ts @@ -6,22 +6,24 @@ import { z } from 'zod' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { validateDatabricksWorkspaceHost } from '@/lib/core/security/input-validation' import { isPayloadSizeLimitError, readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' -import { parseServiceAccountConfig } from '@/lib/credentials/service-account-config' +import { failureClause, parseServiceAccountConfig } from '@/lib/credentials/service-account-config' import { providerFailureCode, TokenServiceAccountValidationError, } from '@/lib/credentials/token-service-accounts/errors' import { WORKLOAD_IDENTITY_AUDIENCES } from '@/lib/credentials/workload-identity/audiences' import { + isWorkloadIdentityConfigured, MAX_WORKLOAD_IDENTITY_TOKEN_TTL_SECONDS, mintWorkloadIdentityToken, type WorkloadIdentityOwner, } from '@/lib/credentials/workload-identity/issuer' import { bindWorkloadIdentitySubject, + KeylessCredentialRefusedError, newWorkloadIdentitySubject, type WorkloadIdentityCredentialOwner, - WorkloadIdentitySubjectError, + WorkloadIdentityNotConfiguredError, } from '@/lib/credentials/workload-identity/subject' import { DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' @@ -229,20 +231,13 @@ function bindSubject( subjectProof: string | undefined, owner: WorkloadIdentityCredentialOwner ): string { - try { - return bindWorkloadIdentitySubject({ - provider: 'databricks', - owner, - storedSubject: readStoredSubject(owner.storedSecret), - subject, - subjectProof, - }) - } catch (error) { - if (error instanceof WorkloadIdentitySubjectError) { - throw new DatabricksCredentialError(error.message) - } - throw error - } + return bindWorkloadIdentitySubject({ + provider: 'databricks', + owner, + storedSubject: readStoredSubject(owner.storedSecret), + subject, + subjectProof, + }) } /** Proves a configuration by calling the workspace's SCIM `Me` with the resolved token. */ @@ -276,6 +271,8 @@ async function verifyWorkspaceIdentity( config.host ) } catch (error) { + // A keyless refusal already names its cause and code; wrapping it would lose both. + if (error instanceof KeylessCredentialRefusedError) throw error throw new DatabricksCredentialError( `Could not verify the Databricks credential: ${getErrorMessage(error, 'Unknown error')}` ) @@ -305,8 +302,9 @@ async function assertPolicyPinsSubject( error.status !== undefined && providerFailureCode(error.status) === 'invalid_credentials' if (refused) return + if (error instanceof KeylessCredentialRefusedError) throw error throw new DatabricksCredentialError( - `Sim could not confirm the federation policy limits this service principal to this credential: ${getErrorMessage(error, 'Unknown error')}. Try again.` + `Sim could not confirm the federation policy limits this service principal to this credential: ${failureClause(error)}. Try again.` ) } throw new DatabricksCredentialError( @@ -333,6 +331,11 @@ export async function verifyAndEncryptDatabricksCredential( const submitted = parsed.data let config: DatabricksCredentialConfig if (submitted.authType === 'oidc_federation') { + if (!(await isWorkloadIdentityConfigured())) { + throw new WorkloadIdentityNotConfiguredError( + 'Use a personal access token or a service principal OAuth secret.' + ) + } const { subject, subjectProof, ...rest } = submitted config = { ...rest, subject: bindSubject(subject, subjectProof, owner) } } else { diff --git a/apps/sim/lib/credentials/google-workload-identity.server.ts b/apps/sim/lib/credentials/google-workload-identity.server.ts index 3a4f0ea89f4..1288708dfaf 100644 --- a/apps/sim/lib/credentials/google-workload-identity.server.ts +++ b/apps/sim/lib/credentials/google-workload-identity.server.ts @@ -28,7 +28,7 @@ import { bindWorkloadIdentitySubject, newWorkloadIdentitySubject, type WorkloadIdentityCredentialOwner, - WorkloadIdentitySubjectError, + WorkloadIdentityNotConfiguredError, } from '@/lib/credentials/workload-identity/subject' /** @@ -326,20 +326,13 @@ function bindSubject( subjectProof: string | undefined, owner: WorkloadIdentityCredentialOwner ): string { - try { - return bindWorkloadIdentitySubject({ - provider: 'google', - owner, - storedSubject: readStoredSubject(owner.storedSecret), - subject, - subjectProof, - }) - } catch (error) { - if (error instanceof WorkloadIdentitySubjectError) { - throw new GoogleWorkloadIdentityError(error.message) - } - throw error - } + return bindWorkloadIdentitySubject({ + provider: 'google', + owner, + storedSubject: readStoredSubject(owner.storedSecret), + subject, + subjectProof, + }) } export interface GoogleWorkloadIdentitySaveResult { @@ -367,9 +360,7 @@ export async function verifyAndEncryptGoogleWorkloadIdentityCredential( ) if (!parsed.success) throw new GoogleWorkloadIdentityError(parsed.message) if (!(await isWorkloadIdentityConfigured())) { - throw new GoogleWorkloadIdentityError( - 'Keyless connections are not available on this deployment. Use a JSON key.' - ) + throw new WorkloadIdentityNotConfiguredError('Use a JSON key.') } const submitted = parsed.data const config: GoogleWorkloadIdentityConfig = { 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 239bb95c64f..63d45187e22 100644 --- a/apps/sim/lib/credentials/microsoft-app-service-account.server.ts +++ b/apps/sim/lib/credentials/microsoft-app-service-account.server.ts @@ -24,7 +24,7 @@ import { bindWorkloadIdentitySubject, newWorkloadIdentitySubject, type WorkloadIdentityCredentialOwner, - WorkloadIdentitySubjectError, + WorkloadIdentityNotConfiguredError, } from '@/lib/credentials/workload-identity/subject' import { normalizeMicrosoftDataverseEnvironmentUrl } from '@/lib/oauth/microsoft-dataverse' import { MICROSOFT_APP_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' @@ -508,20 +508,13 @@ function bindSubject( subjectProof: string | undefined, owner: WorkloadIdentityCredentialOwner ): string { - try { - return bindWorkloadIdentitySubject({ - provider: 'microsoft', - owner, - storedSubject: readStoredSubject(owner.storedSecret), - subject, - subjectProof, - }) - } catch (error) { - if (error instanceof WorkloadIdentitySubjectError) { - throw new MicrosoftAppCredentialError(error.message) - } - throw error - } + return bindWorkloadIdentitySubject({ + provider: 'microsoft', + owner, + storedSubject: readStoredSubject(owner.storedSecret), + subject, + subjectProof, + }) } export interface MicrosoftAppCredentialSaveResult { @@ -554,9 +547,7 @@ export async function verifyAndEncryptMicrosoftAppCredential( let config: MicrosoftAppConfig if (submitted.authType === 'federated') { if (!(await isWorkloadIdentityConfigured())) { - throw new MicrosoftAppCredentialError( - 'Keyless connections are not available on this deployment. Use a client secret or certificate.' - ) + throw new WorkloadIdentityNotConfiguredError('Use a client secret or certificate.') } const { subject, subjectProof, ...rest } = submitted config = { ...rest, subject: bindSubject(subject, subjectProof, owner) } diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts index 2c42414f9fd..3efb824c52d 100644 --- a/apps/sim/lib/credentials/orchestration/credential-create.ts +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -31,7 +31,7 @@ import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { assertWorkloadIdentitySubjectUnclaimed, - WorkloadIdentitySubjectError, + KeylessCredentialRefusedError, } from '@/lib/credentials/workload-identity/subject' import { getServiceConfigByProviderId } from '@/lib/oauth' import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' @@ -121,6 +121,8 @@ export interface PerformCreateCredentialResult { created?: boolean /** Verified provider identity metadata for the application audit projection. */ auditMetadata?: Record + /** A refused keyless credential, carried unchanged so callers rethrow it with its detail code. */ + keylessRefusal?: KeylessCredentialRefusedError } interface ExistingCredentialSourceParams extends ResourceOwner { @@ -228,6 +230,16 @@ function failure( return { success: false, error, errorCode, ...extra } } +/** The failure result for a refused keyless credential: its class and code travel with it. */ +export function keylessFailure(refusal: KeylessCredentialRefusedError) { + return { + success: false as const, + error: refusal.message, + errorCode: refusal.code === 'conflict' ? ('conflict' as const) : ('validation' as const), + keylessRefusal: refusal, + } +} + export type CreateCredentialRecordParams = Omit & ResourceOwner @@ -343,6 +355,7 @@ export async function createCredentialRecord( resolvedWorkloadIdentitySubject = secret.newWorkloadIdentitySubject Object.assign(extraAuditMetadata, secret.auditMetadata) } catch (error) { + if (error instanceof KeylessCredentialRefusedError) return keylessFailure(error) if (error instanceof ServiceAccountSecretError) { return failure(error.message, 'validation') } @@ -530,8 +543,7 @@ export async function createCredentialRecord( scope, }) } catch (error) { - if (error instanceof WorkloadIdentitySubjectError) - return failure(error.message, 'conflict') + if (error instanceof KeylessCredentialRefusedError) return keylessFailure(error) throw error } } diff --git a/apps/sim/lib/credentials/orchestration/index.test.ts b/apps/sim/lib/credentials/orchestration/index.test.ts index fb875fd5a68..96d24024500 100644 --- a/apps/sim/lib/credentials/orchestration/index.test.ts +++ b/apps/sim/lib/credentials/orchestration/index.test.ts @@ -24,7 +24,9 @@ const { mockGetClientCredentialAccountDescriptor, mockDeleteConnectionCredential, mockDeleteOrphanedOAuthAccount, + mockAssertReconnectedSubjectUnclaimed, } = vi.hoisted(() => ({ + mockAssertReconnectedSubjectUnclaimed: vi.fn(), mockVerifyAndBuildServiceAccountSecret: vi.fn(), mockIsClientCredentialAccountProviderId: vi.fn(() => false), // Only a descriptor carrying `defaultAuthMethod` is multi-grant; single-grant @@ -51,6 +53,12 @@ vi.mock('@/lib/credentials/service-account-secret', () => ({ verifyAndBuildServiceAccountSecret: mockVerifyAndBuildServiceAccountSecret, ServiceAccountSecretError: class ServiceAccountSecretError extends Error {}, })) +vi.mock('@/lib/credentials/workload-identity/subject', async (importOriginal) => { + const actual = + await importOriginal() + mockAssertReconnectedSubjectUnclaimed.mockImplementation(actual.assertReconnectedSubjectUnclaimed) + return { ...actual, assertReconnectedSubjectUnclaimed: mockAssertReconnectedSubjectUnclaimed } +}) vi.mock('@/lib/credentials/client-credential-accounts/descriptors', () => ({ CLIENT_CREDENTIAL_ACCOUNT_REQUIRED_FIELDS: {}, isClientCredentialAccountProviderId: mockIsClientCredentialAccountProviderId, @@ -406,6 +414,62 @@ describe('performUpdateCredential — service-account secret rotation', () => { expect(mockRecordAudit).not.toHaveBeenCalled() }) + it('reports a refused keyless rebuild with its stable code', async () => { + mockCredential() + mockStoredBlob({ type: 'service_account', client_email: OLD_EMAIL }) + const { WorkloadIdentityNotConfiguredError } = await import( + '@/lib/credentials/workload-identity/subject' + ) + const refusal = new WorkloadIdentityNotConfiguredError() + mockVerifyAndBuildServiceAccountSecret.mockRejectedValue(refusal) + + const result = await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + serviceAccountJson: '{}', + }) + + expect(result).toMatchObject({ + success: false, + errorCode: 'validation', + keylessRefusal: refusal, + }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('reports a reconnect onto a subject another credential holds with the create code', async () => { + mockCredential() + mockStoredBlob({ type: 'google_workload_identity_v1', config: { subject: 'sim:old' } }) + mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ + providerId: 'google-service-account', + encryptedServiceAccountKey: 'rotated-cipher', + displayName: OLD_EMAIL, + auditMetadata: {}, + principal: null, + }) + const { WorkloadIdentitySubjectError } = await import( + '@/lib/credentials/workload-identity/subject' + ) + const refusal = new WorkloadIdentitySubjectError( + 'Another credential already uses this trust setup.', + 'WORKLOAD_IDENTITY_SUBJECT_IN_USE' + ) + mockAssertReconnectedSubjectUnclaimed.mockRejectedValueOnce(refusal) + + const result = await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + serviceAccountJson: '{"authType":"workload_identity"}', + }) + + expect(result).toMatchObject({ + success: false, + errorCode: 'conflict', + keylessRefusal: refusal, + }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + it('conceals managed OAuth credentials from the ordinary update path', async () => { mockCredential({ type: 'managed_oauth' }) @@ -579,6 +643,32 @@ describe('createServiceAccountCredential', () => { expect(mockGetCredentialActorContext).toHaveBeenCalledWith('credential-1', 'user-1', {}) }) + it('carries a refused keyless credential’s code into the create result', async () => { + const { WorkloadIdentitySubjectError } = await import( + '@/lib/credentials/workload-identity/subject' + ) + const refusal = new WorkloadIdentitySubjectError( + 'This trust setup has expired.', + 'WORKLOAD_IDENTITY_PROOF_EXPIRED' + ) + mockVerifyAndBuildServiceAccountSecret.mockRejectedValue(refusal) + + const result = await createServiceAccountCredential({ + workspaceId: 'workspace-1', + userId: 'user-1', + providerId: 'aws-service-account', + displayName: 'AWS (keyless)', + serviceAccountJson: '{"authType":"oidc_role"}', + }) + + expect(result).toMatchObject({ + success: false, + errorCode: 'validation', + keylessRefusal: refusal, + }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + it('returns an accessible credential for an exact non-token secret replay', async () => { const existingCredential = { id: 'credential-1', diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index 53e71d79ba1..497b7165009 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -39,6 +39,7 @@ import { } from '@/lib/credentials/environment' import { GOOGLE_WORKLOAD_IDENTITY_SECRET_TYPE } from '@/lib/credentials/google-workload-identity' import { OciCredentialVerificationError } from '@/lib/credentials/oci-api-key-service-account.server' +import { keylessFailure } from '@/lib/credentials/orchestration/credential-create' import type { AtlassianProduct, ServiceAccountFieldId, @@ -51,7 +52,7 @@ import { SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/token-s import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { assertReconnectedSubjectUnclaimed, - WorkloadIdentitySubjectError, + KeylessCredentialRefusedError, } from '@/lib/credentials/workload-identity/subject' import { invalidateEffectiveDecryptedEnvCache } from '@/lib/environment/utils' import { findSlackSearchInstallation } from '@/lib/knowledge/application/slack-search/repository' @@ -241,6 +242,8 @@ export interface PerformCredentialResult { errorCode?: CredentialOrchestrationErrorCode /** Provider-specific code (e.g. Atlassian `invalid_credentials`) for client message mapping. */ providerErrorCode?: string + /** A refused keyless credential, carried unchanged so callers rethrow it with its detail code. */ + keylessRefusal?: KeylessCredentialRefusedError workspaceId?: string updatedFields?: string[] previousDisplayName?: string @@ -486,6 +489,7 @@ export async function updateCredentialRecord( } } } catch (error) { + if (error instanceof KeylessCredentialRefusedError) return keylessFailure(error) if (error instanceof ServiceAccountSecretError) { return { success: false, error: error.message, errorCode: 'validation' } } @@ -547,9 +551,7 @@ export async function updateCredentialRecord( await tx.update(credential).set(updates).where(eq(credential.id, params.credentialId)) }) } catch (error) { - if (error instanceof WorkloadIdentitySubjectError) { - return { success: false, error: error.message, errorCode: 'conflict' } - } + if (error instanceof KeylessCredentialRefusedError) return keylessFailure(error) throw error } } else { diff --git a/apps/sim/lib/credentials/service-account-config.ts b/apps/sim/lib/credentials/service-account-config.ts index 9b11582cb9e..61eb3904787 100644 --- a/apps/sim/lib/credentials/service-account-config.ts +++ b/apps/sim/lib/credentials/service-account-config.ts @@ -1,7 +1,17 @@ +import { getErrorMessage } from '@sim/utils/errors' import type { z } from 'zod' const MAX_CONFIG_BYTES = 64 * 1024 +/** + * A failure's message without its closing punctuation, so it can sit inside a + * sentence that adds the remedy — a provider message that already ends in a + * period would otherwise print two. + */ +export function failureClause(error: unknown): string { + return getErrorMessage(error, 'Unknown error').replace(/[.\s]+$/, '') +} + /** * Parses a service-account configuration submitted as JSON (the connect modal's * `serviceAccountJson`) against its provider schema. Returns the first diff --git a/apps/sim/lib/credentials/service-account-fields.ts b/apps/sim/lib/credentials/service-account-fields.ts index ea3ae647725..6070efddd76 100644 --- a/apps/sim/lib/credentials/service-account-fields.ts +++ b/apps/sim/lib/credentials/service-account-fields.ts @@ -1,8 +1,6 @@ import { CLIENT_CREDENTIAL_ACCOUNT_REQUIRED_FIELDS } from '@/lib/credentials/client-credential-accounts/descriptors' -import { - SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID, - TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS, -} from '@/lib/credentials/token-service-accounts/descriptors' +import { TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS } from '@/lib/credentials/token-service-accounts/descriptors' +import { isWorkloadIdentityServiceAccountProviderId } from '@/lib/credentials/workload-identity/audiences' import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, AWS_SERVICE_ACCOUNT_PROVIDER_ID, @@ -79,12 +77,22 @@ export const FALLBACK_SERVICE_ACCOUNT_REQUIRED_FIELDS: readonly ServiceAccountFi 'serviceAccountJson', ] +/** Every keyless credential is one JSON configuration carrying its subject and proof. */ +const KEYLESS_REQUIRED_FIELDS: readonly ServiceAccountFieldId[] = ['serviceAccountJson'] + /** - * Token providers that also accept a keyless JSON configuration in place of - * their token fields. The keyless builder validates the configuration itself. + * The create-body fields a keyless (workload identity) credential sends in + * place of the provider's required fields, or `undefined` for a provider with + * no keyless mode. For most keyless providers this is their only field; for + * Snowflake it replaces the token fields. The keyless builder validates the + * configuration itself. */ -const KEYLESS_ALTERNATIVE_REQUIRED_FIELDS: Record = { - [SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID]: ['serviceAccountJson'], +function getKeylessServiceAccountFields( + providerId: string | null | undefined +): readonly ServiceAccountFieldId[] | undefined { + return isWorkloadIdentityServiceAccountProviderId(providerId) + ? KEYLESS_REQUIRED_FIELDS + : undefined } function getServiceAccountRequiredFields( @@ -104,10 +112,7 @@ export function getMissingServiceAccountFields( providerId: string | null | undefined, provided: Partial> ): ServiceAccountFieldId[] { - const keyless = - providerId && Object.hasOwn(KEYLESS_ALTERNATIVE_REQUIRED_FIELDS, providerId) - ? KEYLESS_ALTERNATIVE_REQUIRED_FIELDS[providerId] - : undefined + const keyless = getKeylessServiceAccountFields(providerId) if (keyless?.every((field) => provided[field])) return [] return getServiceAccountRequiredFields(providerId).filter((field) => !provided[field]) } diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts index d2e9f241d0a..4cdd2f13815 100644 --- a/apps/sim/lib/credentials/service-account-secret.test.ts +++ b/apps/sim/lib/credentials/service-account-secret.test.ts @@ -380,6 +380,99 @@ describe('verifyAndBuildServiceAccountSecret', () => { expect(result.principal).toBeNull() }) + it.each([ + [ + 'aws-service-account', + { authType: 'oidc_role', roleArn: 'arn:aws:iam::123456789012:role/SimWorkflows' }, + 'Use access keys.', + ], + [ + 'databricks-service-account', + { + authType: 'oidc_federation', + host: 'dbc-abc123.cloud.databricks.com', + clientId: '11111111-2222-3333-4444-555555555555', + }, + 'Use a personal access token or a service principal OAuth secret.', + ], + [ + 'snowflake-service-account', + { authType: 'workload_identity', host: 'myorg-myaccount.snowflakecomputing.com' }, + 'Use a programmatic access token.', + ], + [ + 'microsoft-app-service-account', + { + authType: 'federated', + tenantId: '11111111-2222-3333-4444-555555555555', + clientId: '66666666-7777-8888-9999-000000000000', + }, + 'Use a client secret or certificate.', + ], + [ + 'google-service-account', + { + authType: 'workload_identity', + projectNumber: '123456789012', + poolId: 'sim-pool', + providerId: 'sim-provider', + serviceAccountEmail: 'sim-workflows@my-project.iam.gserviceaccount.com', + }, + 'Use a JSON key.', + ], + ])( + 'refuses a keyless %s credential where this deployment cannot sign', + async (providerId, configuration, remedy) => { + await expect( + verifyAndBuildServiceAccountSecret(providerId, { + serviceAccountJson: JSON.stringify(configuration), + owner: { workspaceId: 'ws-1', actorUserId: 'user-1' }, + }) + ).rejects.toMatchObject({ + code: 'validation', + detailCode: 'WORKLOAD_IDENTITY_NOT_CONFIGURED', + message: `Keyless connections are not available on this deployment. ${remedy}`, + }) + } + ) + + it.each([ + [ + 'aws-service-account', + { authType: 'oidc_role', roleArn: 'arn:aws:iam::123456789012:role/SimWorkflows' }, + { type: 'aws_credential_v1' }, + ], + [ + 'databricks-service-account', + { + authType: 'oidc_federation', + host: 'dbc-abc123.cloud.databricks.com', + clientId: '11111111-2222-3333-4444-555555555555', + }, + { type: 'databricks_credential_v1' }, + ], + ])( + 'refuses reconnecting a keyless %s credential where this deployment cannot sign', + async (providerId, configuration, secret) => { + await expect( + verifyAndBuildServiceAccountSecret(providerId, { + serviceAccountJson: JSON.stringify(configuration), + owner: { + workspaceId: 'ws-1', + actorUserId: 'user-1', + storedSecret: { + ...secret, + config: { + ...configuration, + subject: 'sim:workspace:ws-1:credential:abcdef0123456789', + }, + }, + }, + }) + ).rejects.toMatchObject({ detailCode: 'WORKLOAD_IDENTITY_NOT_CONFIGURED' }) + } + ) + it('throws when client-credential required fields are missing, without minting', async () => { await expect( verifyAndBuildServiceAccountSecret('zoom-service-account', { diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index 0faaf52a32c..dcd62f7ea20 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -58,7 +58,10 @@ import { getTokenServiceAccountValidator, type TokenServiceAccountSecretBlob, } from '@/lib/credentials/token-service-accounts/server' -import type { WorkloadIdentityCredentialOwner } from '@/lib/credentials/workload-identity/subject' +import { + KeylessCredentialRefusedError, + type WorkloadIdentityCredentialOwner, +} from '@/lib/credentials/workload-identity/subject' import { GITHUB_INSTALLATION_PROVIDER_ID } from '@/lib/oauth/github-installation-types' import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, @@ -283,6 +286,7 @@ async function buildGoogleWorkloadIdentitySecret( try { result = await verifyAndEncryptGoogleWorkloadIdentityCredential(serviceAccountJson, owner) } catch (error) { + if (error instanceof KeylessCredentialRefusedError) throw error if (error instanceof GoogleWorkloadIdentityError) { throw new ServiceAccountSecretError(error.message) } @@ -315,6 +319,7 @@ async function buildAwsServiceAccountSecret( try { result = await verifyAndEncryptAwsCredential(fields.serviceAccountJson, fields.owner) } catch (error) { + if (error instanceof KeylessCredentialRefusedError) throw error if (error instanceof AwsCredentialError) throw new ServiceAccountSecretError(error.message) throw new ServiceAccountSecretError( `Could not verify the AWS credential: ${getErrorMessage(error, 'Unknown error')}` @@ -341,6 +346,7 @@ async function buildDatabricksServiceAccountSecret( try { result = await verifyAndEncryptDatabricksCredential(fields.serviceAccountJson, fields.owner) } catch (error) { + if (error instanceof KeylessCredentialRefusedError) throw error if (error instanceof DatabricksCredentialError) { throw new ServiceAccountSecretError(error.message) } @@ -370,6 +376,7 @@ async function buildMicrosoftAppServiceAccountSecret( try { result = await verifyAndEncryptMicrosoftAppCredential(fields.serviceAccountJson, fields.owner) } catch (error) { + if (error instanceof KeylessCredentialRefusedError) throw error if (error instanceof MicrosoftAppCredentialError) { throw new ServiceAccountSecretError(error.message) } diff --git a/apps/sim/lib/credentials/snowflake-service-account.server.ts b/apps/sim/lib/credentials/snowflake-service-account.server.ts index ab3527283e7..deb268b19df 100644 --- a/apps/sim/lib/credentials/snowflake-service-account.server.ts +++ b/apps/sim/lib/credentials/snowflake-service-account.server.ts @@ -20,7 +20,7 @@ import { import { bindWorkloadIdentitySubject, type WorkloadIdentityCredentialOwner, - WorkloadIdentitySubjectError, + WorkloadIdentityNotConfiguredError, } from '@/lib/credentials/workload-identity/subject' import { normalizeSnowflakeHost, @@ -153,26 +153,16 @@ export async function verifyAndEncryptSnowflakeKeylessCredential( ) if (!parsed.success) throw new SnowflakeCredentialError(parsed.message) if (!(await isWorkloadIdentityConfigured())) { - throw new SnowflakeCredentialError( - 'Keyless connections are not available on this deployment. Use a programmatic access token.' - ) + throw new WorkloadIdentityNotConfiguredError('Use a programmatic access token.') } const { host, subject: submittedSubject, subjectProof } = parsed.data - let subject: string - try { - subject = bindWorkloadIdentitySubject({ - provider: 'snowflake', - owner, - storedSubject: readStoredSubject(owner.storedSecret), - subject: submittedSubject, - subjectProof, - }) - } catch (error) { - if (error instanceof WorkloadIdentitySubjectError) { - throw new SnowflakeCredentialError(error.message) - } - throw error - } + const subject = bindWorkloadIdentitySubject({ + provider: 'snowflake', + owner, + storedSubject: readStoredSubject(owner.storedSecret), + subject: submittedSubject, + subjectProof, + }) const validation = await validateSnowflakeWorkloadIdentity({ domain: host, diff --git a/apps/sim/lib/credentials/workload-identity/audiences.ts b/apps/sim/lib/credentials/workload-identity/audiences.ts index 561f9f99e22..3f5e7f0daaa 100644 --- a/apps/sim/lib/credentials/workload-identity/audiences.ts +++ b/apps/sim/lib/credentials/workload-identity/audiences.ts @@ -1,3 +1,11 @@ +import { SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/token-service-accounts/descriptors' +import { + AWS_SERVICE_ACCOUNT_PROVIDER_ID, + DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID, + GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + MICROSOFT_APP_SERVICE_ACCOUNT_PROVIDER_ID, +} from '@/lib/oauth/types' + /** Relying parties that trust Sim's workload-identity issuer. */ export const WORKLOAD_IDENTITY_PROVIDERS = [ 'aws', @@ -9,6 +17,87 @@ export const WORKLOAD_IDENTITY_PROVIDERS = [ export type WorkloadIdentityProvider = (typeof WORKLOAD_IDENTITY_PROVIDERS)[number] +/** The service-account provider ID each relying party's keyless credential is created under. */ +const WORKLOAD_IDENTITY_SERVICE_ACCOUNT_PROVIDER_IDS = { + aws: AWS_SERVICE_ACCOUNT_PROVIDER_ID, + databricks: DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID, + snowflake: SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID, + microsoft: MICROSOFT_APP_SERVICE_ACCOUNT_PROVIDER_ID, + google: GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, +} as const satisfies Record + +export type WorkloadIdentityServiceAccountProviderId = + (typeof WORKLOAD_IDENTITY_SERVICE_ACCOUNT_PROVIDER_IDS)[WorkloadIdentityProvider] + +/** + * What each provider's keyless `serviceAccountJson` holds besides the issued + * `subject` and `subjectProof`: its `authType`, and the values the customer + * supplies from their own cloud setup. + */ +export const WORKLOAD_IDENTITY_KEYLESS_CONFIGS: Record< + WorkloadIdentityProvider, + { + authType: string + requiredFields: readonly string[] + optionalFields: readonly string[] + /** Where a keyless credential works, when that is narrower than the provider. */ + scope?: string + } +> = { + aws: { + authType: 'oidc_role', + requiredFields: ['roleArn'], + optionalFields: ['stsRegion', 'sessionDurationSeconds'], + }, + databricks: { + authType: 'oidc_federation', + requiredFields: ['host', 'clientId'], + optionalFields: [], + }, + snowflake: { authType: 'workload_identity', requiredFields: ['host'], optionalFields: [] }, + microsoft: { + authType: 'federated', + requiredFields: ['tenantId', 'clientId'], + optionalFields: ['environmentUrl'], + }, + google: { + authType: 'workload_identity', + requiredFields: ['projectNumber', 'poolId', 'providerId', 'serviceAccountEmail'], + optionalFields: [], + scope: + 'Keyless works only for Google Cloud services such as BigQuery and Vertex AI, not Google Workspace (Gmail, Drive, Sheets, Calendar, and the like), which need a JSON key with domain-wide delegation.', + }, +} + +/** Every service-account provider ID that can hold a keyless credential. */ +export const WORKLOAD_IDENTITY_SERVICE_ACCOUNT_PROVIDER_ID_LIST = WORKLOAD_IDENTITY_PROVIDERS.map( + (provider) => WORKLOAD_IDENTITY_SERVICE_ACCOUNT_PROVIDER_IDS[provider] +) as [WorkloadIdentityServiceAccountProviderId, ...WorkloadIdentityServiceAccountProviderId[]] + +const PROVIDER_BY_SERVICE_ACCOUNT_PROVIDER_ID: ReadonlyMap = + new Map( + WORKLOAD_IDENTITY_PROVIDERS.map((provider) => [ + WORKLOAD_IDENTITY_SERVICE_ACCOUNT_PROVIDER_IDS[provider], + provider, + ]) + ) + +/** Whether a service-account provider can hold a keyless credential. */ +export function isWorkloadIdentityServiceAccountProviderId( + providerId: string | null | undefined +): providerId is WorkloadIdentityServiceAccountProviderId { + return providerId != null && PROVIDER_BY_SERVICE_ACCOUNT_PROVIDER_ID.has(providerId) +} + +/** The relying party a keyless service-account provider ID trusts Sim through. */ +export function workloadIdentityProviderFor( + providerId: WorkloadIdentityServiceAccountProviderId +): WorkloadIdentityProvider { + const provider = PROVIDER_BY_SERVICE_ACCOUNT_PROVIDER_ID.get(providerId) + if (!provider) throw new Error(`No workload identity provider for ${providerId}`) + return provider +} + /** * Relying parties that expect one audience for every credential. Google expects * each credential's own workload identity provider as its audience (see diff --git a/apps/sim/lib/credentials/workload-identity/issuer.test.ts b/apps/sim/lib/credentials/workload-identity/issuer.test.ts index 9428fb87ed4..88da9a3419a 100644 --- a/apps/sim/lib/credentials/workload-identity/issuer.test.ts +++ b/apps/sim/lib/credentials/workload-identity/issuer.test.ts @@ -180,7 +180,7 @@ describe('workload identity issuer', () => { audience: 'sts.amazonaws.com', owner: { workspaceId: 'ws-1' }, }) - ).rejects.toBeInstanceOf(issuer.WorkloadIdentityNotConfiguredError) + ).rejects.toMatchObject({ detailCode: 'WORKLOAD_IDENTITY_NOT_CONFIGURED' }) }) it('uses the pinned issuer URL when one is configured', async () => { diff --git a/apps/sim/lib/credentials/workload-identity/issuer.ts b/apps/sim/lib/credentials/workload-identity/issuer.ts index d658467be05..135a36db6a1 100644 --- a/apps/sim/lib/credentials/workload-identity/issuer.ts +++ b/apps/sim/lib/credentials/workload-identity/issuer.ts @@ -4,7 +4,10 @@ import { generateId } from '@sim/utils/id' import { calculateJwkThumbprint, type JWK, SignJWT } from 'jose' import { env } from '@/lib/core/config/env' import { getBaseUrl } from '@/lib/core/utils/urls' -import { assertWorkloadIdentitySubjectOwner } from '@/lib/credentials/workload-identity/subject' +import { + assertWorkloadIdentitySubjectOwner, + WorkloadIdentityNotConfiguredError, +} from '@/lib/credentials/workload-identity/subject' /** * Sim's workload-identity issuer: a minimal OpenID Connect token issuer that @@ -29,13 +32,6 @@ export const MAX_WORKLOAD_IDENTITY_TOKEN_TTL_SECONDS = 3600 const PRIVATE_JWK_FIELDS = ['d', 'p', 'q', 'dp', 'dq', 'qi'] as const -export class WorkloadIdentityNotConfiguredError extends Error { - constructor() { - super('Workload identity is not configured on this deployment') - this.name = 'WorkloadIdentityNotConfiguredError' - } -} - interface SigningMaterial { privateKey: KeyObject kid: string diff --git a/apps/sim/lib/credentials/workload-identity/subject.test.ts b/apps/sim/lib/credentials/workload-identity/subject.test.ts index bf194a9460a..079ebf8fbff 100644 --- a/apps/sim/lib/credentials/workload-identity/subject.test.ts +++ b/apps/sim/lib/credentials/workload-identity/subject.test.ts @@ -85,7 +85,7 @@ describe('workload identity subjects', () => { ).toThrow(WorkloadIdentitySubjectError) }) - it('refuses an expired proof', () => { + it('refuses an expired proof with a code that says to request a new subject', () => { const grant = issueWorkloadIdentitySubject('aws', owner, 'user-1', now) const later = now + 24 * 60 * 60 * 1000 + 1 @@ -98,7 +98,7 @@ describe('workload identity subjects', () => { grant.subjectProof, later ) - ).toThrow(WorkloadIdentitySubjectError) + ).toThrow(expect.objectContaining({ detailCode: 'WORKLOAD_IDENTITY_PROOF_EXPIRED' })) }) it('refuses a tampered or malformed proof', () => { @@ -113,7 +113,7 @@ describe('workload identity subjects', () => { ]) { expect(() => verifyWorkloadIdentitySubject('aws', owner, 'user-1', grant.subject, proof, now) - ).toThrow(WorkloadIdentitySubjectError) + ).toThrow(expect.objectContaining({ detailCode: 'WORKLOAD_IDENTITY_PROOF_INVALID' })) } }) }) diff --git a/apps/sim/lib/credentials/workload-identity/subject.ts b/apps/sim/lib/credentials/workload-identity/subject.ts index 3d2db08e76e..369306fec24 100644 --- a/apps/sim/lib/credentials/workload-identity/subject.ts +++ b/apps/sim/lib/credentials/workload-identity/subject.ts @@ -6,23 +6,18 @@ import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull, ne } from 'drizzle-orm' import { z } from 'zod' import { env } from '@/lib/core/config/env' +import { OrchestrationError } from '@/lib/core/orchestration/types' import type { ResourceScope } from '@/lib/core/resource-scope' import { resourceScopeCondition } from '@/lib/core/resource-scope.server' import { decryptSecret } from '@/lib/core/security/encryption' +import { TOKEN_SERVICE_ACCOUNT_SECRET_TYPE } from '@/lib/credentials/token-service-accounts/descriptors' import { - SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID, - TOKEN_SERVICE_ACCOUNT_SECRET_TYPE, -} from '@/lib/credentials/token-service-accounts/descriptors' -import type { WorkloadIdentityProvider } from '@/lib/credentials/workload-identity/audiences' + WORKLOAD_IDENTITY_SERVICE_ACCOUNT_PROVIDER_ID_LIST, + type WorkloadIdentityProvider, +} from '@/lib/credentials/workload-identity/audiences' import type { WorkloadIdentityOwner } from '@/lib/credentials/workload-identity/issuer' import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks' import type { DbTransaction } from '@/lib/db/types' -import { - AWS_SERVICE_ACCOUNT_PROVIDER_ID, - DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID, - GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, - MICROSOFT_APP_SERVICE_ACCOUNT_PROVIDER_ID, -} from '@/lib/oauth/types' const logger = createLogger('WorkloadIdentitySubject') @@ -51,13 +46,72 @@ export interface WorkloadIdentityCredentialOwner extends WorkloadIdentityOwner { storedSecret?: Record | null } -export class WorkloadIdentitySubjectError extends Error { - constructor(message: string) { - super(message) +/** Why a keyless credential was refused; every surface reports it as `error.details.code`. */ +type KeylessCredentialErrorCode = + /** This deployment has no workload-identity signing key; use a key-based credential. */ + | 'WORKLOAD_IDENTITY_NOT_CONFIGURED' + /** The proof is more than a day old; request a new subject. */ + | 'WORKLOAD_IDENTITY_PROOF_EXPIRED' + /** The proof was issued to another user, workspace, or provider, or was altered. */ + | 'WORKLOAD_IDENTITY_PROOF_INVALID' + /** No subject was submitted and the credential holds none to keep. */ + | 'WORKLOAD_IDENTITY_SUBJECT_REQUIRED' + /** Another live credential already holds the subject; request a new one. */ + | 'WORKLOAD_IDENTITY_SUBJECT_IN_USE' + /** A sibling credential could not be read, so the subject's uniqueness is unknown. */ + | 'WORKLOAD_IDENTITY_SUBJECT_UNVERIFIABLE' + +/** Refusals caused by another credential's state rather than by the request itself. */ +const KEYLESS_CONFLICT_CODES: ReadonlySet = new Set([ + 'WORKLOAD_IDENTITY_SUBJECT_IN_USE', + 'WORKLOAD_IDENTITY_SUBJECT_UNVERIFIABLE', +]) + +/** + * A keyless credential Sim refused. It travels as an orchestration failure, so + * every surface maps its status the usual way (`409` for a conflict with + * another credential, otherwise `400`) and adds `detailCode` as `details.code`. + */ +export class KeylessCredentialRefusedError extends OrchestrationError { + constructor( + message: string, + readonly detailCode: KeylessCredentialErrorCode + ) { + super(KEYLESS_CONFLICT_CODES.has(detailCode) ? 'conflict' : 'validation', message) + this.name = 'KeylessCredentialRefusedError' + } +} + +/** A submitted or stored subject that cannot be bound to the credential being saved. */ +export class WorkloadIdentitySubjectError extends KeylessCredentialRefusedError { + constructor( + message: string, + detailCode: Exclude< + KeylessCredentialErrorCode, + 'WORKLOAD_IDENTITY_NOT_CONFIGURED' + > = 'WORKLOAD_IDENTITY_PROOF_INVALID' + ) { + super(message, detailCode) this.name = 'WorkloadIdentitySubjectError' } } +/** + * This deployment has no usable signing key, so it cannot issue or verify + * keyless credentials. + * + * @param remedy The provider's key-based alternative, as one sentence. + */ +export class WorkloadIdentityNotConfiguredError extends KeylessCredentialRefusedError { + constructor(remedy = 'Use a key- or token-based credential.') { + super( + `Keyless connections are not available on this deployment. ${remedy}`, + 'WORKLOAD_IDENTITY_NOT_CONFIGURED' + ) + this.name = 'WorkloadIdentityNotConfiguredError' + } +} + function ownerSegment(owner: WorkloadIdentityOwner): string { if (owner.workspaceId && !owner.organizationId) return `workspace:${owner.workspaceId}` if (owner.organizationId && !owner.workspaceId) return `organization:${owner.organizationId}` @@ -151,7 +205,13 @@ export function verifyWorkloadIdentitySubject( if (separator <= 0) throw invalid() const expiresAt = Number(subjectProof.slice(0, separator)) const signature = subjectProof.slice(separator + 1) - if (!Number.isSafeInteger(expiresAt) || expiresAt <= now) throw invalid() + if (!Number.isSafeInteger(expiresAt)) throw invalid() + if (expiresAt <= now) { + throw new WorkloadIdentitySubjectError( + 'This trust setup has expired. Start the keyless connection again to get a new subject.', + 'WORKLOAD_IDENTITY_PROOF_EXPIRED' + ) + } if (!subject.startsWith(subjectPrefix(owner))) throw invalid() if (!safeCompare(signature, sign(provider, owner, userId, subject, expiresAt))) throw invalid() } @@ -175,24 +235,21 @@ export function bindWorkloadIdentitySubject(input: { const { provider, owner, storedSubject, subject, subjectProof } = input if (!subject || subject === storedSubject) { if (storedSubject) return storedSubject - throw new WorkloadIdentitySubjectError('Start a keyless connection to get a trust setup first.') + throw new WorkloadIdentitySubjectError( + 'Start a keyless connection to get a trust setup first.', + 'WORKLOAD_IDENTITY_SUBJECT_REQUIRED' + ) } if (!subjectProof) { - throw new WorkloadIdentitySubjectError('Start a keyless connection to get a trust setup first.') + throw new WorkloadIdentitySubjectError( + 'Start a keyless connection to get a trust setup first.', + 'WORKLOAD_IDENTITY_SUBJECT_REQUIRED' + ) } verifyWorkloadIdentitySubject(provider, owner, owner.actorUserId, subject, subjectProof) return subject } -/** Providers whose stored secret can hold a bound subject at `config.subject`. */ -const KEYLESS_PROVIDER_IDS = [ - AWS_SERVICE_ACCOUNT_PROVIDER_ID, - DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID, - SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID, - MICROSOFT_APP_SERVICE_ACCOUNT_PROVIDER_ID, - GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, -] - /** The part of a keyless provider's stored secret that can hold a bound subject. */ const storedSubjectSchema = z.object({ config: z.object({ subject: z.string().optional() }), @@ -246,7 +303,7 @@ async function assertNoOtherCredentialHolds( .where( and( resourceScopeCondition(credential, input.scope), - inArray(credential.providerId, KEYLESS_PROVIDER_IDS), + inArray(credential.providerId, WORKLOAD_IDENTITY_SERVICE_ACCOUNT_PROVIDER_ID_LIST), isNull(credential.revokedAt), input.credentialId ? ne(credential.id, input.credentialId) : undefined ) @@ -259,12 +316,14 @@ async function assertNoOtherCredentialHolds( credentialId: row.id, }) throw new WorkloadIdentitySubjectError( - `Sim could not read another keyless credential in ${scopeNoun(input.scope)}, so it cannot confirm this trust setup is unused. Ask an admin to reconnect or delete the broken keyless credential, then try again.` + `Sim could not read another keyless credential in ${scopeNoun(input.scope)}, so it cannot confirm this trust setup is unused. Ask an admin to reconnect or delete the broken keyless credential, then try again.`, + 'WORKLOAD_IDENTITY_SUBJECT_UNVERIFIABLE' ) } if (storedSubject === input.subject) { throw new WorkloadIdentitySubjectError( - `Another credential in ${scopeNoun(input.scope)} already uses this trust setup. Start the keyless connection again.` + `Another credential in ${scopeNoun(input.scope)} already uses this trust setup. Start the keyless connection again.`, + 'WORKLOAD_IDENTITY_SUBJECT_IN_USE' ) } } diff --git a/packages/sim-cli/src/commands/credentials.test.ts b/packages/sim-cli/src/commands/credentials.test.ts index 9d40f11cf0c..01f411a42f0 100644 --- a/packages/sim-cli/src/commands/credentials.test.ts +++ b/packages/sim-cli/src/commands/credentials.test.ts @@ -1,5 +1,6 @@ import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SimApiError } from '../http/client' import { buildGeneratedCommands } from '../runtime/build' import { attachCredentialCommands } from './credentials' @@ -188,3 +189,228 @@ describe('credentials update --name', () => { expect(mockRequest).not.toHaveBeenCalled() }) }) + +describe('keyless credentials', () => { + const subject = 'sim:workspace:ws_local:credential:abcdef0123456789' + const issued = { + issuer: 'https://sim.example.com/oidc/workload', + audience: 'snowflakecomputing.com', + subject, + subjectProof: '1781965031000.signature', + expiresAt: '2026-11-11T20:15:00.000Z', + keylessConfig: { + authType: 'workload_identity', + subject, + subjectProof: '1781965031000.signature', + }, + requiredConfigFields: ['host'], + optionalConfigFields: [], + } + const snowflake = { + type: 'service_account', + providerId: 'snowflake-service-account', + available: true, + requiresClientGeneratedCredentialId: false, + fields: [ + { id: 'domain', required: true }, + { id: 'apiToken', required: true, secret: true }, + { id: 'serviceAccountJson', required: false, secret: true }, + ], + keyless: { + authType: 'workload_identity', + requiredConfigFields: ['host'], + optionalConfigFields: [], + }, + } + const keylessConfig = { ...issued.keylessConfig, host: 'myorg-myaccount.snowflakecomputing.com' } + let log: ReturnType + + beforeEach(() => { + output.format = 'json' + mockRequest.mockReset() + log = vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + it('prints the issued subject as the API data, ready to feed into creation', async () => { + mockRequest.mockResolvedValue({ data: issued }) + + await program().parseAsync([ + 'node', + 'sim', + 'credentials', + 'keyless-subject', + 'snowflake-service-account', + ]) + + expect(mockRequest).toHaveBeenCalledWith('/api/v2/credentials/workload-identity/subjects', { + method: 'POST', + body: { workspaceId: 'ws_local', providerId: 'snowflake-service-account' }, + }) + expect(JSON.parse(String(log.mock.calls[0][0]))).toEqual(issued) + }) + + it('refuses a provider with no keyless mode before calling the API', async () => { + const root = program() + const keylessSubject = root.commands + .find((command) => command.name() === 'credentials') + ?.commands.find((command) => command.name() === 'keyless-subject') + // exitOverride is not inherited by added subcommands; set it where the argument is parsed. + keylessSubject?.exitOverride().configureOutput({ writeErr: () => {} }) + + await expect( + root.parseAsync(['node', 'sim', 'credentials', 'keyless-subject', 'zoom-service-account']) + ).rejects.toThrow( + "command-argument value 'zoom-service-account' is invalid for argument 'providerId'. Allowed choices are aws-service-account, databricks-service-account, snowflake-service-account, microsoft-app-service-account, google-service-account." + ) + expect(mockRequest).not.toHaveBeenCalled() + }) + + it('creates a keyless Snowflake credential from the completed keylessConfig alone', async () => { + mockRequest + .mockResolvedValueOnce({ data: [snowflake], nextCursor: null }) + .mockResolvedValueOnce({ data: { id: 'credential-1' } }) + + await program().parseAsync([ + 'node', + 'sim', + 'credentials', + 'create', + 'snowflake-service-account', + '--name', + 'Snowflake (keyless)', + '--credentials', + JSON.stringify(keylessConfig), + ]) + + const [, createRequest] = mockRequest.mock.calls[1] + expect(JSON.parse(createRequest.body.credentials)).toEqual({ + serviceAccountJson: JSON.stringify(keylessConfig), + }) + }) + + const configOnly = (providerId: string) => ({ + type: 'service_account', + providerId, + available: true, + requiresClientGeneratedCredentialId: false, + fields: [{ id: 'serviceAccountJson', required: true, secret: true }], + keyless: { authType: 'oidc_role', requiredConfigFields: ['roleArn'], optionalConfigFields: [] }, + }) + + async function createWith(provider: object, providerId: string, credentials: unknown) { + mockRequest + .mockResolvedValueOnce({ data: [provider], nextCursor: null }) + .mockResolvedValueOnce({ data: { id: 'credential-1' } }) + await program().parseAsync([ + 'node', + 'sim', + 'credentials', + 'create', + providerId, + '--name', + 'Credential', + '--credentials', + JSON.stringify(credentials), + ]) + const [, createRequest] = mockRequest.mock.calls[1] + return JSON.parse(createRequest.body.credentials) + } + + it.each([ + ['aws-service-account', { serviceAccountJson: '{"authType":"access_keys"}' }], + ['google-service-account', { serviceAccountJson: '{"type":"service_account"}' }], + ])( + 'sends %s fields that already name serviceAccountJson unchanged', + async (providerId, fields) => { + expect(await createWith(configOnly(providerId), providerId, fields)).toEqual(fields) + } + ) + + it('sends Snowflake token fields unchanged', async () => { + const fields = { domain: 'myorg-myaccount.snowflakecomputing.com', apiToken: 'pat' } + expect(await createWith(snowflake, 'snowflake-service-account', fields)).toEqual(fields) + }) + + it.each([ + [ + 'a keylessConfig carrying its subject and proof', + { authType: 'workload_identity', projectNumber: '1', subject, subjectProof: 'p' }, + ], + [ + 'an object with the catalog’s keyless authType', + { authType: 'oidc_role', roleArn: 'arn:aws:iam::123456789012:role/Sim' }, + ], + ])('wraps %s as serviceAccountJson', async (_label, configuration) => { + expect( + await createWith(configOnly('aws-service-account'), 'aws-service-account', configuration) + ).toEqual({ serviceAccountJson: JSON.stringify(configuration) }) + }) + + it('reads any other object as fields, so a raw key file is reported, not wrapped', async () => { + mockRequest.mockResolvedValueOnce({ + data: [configOnly('google-service-account')], + nextCursor: null, + }) + + await expect( + program().parseAsync([ + 'node', + 'sim', + 'credentials', + 'create', + 'google-service-account', + '--name', + 'Google', + '--credentials', + '{"type":"service_account","client_email":"sa@p.iam.gserviceaccount.com"}', + ]) + ).rejects.toThrow('unsupported field "type" for google-service-account') + }) + + it('still reports a misspelled Snowflake field instead of wrapping it', async () => { + mockRequest.mockResolvedValueOnce({ data: [snowflake], nextCursor: null }) + + await expect( + program().parseAsync([ + 'node', + 'sim', + 'credentials', + 'create', + 'snowflake-service-account', + '--name', + 'Snowflake', + '--credentials', + '{"Domain":"myorg-myaccount.snowflakecomputing.com"}', + ]) + ).rejects.toThrow('unsupported field "Domain" for snowflake-service-account') + expect(mockRequest).toHaveBeenCalledTimes(1) + }) + + it('names the command that issues a new subject when a proof is refused', async () => { + mockRequest + .mockResolvedValueOnce({ data: [snowflake], nextCursor: null }) + .mockRejectedValueOnce( + new SimApiError('This trust setup has expired.', 400, 'BAD_REQUEST', { + code: 'WORKLOAD_IDENTITY_PROOF_EXPIRED', + }) + ) + + const failure = program().parseAsync([ + 'node', + 'sim', + 'credentials', + 'create', + 'snowflake-service-account', + '--name', + 'Snowflake (keyless)', + '--credentials', + JSON.stringify(keylessConfig), + ]) + + await expect(failure).rejects.toMatchObject({ + message: expect.stringContaining('sim credentials keyless-subject snowflake-service-account'), + code: 'BAD_REQUEST', + details: { code: 'WORKLOAD_IDENTITY_PROOF_EXPIRED' }, + }) + }) +}) diff --git a/packages/sim-cli/src/commands/credentials.ts b/packages/sim-cli/src/commands/credentials.ts index bbd54d19364..46f6960dbba 100644 --- a/packages/sim-cli/src/commands/credentials.ts +++ b/packages/sim-cli/src/commands/credentials.ts @@ -1,12 +1,14 @@ -import { type Command, Option } from 'commander' +import { Argument, type Command, Option } from 'commander' import type { CommandSpec } from '../contract/types' import { type CreateCredentialConnectionResponse, + type CreateCredentialWorkloadIdentitySubjectBody, + type CreateCredentialWorkloadIdentitySubjectResponse, type CreateServiceAccountCredentialResponse, type ListCredentialProvidersResponse, V2_OPERATIONS, } from '../generated/v2-api' -import { SimApiError } from '../http/client' +import { refusalCode, SimApiError } from '../http/client' import { describeOperation } from '../runtime/build' import { apiCommand, type Connection } from '../runtime/called-operations' import { coerce } from '../runtime/request' @@ -29,6 +31,52 @@ const SERVICE_ACCOUNT_RESULT: CommandSpec = { ], } +const KEYLESS_SUBJECT_RESULT: CommandSpec = { + fields: [ + { header: 'issuer' }, + { header: 'audience' }, + { header: 'subject' }, + { header: 'subject proof', path: 'subjectProof' }, + { header: 'expires', path: 'expiresAt', format: 'timestamp' }, + { header: 'auth type', path: 'keylessConfig.authType' }, + { header: 'required fields', path: 'requiredConfigFields' }, + { header: 'optional fields', path: 'optionalConfigFields' }, + ], +} + +type KeylessProviderId = CreateCredentialWorkloadIdentitySubjectBody['providerId'] + +/** The generated enum of providers that can hold a keyless credential. */ +const KEYLESS_PROVIDER_IDS = + V2_OPERATIONS.createCredentialWorkloadIdentitySubject.body.providerId.values + +/** Refusals a fresh subject fixes: the proof expired, belongs to someone else, or is already used. */ +const KEYLESS_RETRY_CODES: ReadonlySet = new Set([ + 'WORKLOAD_IDENTITY_PROOF_EXPIRED', + 'WORKLOAD_IDENTITY_PROOF_INVALID', + 'WORKLOAD_IDENTITY_SUBJECT_REQUIRED', + 'WORKLOAD_IDENTITY_SUBJECT_IN_USE', +]) + +/** + * Paragraphs separated by blank lines, then one `Examples:` block: the shape + * the CLI docs generator renders as separate paragraphs and a code block. + */ +const KEYLESS_FLOW_HELP = [ + '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.", + '', + '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.`, + '', + 'Examples:', + ' $ sim credentials keyless-subject --output json > subject.json', + ` $ jq '.keylessConfig + {"roleArn":"arn:aws:iam::123456789012:role/SimWorkflows"}' subject.json > keyless.json`, + ' $ sim credentials create --name "Keyless" --credentials @keyless.json', +].join('\n') + type ConnectionBody = { providerId: string; displayName: string } | { credentialId: string } type CredentialProvider = ListCredentialProvidersResponse['data'][number] type ServiceAccountProvider = Extract @@ -66,8 +114,8 @@ async function credentialValues( throw new SimApiError('--credentials must be a JSON object', 0) } - const values = parsed as Record const fields = new Map(provider.fields.map((field) => [field.id, field])) + const values = asServiceAccountJson(parsed as Record, provider) for (const [id, value] of Object.entries(values)) { const field = fields.get(id) if (!field) { @@ -87,6 +135,11 @@ async function credentialValues( } } + // A keyless configuration stands in for the provider's required fields. + if (provider.keyless && values.serviceAccountJson !== undefined) { + return values as Record + } + const authMethod = typeof values.authMethod === 'string' ? values.authMethod : undefined const missing = provider.fields .filter( @@ -106,6 +159,53 @@ async function credentialValues( return values as Record } +/** + * Accepts the keylessConfig from `sim credentials keyless-subject` as it is: + * an object that carries a subject and its proof, or the provider's keyless + * `authType`, is sent as `serviceAccountJson`. + * Anything else is read as the provider's fields, so a misspelled field is + * still reported as unsupported. + */ +function asServiceAccountJson( + values: Record, + provider: ServiceAccountProvider +): Record { + const keylessConfig = + (values.subject !== undefined && values.subjectProof !== undefined) || + (provider.keyless !== undefined && values.authType === provider.keyless.authType) + return keylessConfig ? { serviceAccountJson: JSON.stringify(values) } : values +} + +/** Names the command that fixes a refused keyless subject, keeping the API's code and details. */ +function withKeylessRetryHint(error: unknown, providerId: string): unknown { + if (!(error instanceof SimApiError)) return error + const code = refusalCode(error) + if (code === null || !KEYLESS_RETRY_CODES.has(code)) return error + return new SimApiError( + `${error.message} Run \`sim credentials keyless-subject ${providerId}\` for a new subject, trust it in your cloud, and retry.`, + error.status, + error.code, + error.details, + error.exitCode + ) +} + +async function createKeylessSubject( + { client, profile }: Connection<'createCredentialWorkloadIdentitySubject'>, + providerId: KeylessProviderId +): Promise { + const response = await client.request( + 'createCredentialWorkloadIdentitySubject', + { body: { workspaceId: client.requireWorkspace(), providerId } } + ) + renderResult( + 'createCredentialWorkloadIdentitySubject', + profile.output, + response.data, + KEYLESS_SUBJECT_RESULT + ) +} + async function createServiceAccount( { client, profile }: Connection<'listCredentialProviders' | 'createServiceAccountCredential'>, providerId: string, @@ -121,9 +221,8 @@ async function createServiceAccount( } const credentialFields = await credentialValues(provider, options.credentials) - const response = await client.request( - 'createServiceAccountCredential', - { + const response = await client + .request('createServiceAccountCredential', { body: { workspaceId, type: 'service_account', @@ -133,8 +232,10 @@ async function createServiceAccount( ...(options.id ? { id: options.id } : {}), credentials: JSON.stringify(credentialFields), }, - } - ) + }) + .catch((error: unknown) => { + throw withKeylessRetryHint(error, providerId) + }) renderResult( 'createServiceAccountCredential', @@ -222,10 +323,39 @@ export function attachCredentialCommands(program: Command): void { '--id ', 'Client-generated credential ID when provider discovery requires it' ) + .addHelpText( + 'after', + [ + '', + "--credentials takes the provider's fields, or the keylessConfig from `sim credentials keyless-subject` (with the required fields filled in). Pass @path or @- to keep secrets out of shell history.", + '', + KEYLESS_FLOW_HELP, + ].join('\n') + ) .action((providerId: string, options: CreateServiceAccountOptions) => createServiceAccount(connectCreate(), providerId, options) ) + const [keylessSubject, connectKeylessSubject] = apiCommand(credentials, 'keyless-subject', [ + 'createCredentialWorkloadIdentitySubject', + ]) + keylessSubject + .addArgument( + new Argument('', 'Service-account provider for the keyless credential').choices([ + ...KEYLESS_PROVIDER_IDS, + ]) + ) + .description( + describeOperation( + V2_OPERATIONS.createCredentialWorkloadIdentitySubject, + 'Issue the subject and proof for a new keyless (workload identity) credential' + ) + ) + .addHelpText('after', `\n${KEYLESS_FLOW_HELP}`) + .action((providerId: KeylessProviderId) => + createKeylessSubject(connectKeylessSubject(), providerId) + ) + const [connect, connectLink] = apiCommand(credentials, 'connect', ['createCredentialConnection']) connect .argument('', 'OAuth provider to connect') diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 31e1b3f126f..a76ee9d6a01 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -185,6 +185,7 @@ export const CLI_CONTRACT: CliContract = { // endpoint as an NDJSON stream so the reply prints as it generates. chat: { hidden: true }, createCredentialConnection: { hidden: true }, + createCredentialWorkloadIdentitySubject: { hidden: true }, createServiceAccountCredential: { hidden: true }, getBillingStatus: { command: 'billing status', diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index e2d7d149ccc..052210e9390 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -2067,6 +2067,38 @@ export type CreateCredentialConnectionResponse = { data: CreateCredentialConnectionResponseRef0 } +/** `POST /api/v2/credentials/workload-identity/subjects` */ +export type CreateCredentialWorkloadIdentitySubjectQuery = Record + +export type CreateCredentialWorkloadIdentitySubjectBody = { + workspaceId: string + providerId: + | 'aws-service-account' + | 'databricks-service-account' + | 'snowflake-service-account' + | 'microsoft-app-service-account' + | 'google-service-account' +} + +type CreateCredentialWorkloadIdentitySubjectResponseRef0 = { + issuer: string + audience?: string + subject: string + subjectProof: string + expiresAt: string + keylessConfig: { + authType: string + subject: string + subjectProof: string + } + requiredConfigFields: Array + optionalConfigFields: Array +} + +export type CreateCredentialWorkloadIdentitySubjectResponse = { + data: CreateCredentialWorkloadIdentitySubjectResponseRef0 +} + /** `POST /api/v2/custom-tools` */ export type CreateCustomToolQuery = Record @@ -8778,6 +8810,11 @@ type ListCredentialProvidersResponseRef0 = }> hint?: string }> + keyless?: { + authType: string + requiredConfigFields: Array + optionalConfigFields: Array + } } export type ListCredentialProvidersResponse = { @@ -16889,6 +16926,34 @@ export const V2_OPERATIONS = { workspaceKeyUnsupported: true, opaqueBody: true, }, + createCredentialWorkloadIdentitySubject: { + method: 'POST', + path: '/api/v2/credentials/workload-identity/subjects', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create workload identity subject', + workspaceKeyUnsupported: true, + body: { + workspaceId: { + kind: 'string', + required: true, + describe: 'Workspace that will own the keyless credential.', + }, + providerId: { + kind: 'enum', + required: true, + values: [ + 'aws-service-account', + 'databricks-service-account', + 'snowflake-service-account', + 'microsoft-app-service-account', + 'google-service-account', + ] as const, + describe: + 'Service-account provider the keyless credential will be created under; pass the same value to Create service-account credential. The response and List credential providers name the fields its keyless configuration needs.', + }, + }, + }, createCustomTool: { method: 'POST', path: '/api/v2/custom-tools', diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index 65c1af026f4..0ab8d207b65 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -475,6 +475,7 @@ describe('destructive operations are gated', () => { 'completeKnowledgeImport', 'completeTableImport', 'createCredentialConnection', + 'createCredentialWorkloadIdentitySubject', 'createCustomTool', 'createFile', 'createFileFolder', diff --git a/packages/sim-cli/src/http/client.ts b/packages/sim-cli/src/http/client.ts index c6e2f292894..e6a82701dca 100644 --- a/packages/sim-cli/src/http/client.ts +++ b/packages/sim-cli/src/http/client.ts @@ -223,6 +223,16 @@ async function readResponseText(response: Response): Promise { } } +/** A refusal's machine-readable code: `details.code` when present, else the top-level `code`. */ +export function refusalCode(error: SimApiError): string | null { + const details = error.details + if (details && typeof details === 'object') { + const code = (details as { code?: unknown }).code + if (typeof code === 'string') return code + } + return error.code +} + /** * Whether this is the refusal a workspace-scoped key gets from an operation only * a personal key may perform, under either code that expresses it. @@ -234,11 +244,8 @@ async function readResponseText(response: Response): Promise { * is still accepted so a server that promotes the code stays covered. */ function namesKeyScopeRefusal(error: SimApiError): boolean { - if (typeof error.code === 'string' && KEY_SCOPE_REFUSALS.has(error.code)) return true - const details = error.details - if (!details || typeof details !== 'object') return false - const code = (details as { code?: unknown }).code - return typeof code === 'string' && KEY_SCOPE_REFUSALS.has(code) + const code = refusalCode(error) + return code !== null && KEY_SCOPE_REFUSALS.has(code) } interface DetailIssue {