diff --git a/apps/docs/content/docs/platform/self-hosting/networking.mdx b/apps/docs/content/docs/platform/self-hosting/networking.mdx index 1a995ac67c5..0c21bd9b831 100644 --- a/apps/docs/content/docs/platform/self-hosting/networking.mdx +++ b/apps/docs/content/docs/platform/self-hosting/networking.mdx @@ -301,6 +301,8 @@ A request through the SSRF guard keeps its checks when it is proxied. Before eac Set `NO_PROXY` for every destination that is not on the public internet, not just model endpoints. The app reaches the realtime server (`SOCKET_SERVER_URL`), the Presidio PII service (`PII_URL`), and itself (`INTERNAL_API_BASE_URL`) over the same default `fetch`, alongside self-hosted Ollama, LiteLLM, and vLLM — so a proxy that cannot reach your internal network breaks live updates and PII redaction, not only inference. AWS clients and OTLP export follow the same rule, so also add an internal S3-compatible store such as MinIO, AWS VPC endpoints, and an in-cluster OpenTelemetry collector. +For the SSRF guard, AWS clients (including the boot-time Secrets Manager fetch), and OTLP export, Sim matches `NO_PROXY` entries the way curl does: `example.com`, `.example.com`, and `*.example.com` each cover the domain and all its subdomains; `host:port` and `[::1]:port` cover one port; IPv6 entries work bare (`::1`) or bracketed; and CIDR ranges such as `10.0.0.0/8` cover IP-literal destinations only, not hostnames that resolve into the range. Bun's default `fetch`, which carries internal service URLs such as the realtime server and PII service, applies Bun's own `NO_PROXY` rules, which may not honor CIDR or bare IPv6 entries, so list those destinations by hostname. + Set it in the application environment, not your shell — under `app.env` on Helm, or the service's `environment:` on Compose. On Helm the suffixes alone are not enough: the chart wires `SOCKET_SERVER_URL`, `PII_URL`, and `OLLAMA_URL` to bare Service names, which no domain suffix matches. Add those names too — `helm template` prints the rendered ones, and the prefix is the release name unless it already contains `sim`, in which case it is the release name alone: ```yaml diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 8d7fc7cb356..f0b30c41413 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -3958,7 +3958,7 @@ "post": { "operationId": "executeWorkflowV2", "summary": "Execute workflow", - "description": "Run the active deployment, set `run: { source: \"deployment\", version }` to run an earlier deployment version, or set `run.source: \"manual\"` to run the saved draft. Deployed execution requires workspace read access and OAuth `api:write` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Running a chosen version requires workspace write access and an API key or OAuth access token; an unknown version returns `404`. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse `async: true` for a queue receipt, or `stream: true` for SSE. For a synchronous run with both flags false, send `Accept: application/x-ndjson` for heartbeat frames and a final frame containing `data`. With `waitTimeoutSeconds`, a synchronous run still going when the wait elapses continues in the background and answers with its run receipt instead: `202` on a JSON response, or a closing `detached` frame on an NDJSON stream, which keeps its `200`. Timeouts return `200` with `status: \"failed\"` and `error.code: \"TIMEOUT\"`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nSupply `X-Run-Id` to prevent duplicate execution. Reuse returns `409`, without replaying results. A `503` with `error.details.code: \"ASYNC_ENQUEUE_AMBIGUOUS\"` omits `Retry-After` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.\n\nOAuth scope: `api:write`.", + "description": "Run the active deployment, set `run: { source: \"deployment\", version }` to run an earlier deployment version, or set `run.source: \"manual\"` to run the saved draft. Deployed execution requires workspace read access and OAuth `api:write` scope or a full-access API key; public deployments also allow anonymous synchronous or streamed runs. Running a chosen version requires workspace write access and a full-access API key or OAuth `api:write` scope; an unknown version returns `404`. Manual runs require a full-access personal API key or OAuth `api:write` scope, and workspace write access; workspace API keys are refused, and manual runs cannot be async.\n\nUse `async: true` for a queue receipt, or `stream: true` for SSE. For a synchronous run with both flags false, send `Accept: application/x-ndjson` for heartbeat frames and a final frame containing `data`. With `waitTimeoutSeconds`, a synchronous run still going when the wait elapses continues in the background and answers with its run receipt instead: `202` on a JSON response, or a closing `detached` frame on an NDJSON stream, which keeps its `200`. Timeouts return `200` with `status: \"failed\"` and `error.code: \"TIMEOUT\"`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nSupply `X-Run-Id` to prevent duplicate execution. Reuse returns `409`, without replaying results. A `503` with `error.details.code: \"ASYNC_ENQUEUE_AMBIGUOUS\"` omits `Retry-After` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.\n\nOAuth scope: `api:write`.", "x-sim-operation": "workflows.execute", "x-oauth-scope": "api:write", "tags": ["Workflows"], diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index 929402de51e..e077ab772b4 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -1282,7 +1282,7 @@ const declaredRoutes = [ applicationOperation: workflowOperations.execute, operationId: 'executeWorkflowV2', summary: 'Execute workflow', - description: `Run the active deployment, set \`run: { source: "deployment", version }\` to run an earlier deployment version, or set \`run.source: "manual"\` to run the saved draft. Deployed execution requires workspace read access and OAuth \`api:write\` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Running a chosen version requires workspace write access and an API key or OAuth access token; an unknown version returns \`404\`. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse \`async: true\` for a queue receipt, or \`stream: true\` for SSE. For a synchronous run with both flags false, send \`Accept: application/x-ndjson\` for heartbeat frames and a final frame containing \`data\`. With \`waitTimeoutSeconds\`, a synchronous run still going when the wait elapses continues in the background and answers with its run receipt instead: \`202\` on a JSON response, or a closing \`detached\` frame on an NDJSON stream, which keeps its \`200\`. Timeouts return \`200\` with \`status: "failed"\` and \`error.code: "TIMEOUT"\`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. ${EXECUTE_OPTION_CONSTRAINTS}\n\nSupply \`X-Run-Id\` to prevent duplicate execution. Reuse returns \`409\`, without replaying results. A \`503\` with \`error.details.code: "ASYNC_ENQUEUE_AMBIGUOUS"\` omits \`Retry-After\` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.`, + description: `Run the active deployment, set \`run: { source: "deployment", version }\` to run an earlier deployment version, or set \`run.source: "manual"\` to run the saved draft. Deployed execution requires workspace read access and OAuth \`api:write\` scope or a full-access API key; public deployments also allow anonymous synchronous or streamed runs. Running a chosen version requires workspace write access and a full-access API key or OAuth \`api:write\` scope; an unknown version returns \`404\`. Manual runs require a full-access personal API key or OAuth \`api:write\` scope, and workspace write access; workspace API keys are refused, and manual runs cannot be async.\n\nUse \`async: true\` for a queue receipt, or \`stream: true\` for SSE. For a synchronous run with both flags false, send \`Accept: application/x-ndjson\` for heartbeat frames and a final frame containing \`data\`. With \`waitTimeoutSeconds\`, a synchronous run still going when the wait elapses continues in the background and answers with its run receipt instead: \`202\` on a JSON response, or a closing \`detached\` frame on an NDJSON stream, which keeps its \`200\`. Timeouts return \`200\` with \`status: "failed"\` and \`error.code: "TIMEOUT"\`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. ${EXECUTE_OPTION_CONSTRAINTS}\n\nSupply \`X-Run-Id\` to prevent duplicate execution. Reuse returns \`409\`, without replaying results. A \`503\` with \`error.details.code: "ASYNC_ENQUEUE_AMBIGUOUS"\` omits \`Retry-After\` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.`, errors: [ 'BadRequest', 'Unauthorized', diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index 0b7a66f8915..e653f37b816 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -1252,7 +1252,7 @@ export const V2_MCP_OPERATIONS = { contract: v2ExecuteWorkflowContract, summary: 'Execute workflow', description: - 'Run the active deployment, set `run: { source: "deployment", version }` to run an earlier deployment version, or set `run.source: "manual"` to run the saved draft. Deployed execution requires workspace read access and OAuth `api:write` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Running a chosen version requires workspace write access and an API key or OAuth access token; an unknown version returns `404`. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse `async: true` for a queue receipt, or `stream: true` for SSE. For a synchronous run with both flags false, send `Accept: application/x-ndjson` for heartbeat frames and a final frame containing `data`. With `waitTimeoutSeconds`, a synchronous run still going when the wait elapses continues in the background and answers with its run receipt instead: `202` on a JSON response, or a closing `detached` frame on an NDJSON stream, which keeps its `200`. Timeouts return `200` with `status: "failed"` and `error.code: "TIMEOUT"`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nSupply `X-Run-Id` to prevent duplicate execution. Reuse returns `409`, without replaying results. A `503` with `error.details.code: "ASYNC_ENQUEUE_AMBIGUOUS"` omits `Retry-After` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.\n\nOAuth scope: `api:write`.', + 'Run the active deployment, set `run: { source: "deployment", version }` to run an earlier deployment version, or set `run.source: "manual"` to run the saved draft. Deployed execution requires workspace read access and OAuth `api:write` scope or a full-access API key; public deployments also allow anonymous synchronous or streamed runs. Running a chosen version requires workspace write access and a full-access API key or OAuth `api:write` scope; an unknown version returns `404`. Manual runs require a full-access personal API key or OAuth `api:write` scope, and workspace write access; workspace API keys are refused, and manual runs cannot be async.\n\nUse `async: true` for a queue receipt, or `stream: true` for SSE. For a synchronous run with both flags false, send `Accept: application/x-ndjson` for heartbeat frames and a final frame containing `data`. With `waitTimeoutSeconds`, a synchronous run still going when the wait elapses continues in the background and answers with its run receipt instead: `202` on a JSON response, or a closing `detached` frame on an NDJSON stream, which keeps its `200`. Timeouts return `200` with `status: "failed"` and `error.code: "TIMEOUT"`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nSupply `X-Run-Id` to prevent duplicate execution. Reuse returns `409`, without replaying results. A `503` with `error.details.code: "ASYNC_ENQUEUE_AMBIGUOUS"` omits `Retry-After` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.\n\nOAuth scope: `api:write`.', handler: () => import('@/app/api/v2/workflows/[workflowId]/execute/route').then((route) => route.POST), }, diff --git a/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts b/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts index 6c9c46daaa2..8308eee3ceb 100644 --- a/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts +++ b/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts @@ -199,6 +199,28 @@ describe('v2 API key authentication', () => { ) }) + it('refuses a personal key whose owner row is gone as invalid, expired or not', async () => { + for (const expiresAt of [null, new Date(Date.now() - 1)]) { + queueTableRows(schemaMock.apiKey, [ + { + id: 'key-orphan', + userId: 'user-gone', + workspaceId: null, + type: 'personal', + access: 'write', + expiresAt, + userBanned: null, + userSuspendedAt: null, + }, + ]) + const refusal = await authenticateV2ApiKey({ apiKey: 'orphan', bearer: null }).catch( + (error: unknown) => error + ) + expect(refusal).toBeInstanceOf(V2ApiKeyUnauthenticatedError) + expect((refusal as V2ApiKeyUnauthenticatedError).message).toBe('Invalid API key') + } + }) + it('propagates auth-store failures instead of converting them to invalid credentials', async () => { const failure = new Error('database unavailable') dbChainMockFns.limit.mockRejectedValueOnce(failure) diff --git a/apps/sim/lib/api/server/routes/v2-api-key-auth.ts b/apps/sim/lib/api/server/routes/v2-api-key-auth.ts index bb636387d10..f4ac64492d0 100644 --- a/apps/sim/lib/api/server/routes/v2-api-key-auth.ts +++ b/apps/sim/lib/api/server/routes/v2-api-key-auth.ts @@ -85,7 +85,8 @@ function requireValidRow(row: ApiKeyRow | undefined): ApiKeyRow { if (!row) throw new V2ApiKeyUnauthenticatedError() if (row.type === 'personal' && row.workspaceId === null) { if (row.userBanned === null) { - throw new Error(`Personal API key ${row.id} is missing its credential owner`) + logger.error('Personal API key is missing its credential owner', { keyId: row.id }) + throw new V2ApiKeyUnauthenticatedError() } if (row.userBanned) throw new V2ApiKeyUnauthenticatedError() /** diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 42d83122109..91d55e1482d 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -79,7 +79,7 @@ const FEATURE_FLAGS = { }, 'api-key-read-only': { description: - 'Allow creating read-only API keys. Turn on only after every pod runs a release that enforces key access, since older pods treat a read-only key as full access. Enforcement of existing read-only keys never depends on this flag. Global; disabled by default.', + 'Allow creating read-only API keys. Turn on only after every pod runs a release that enforces key access, since older pods treat a read-only key as full access. Before rolling back below the release that added read-only keys, turn this off first: that older code also serves every existing read-only key as full access. Enforcement of existing read-only keys never depends on this flag. Global; disabled by default.', fallback: 'API_KEY_READ_ONLY', }, 'mothership-model-selector': { diff --git a/apps/sim/lib/core/network/environment-proxy.server.test.ts b/apps/sim/lib/core/network/environment-proxy.server.test.ts index f63b1ce0b8d..15c9ff59388 100644 --- a/apps/sim/lib/core/network/environment-proxy.server.test.ts +++ b/apps/sim/lib/core/network/environment-proxy.server.test.ts @@ -31,7 +31,7 @@ vi.mock('@sim/security/dns', async (importOriginal) => { } }) -import { resolveEnvironmentProxy } from '@/lib/core/network/environment-proxy.server' +import { resolveEnvironmentProxy } from '@sim/security/environment-proxy' import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server' import { createPinnedFetchWithDispatcher, @@ -323,6 +323,66 @@ describe('proxy selection from the environment', () => { target: 'https://api.example.com/', proxy: 'http://p:3128/', }, + { + case: 'a bare NO_PROXY domain also excludes its subdomains', + env: { HTTPS_PROXY: 'http://p:3128', NO_PROXY: 'example.com' }, + target: 'https://api.example.com/', + proxy: null, + }, + { + case: 'a leading-dot NO_PROXY domain also excludes the apex', + env: { HTTPS_PROXY: 'http://p:3128', NO_PROXY: '.example.com' }, + target: 'https://example.com/', + proxy: null, + }, + { + case: 'a NO_PROXY domain does not match a longer name that merely ends with it', + env: { HTTPS_PROXY: 'http://p:3128', NO_PROXY: 'example.com' }, + target: 'https://notexample.com/', + proxy: 'http://p:3128/', + }, + { + case: 'a bare IPv6 NO_PROXY entry excludes that address', + env: { HTTP_PROXY: 'http://p:3128', NO_PROXY: 'localhost,::1' }, + target: 'http://[::1]:3000/', + proxy: null, + }, + { + case: 'an IPv6 NO_PROXY entry matches any spelling of the address', + env: { HTTP_PROXY: 'http://p:3128', NO_PROXY: '0:0:0:0:0:0:0:1' }, + target: 'http://[::1]/', + proxy: null, + }, + { + case: 'a bracketed IPv6 NO_PROXY entry with a port only excludes that port', + env: { HTTP_PROXY: 'http://p:3128', NO_PROXY: '[::1]:3000' }, + target: 'http://[::1]:4000/', + proxy: 'http://p:3128/', + }, + { + case: 'an IPv4 CIDR NO_PROXY entry excludes addresses inside the range', + env: { HTTPS_PROXY: 'http://p:3128', NO_PROXY: '10.0.0.0/8' }, + target: 'https://10.20.30.40/', + proxy: null, + }, + { + case: 'an IPv4 CIDR NO_PROXY entry leaves addresses outside the range proxied', + env: { HTTPS_PROXY: 'http://p:3128', NO_PROXY: '10.0.0.0/8' }, + target: 'https://11.0.0.1/', + proxy: 'http://p:3128/', + }, + { + case: 'an IPv6 CIDR NO_PROXY entry excludes addresses inside the range', + env: { HTTP_PROXY: 'http://p:3128', NO_PROXY: 'fd00::/8' }, + target: 'http://[fd12::1]/', + proxy: null, + }, + { + case: 'an empty lowercase no_proxy does not hide the uppercase one', + env: { HTTPS_PROXY: 'http://p:3128', no_proxy: '', NO_PROXY: 'example.com' }, + target: 'https://example.com/', + proxy: null, + }, { case: 'NO_PROXY=* excludes every destination', env: { HTTPS_PROXY: 'http://p:3128', NO_PROXY: '*' }, diff --git a/apps/sim/lib/core/network/environment-proxy.server.ts b/apps/sim/lib/core/network/environment-proxy.server.ts deleted file mode 100644 index c43f6f7bfa1..00000000000 --- a/apps/sim/lib/core/network/environment-proxy.server.ts +++ /dev/null @@ -1,58 +0,0 @@ -const DEFAULT_PORTS: Record = { 'http:': 80, 'https:': 443 } - -/** An empty lowercase variable counts as unset, so it cannot hide the uppercase one. */ -function readEnv(lower: string, upper: string): string | undefined { - return process.env[lower]?.trim() || process.env[upper]?.trim() || undefined -} - -/** Whether any `HTTPS_PROXY` / `HTTP_PROXY` is set, before any destination is considered. */ -export function isEnvironmentProxyConfigured(): boolean { - return Boolean(readEnv('https_proxy', 'HTTPS_PROXY') ?? readEnv('http_proxy', 'HTTP_PROXY')) -} - -/** Matches `NO_PROXY` the way undici's `EnvHttpProxyAgent` does, so both stacks agree. */ -function isExcludedByNoProxy(hostname: string, port: number): boolean { - const noProxy = readEnv('no_proxy', 'NO_PROXY') - if (!noProxy) return false - if (noProxy === '*') return true - for (const entry of noProxy.split(/[,\s]/)) { - if (!entry) continue - const withPort = entry.match(/^(.+):(\d+)$/) - const entryHost = (withPort ? withPort[1] : entry).replace(/^\*?\./, '').toLowerCase() - const entryPort = withPort ? Number.parseInt(withPort[2], 10) : 0 - if (entryPort && entryPort !== port) continue - if (hostname === entryHost || hostname.endsWith(`.${entryHost}`)) return true - } - return false -} - -/** - * The operator's `HTTPS_PROXY` / `HTTP_PROXY` for a destination, or null when none applies. - * Lowercase names win, an https destination falls back to `HTTP_PROXY`, and `NO_PROXY` - * excludes hosts with undici's `EnvHttpProxyAgent` matching, so guarded requests leave the - * way the default `fetch` does. Read per call so a value set after startup is honored. - */ -export function resolveEnvironmentProxy(target: URL): string | null { - const defaultPort = DEFAULT_PORTS[target.protocol] - if (defaultPort === undefined) return null - const httpProxy = readEnv('http_proxy', 'HTTP_PROXY') - const proxy = - target.protocol === 'https:' ? (readEnv('https_proxy', 'HTTPS_PROXY') ?? httpProxy) : httpProxy - if (!proxy) return null - if (isExcludedByNoProxy(target.hostname, Number(target.port) || defaultPort)) return null - return normalizeProxyUrl(proxy) -} - -/** Accepts a bare `host:port`, as curl does; anything unparseable fails rather than going direct. */ -function normalizeProxyUrl(value: string): string { - let parsed: URL - try { - parsed = new URL(value.includes('://') ? value : `http://${value}`) - } catch { - throw new Error('The configured HTTP(S)_PROXY is not a valid URL') - } - if (DEFAULT_PORTS[parsed.protocol] === undefined) { - throw new Error('The configured HTTP(S)_PROXY must be an http:// or https:// URL') - } - return parsed.href -} diff --git a/apps/sim/lib/core/network/proxy-agent.server.ts b/apps/sim/lib/core/network/proxy-agent.server.ts index bdf7c0968b5..de68381bc39 100644 --- a/apps/sim/lib/core/network/proxy-agent.server.ts +++ b/apps/sim/lib/core/network/proxy-agent.server.ts @@ -1,14 +1,14 @@ import http from 'node:http' import https from 'node:https' import { isIP } from 'node:net' +import { + isEnvironmentProxyConfigured, + resolveEnvironmentProxy, +} from '@sim/security/environment-proxy' import { NodeHttpHandler } from '@smithy/node-http-handler' import { Agent, type AgentConnectOpts } from 'agent-base' import { HttpProxyAgent } from 'http-proxy-agent' import { HttpsProxyAgent } from 'https-proxy-agent' -import { - isEnvironmentProxyConfigured, - resolveEnvironmentProxy, -} from '@/lib/core/network/environment-proxy.server' /** Matches the AWS SDK's own default agent, so swapping it in changes routing and nothing else. */ const AGENT_OPTIONS: http.AgentOptions = { keepAlive: true, maxSockets: 50 } diff --git a/apps/sim/lib/core/network/transport.server.ts b/apps/sim/lib/core/network/transport.server.ts index afea63b2d3e..85d294565c1 100644 --- a/apps/sim/lib/core/network/transport.server.ts +++ b/apps/sim/lib/core/network/transport.server.ts @@ -1,6 +1,6 @@ +import { resolveEnvironmentProxy } from '@sim/security/environment-proxy' import { type Agent, type Dispatcher, ProxyAgent, request } from 'undici/index.js' import { resolveCurrentOutboundRoute } from '@/lib/core/network/context.server' -import { resolveEnvironmentProxy } from '@/lib/core/network/environment-proxy.server' import { createGatewayDispatcher } from '@/lib/core/network/gateway.server' import { OutboundRoutingError } from '@/lib/core/network/routing' import type { EgressProfile } from '@/lib/core/security/egress/profiles' 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 new file mode 100644 index 00000000000..e06a15cc191 --- /dev/null +++ b/apps/sim/lib/credentials/__integration__/workload-identity-subject-reuse.integration.ts @@ -0,0 +1,243 @@ +/** + * A keyless trust setup (subject plus its day-long proof) binds to one live + * credential: saving it onto a second credential is refused, even concurrently, + * while the credential that holds it can still be reconnected. + */ + +import { db } from '@sim/db' +import { credential, credentialMember, permissions, user, workspace } from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { jsonResponse } from '@sim/testing/helpers/http' +import { posthogServerMock } from '@sim/testing/mocks/posthog-server.mock' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +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()), + mintWorkloadIdentityToken: async ({ subject }: { subject: string }) => `token-for:${subject}`, +})) + +import { encryptSecret } from '@/lib/core/security/encryption' +import { updateCredentialRecord } from '@/lib/credentials/orchestration' +import { createCredentialRecord } from '@/lib/credentials/orchestration/credential-create' +import { issueWorkloadIdentitySubject } from '@/lib/credentials/workload-identity/subject' +import { DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' + +const HOST = 'dbc-1234abcd-5678.cloud.databricks.com' +const CLIENT_ID = '00000000-0000-0000-0000-000000000000' +const userId = generateId() +const workspaceId = generateId() +const owner = { workspaceId } +const originalFetch = globalThis.fetch + +/** Subjects the fixture service principal's federation policy pins. */ +const pinnedSubjects = new Set() +/** Runs once inside the next token exchange, while a save is still verifying. */ +let duringVerification: (() => Promise) | undefined + +function grant() { + const issued = issueWorkloadIdentitySubject('databricks', owner, userId) + pinnedSubjects.add(issued.subject) + return issued +} + +function keyless(subject?: { subject: string; subjectProof: string }) { + return JSON.stringify({ + authType: 'oidc_federation', + host: HOST, + clientId: CLIENT_ID, + ...(subject ? { subject: subject.subject, subjectProof: subject.subjectProof } : {}), + }) +} + +function create(displayName: string, serviceAccountJson: string) { + return createCredentialRecord( + { + workspaceId, + type: 'service_account', + userId, + providerId: DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID, + displayName, + serviceAccountJson, + }, + { authorizeWorkspace: true } + ) +} + +async function reconnect(credentialId: string, serviceAccountJson: string) { + const [row] = await db.select().from(credential).where(eq(credential.id, credentialId)) + return updateCredentialRecord({ + credentialId, + credential: row, + actorUserId: userId, + serviceAccountJson, + }) +} + +describe('reusing a keyless trust setup', () => { + beforeAll(async () => { + const now = new Date() + await db.insert(user).values({ + id: userId, + name: 'Subject reuse fixture', + email: `${userId}@subject-reuse.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + await insertWorkspaceFixture(db, { + id: workspaceId, + name: 'Subject reuse fixture', + ownerId: userId, + billedAccountUserId: userId, + }) + await db.insert(permissions).values({ + id: generateId(), + userId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }) + + vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { + const target = typeof input === 'string' || input instanceof URL ? String(input) : input.url + if (target === `https://${HOST}/oidc/v1/token`) { + const interleaved = duringVerification + duringVerification = undefined + await interleaved?.() + const subjectToken = new URLSearchParams(String(init?.body)).get('subject_token') ?? '' + const subject = subjectToken.replace(/^token-for:/, '') + return pinnedSubjects.has(subject) + ? jsonResponse({ access_token: `access-for:${subject}` }) + : jsonResponse({ error: 'invalid_client' }, 401) + } + if (target === `https://${HOST}/api/2.0/preview/scim/v2/Me`) { + return jsonResponse({ userName: 'service-principal@example.com' }) + } + return originalFetch(input, init) + }) + }, 60_000) + + afterAll(async () => { + vi.unstubAllGlobals() + await db.delete(credential).where(eq(credential.workspaceId, workspaceId)) + await deleteWorkspaceFixture(db, eq(workspace.id, workspaceId)) + await db.delete(user).where(eq(user.id, userId)) + }) + + it('refuses a second credential with a subject another credential holds', async () => { + const setup = grant() + const first = await create('Lakehouse', keyless(setup)) + expect(first.success).toBe(true) + + const second = await create('Lakehouse again', keyless(setup)) + + expect(second).toMatchObject({ success: false, errorCode: 'conflict' }) + const rows = await db + .select({ id: credential.id }) + .from(credential) + .where(eq(credential.workspaceId, workspaceId)) + expect(rows.map((row) => row.id)).toEqual([first.credential?.id]) + }) + + it('refuses reconnecting another credential onto a subject that is held', async () => { + const held = grant() + expect((await create('Held', keyless(held))).success).toBe(true) + const other = await create('Other', keyless(grant())) + const otherId = other.credential?.id ?? '' + + const result = await reconnect(otherId, keyless(held)) + + expect(result).toMatchObject({ success: false, errorCode: 'conflict' }) + }) + + it('lets the holding credential reconnect with the subject it already has', async () => { + const setup = grant() + const created = await create('Reconnects', keyless(setup)) + const credentialId = created.credential?.id ?? '' + + expect((await reconnect(credentialId, keyless())).success).toBe(true) + expect((await reconnect(credentialId, keyless(setup))).success).toBe(true) + }) + + it('re-checks a kept subject under the lock when the credential moved off it during verification', async () => { + const original = grant() + const mover = await create('Mover', keyless(original)) + const moverId = mover.credential?.id ?? '' + const moved = grant() + duringVerification = async () => { + expect((await reconnect(moverId, keyless(moved))).success).toBe(true) + expect((await create('Claimer', keyless(original))).success).toBe(true) + } + + const stale = await reconnect(moverId, keyless()) + + expect(duringVerification).toBeUndefined() + expect(stale).toMatchObject({ success: false, errorCode: 'conflict' }) + }) + + it('lets only one of two concurrent creates of one subject through', async () => { + const setup = grant() + + const results = await Promise.all([ + create('Racer one', keyless(setup)), + create('Racer two', keyless(setup)), + ]) + + expect(results.filter((result) => result.success)).toHaveLength(1) + expect(results.filter((result) => result.errorCode === 'conflict')).toHaveLength(1) + }) + + it('lets only one of two concurrent reconnects onto one subject through', async () => { + const first = await create('Reconnect racer one', keyless(grant())) + const second = await create('Reconnect racer two', keyless(grant())) + const setup = grant() + + const results = await Promise.all([ + reconnect(first.credential?.id ?? '', keyless(setup)), + reconnect(second.credential?.id ?? '', keyless(setup)), + ]) + + expect(results.filter((result) => result.success)).toHaveLength(1) + expect(results.filter((result) => result.errorCode === 'conflict')).toHaveLength(1) + }) + + it('frees the subject once the credential holding it is deleted', async () => { + const setup = grant() + const created = await create('Deleted holder', keyless(setup)) + const credentialId = created.credential?.id ?? '' + await db.delete(credentialMember).where(eq(credentialMember.credentialId, credentialId)) + await db.delete(credential).where(eq(credential.id, credentialId)) + + expect((await create('Successor', keyless(setup))).success).toBe(true) + }) + + it.each([ + ['ciphertext that does not decrypt', async () => 'not-a-ciphertext'], + ['a secret that is not JSON', async () => (await encryptSecret('not json')).encrypted], + ])( + 'refuses a new subject while a sibling holds %s, instead of failing the save', + async (_case, sealed) => { + const unreadableId = generateId() + await db.insert(credential).values({ + id: unreadableId, + workspaceId, + type: 'service_account', + providerId: DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID, + displayName: 'Unreadable sibling', + encryptedServiceAccountKey: await sealed(), + createdBy: userId, + }) + try { + const result = await create(`Blocked by ${unreadableId}`, keyless(grant())) + + expect(result).toMatchObject({ success: false, errorCode: 'conflict' }) + expect(result.error).toContain('Unreadable sibling') + } finally { + await db.delete(credential).where(eq(credential.id, unreadableId)) + } + } + ) +}) diff --git a/apps/sim/lib/credentials/aws-service-account.server.ts b/apps/sim/lib/credentials/aws-service-account.server.ts index c0adb8e9546..6468e2a3d9f 100644 --- a/apps/sim/lib/credentials/aws-service-account.server.ts +++ b/apps/sim/lib/credentials/aws-service-account.server.ts @@ -258,13 +258,14 @@ function verificationFailure(config: AwsCredentialConfig, error: unknown): AwsCr /** * Validates the submitted configuration, proves it works by resolving it and - * calling `sts:GetCallerIdentity`, and returns the encrypted blob plus the - * caller ARN AWS reports. + * calling `sts:GetCallerIdentity`, and returns the encrypted blob, the caller + * ARN AWS reports, and the keyless subject it newly binds, if any (a + * reconnect keeping its stored subject binds none). */ export async function verifyAndEncryptAwsCredential( serialized: string, owner: WorkloadIdentityCredentialOwner -): Promise<{ encryptedServiceAccountKey: string; callerArn: string }> { +): Promise<{ encryptedServiceAccountKey: string; callerArn: string; newSubject?: string }> { const parsed = parseServiceAccountConfig(serialized, submittedConfigSchema, 'AWS credential') if (!parsed.success) throw new AwsCredentialError(parsed.message) const submitted = parsed.data @@ -292,7 +293,13 @@ export async function verifyAndEncryptAwsCredential( const { encrypted } = await encryptSecret( JSON.stringify({ type: AWS_CREDENTIAL_SECRET_TYPE, config }) ) - return { encryptedServiceAccountKey: encrypted, callerArn } + return { + encryptedServiceAccountKey: encrypted, + callerArn, + ...(config.authType === 'oidc_role' && config.subject !== readStoredSubject(owner.storedSecret) + ? { newSubject: config.subject } + : {}), + } } /** Resolves the configuration and returns the caller ARN AWS reports for it. */ diff --git a/apps/sim/lib/credentials/databricks-service-account.server.ts b/apps/sim/lib/credentials/databricks-service-account.server.ts index 8b3d46aba38..faee195884e 100644 --- a/apps/sim/lib/credentials/databricks-service-account.server.ts +++ b/apps/sim/lib/credentials/databricks-service-account.server.ts @@ -290,13 +290,14 @@ async function assertPolicyPinsSubject( /** * Validates the submitted configuration, proves it by calling the workspace's - * SCIM `Me` endpoint with the resolved token, and returns the encrypted blob - * plus the identity Databricks reports. + * SCIM `Me` endpoint with the resolved token, and returns the encrypted blob, + * the identity Databricks reports, and the keyless subject it newly binds, if + * any (a reconnect keeping its stored subject binds none). */ export async function verifyAndEncryptDatabricksCredential( serialized: string, owner: WorkloadIdentityCredentialOwner -): Promise<{ encryptedServiceAccountKey: string; identity: string }> { +): Promise<{ encryptedServiceAccountKey: string; identity: string; newSubject?: string }> { const parsed = parseServiceAccountConfig( serialized, submittedConfigSchema, @@ -325,7 +326,14 @@ export async function verifyAndEncryptDatabricksCredential( const { encrypted } = await encryptSecret( JSON.stringify({ type: DATABRICKS_CREDENTIAL_SECRET_TYPE, config }) ) - return { encryptedServiceAccountKey: encrypted, identity } + return { + encryptedServiceAccountKey: encrypted, + identity, + ...(config.authType === 'oidc_federation' && + config.subject !== readStoredSubject(owner.storedSecret) + ? { newSubject: config.subject } + : {}), + } } /** diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts index 01d864a489b..2c42414f9fd 100644 --- a/apps/sim/lib/credentials/orchestration/credential-create.ts +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -29,6 +29,10 @@ import { } from '@/lib/credentials/service-account-secret' import { isTokenServiceAccountProviderId } from '@/lib/credentials/token-service-accounts/descriptors' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' +import { + assertWorkloadIdentitySubjectUnclaimed, + WorkloadIdentitySubjectError, +} from '@/lib/credentials/workload-identity/subject' import { getServiceConfigByProviderId } from '@/lib/oauth' import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import { captureServerEvent } from '@/lib/posthog/server' @@ -269,6 +273,7 @@ export async function createCredentialRecord( : null let resolvedEnvOwnerUserId: string | null = null let resolvedEncryptedServiceAccountKey: string | null = null + let resolvedWorkloadIdentitySubject: string | undefined const extraAuditMetadata: Record = {} if (type === 'oauth') { @@ -335,6 +340,7 @@ export async function createCredentialRecord( resolvedEnvOwnerUserId = null if (!resolvedDisplayName) resolvedDisplayName = secret.displayName resolvedEncryptedServiceAccountKey = secret.encryptedServiceAccountKey + resolvedWorkloadIdentitySubject = secret.newWorkloadIdentitySubject Object.assign(extraAuditMetadata, secret.auditMetadata) } catch (error) { if (error instanceof ServiceAccountSecretError) { @@ -517,6 +523,19 @@ export async function createCredentialRecord( if (innerExisting) throw new DuplicateCredentialError() } + if (resolvedWorkloadIdentitySubject) { + try { + await assertWorkloadIdentitySubjectUnclaimed(tx, { + subject: resolvedWorkloadIdentitySubject, + scope, + }) + } catch (error) { + if (error instanceof WorkloadIdentitySubjectError) + return failure(error.message, 'conflict') + throw error + } + } + await tx.insert(credential).values({ id: credentialId, ...resourceScopeColumns(scope), diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index d514e42aaa5..59c45b85677 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -47,6 +47,10 @@ import { verifyAndBuildServiceAccountSecret, } from '@/lib/credentials/service-account-secret' import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' +import { + assertReconnectedSubjectUnclaimed, + WorkloadIdentitySubjectError, +} from '@/lib/credentials/workload-identity/subject' import { invalidateEffectiveDecryptedEnvCache } from '@/lib/environment/utils' import { findSlackSearchInstallation } from '@/lib/knowledge/application/slack-search/repository' import { @@ -312,6 +316,7 @@ export async function updateCredentialRecord( let rotatedSlackBotUserId: string | undefined let rotatedAuditMetadata: Record | undefined + let rotatedKeylessSecret: string | undefined if (hasRotationSecret) { const providerId = params.credential.providerId ?? '' @@ -453,6 +458,7 @@ export async function updateCredentialRecord( updates.encryptedServiceAccountKey = secret.encryptedServiceAccountKey rotatedSlackBotUserId = secret.botUserId rotatedAuditMetadata = secret.auditMetadata + if (needsStoredBoundSubject) rotatedKeylessSecret = secret.encryptedServiceAccountKey if (needsStoredIdentity) { const previousIdentity = deriveStoredDisplayName(storedBlob) @@ -515,7 +521,26 @@ export async function updateCredentialRecord( } updates.updatedAt = new Date() - await db.update(credential).set(updates).where(eq(credential.id, params.credentialId)) + if (rotatedKeylessSecret) { + const rotatedSecret = rotatedKeylessSecret + try { + await db.transaction(async (tx) => { + await assertReconnectedSubjectUnclaimed(tx, { + rotatedSecret, + scope: resourceScopeFromOwner(params.credential), + credentialId: params.credentialId, + }) + 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' } + } + throw error + } + } else { + await db.update(credential).set(updates).where(eq(credential.id, params.credentialId)) + } // The flag rides the environment snapshot into every run's redaction catalog, so a flip // must not serve stale from the same-process snapshot cache. Cross-process readers are diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index 4b5509ea62b..1535ea1eb14 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -104,6 +104,11 @@ export interface ServiceAccountSecretResult { principal: ServiceAccountPrincipal | null /** Slack custom bot: the derived bot user id (for reaction self-drop). */ botUserId?: string + /** + * Keyless AWS or Databricks: a subject this save binds that the credential did + * not already hold, which must not belong to another credential of the owner. + */ + newWorkloadIdentitySubject?: string } /** Thrown when a service-account secret is missing or fails provider verification. */ @@ -272,6 +277,7 @@ async function buildAwsServiceAccountSecret( displayName: result.callerArn, auditMetadata: serviceAccountPrincipalMetadata(principal), principal, + newWorkloadIdentitySubject: result.newSubject, } } @@ -299,6 +305,7 @@ async function buildDatabricksServiceAccountSecret( displayName: result.identity, auditMetadata: serviceAccountPrincipalMetadata(principal), principal, + newWorkloadIdentitySubject: result.newSubject, } } diff --git a/apps/sim/lib/credentials/workload-identity/subject.ts b/apps/sim/lib/credentials/workload-identity/subject.ts index 6a0fed7df19..6b0276c252e 100644 --- a/apps/sim/lib/credentials/workload-identity/subject.ts +++ b/apps/sim/lib/credentials/workload-identity/subject.ts @@ -1,9 +1,24 @@ import { createHmac } from 'node:crypto' +import { credential } from '@sim/db/schema' +import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' 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 type { ResourceScope } from '@/lib/core/resource-scope' +import { resourceScopeCondition } from '@/lib/core/resource-scope.server' +import { decryptSecret } from '@/lib/core/security/encryption' import 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, +} from '@/lib/oauth/types' + +const logger = createLogger('WorkloadIdentitySubject') /** * Credential-scoped workload-identity subjects. @@ -145,3 +160,129 @@ export function bindWorkloadIdentitySubject(input: { 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, +] + +/** The part of an AWS or Databricks stored secret that can hold a bound subject. */ +const storedSubjectSchema = z.object({ + config: z.object({ subject: z.string().optional() }), +}) + +/** The subject a stored secret holds, `undefined` for a keyed mode, or `null` when unreadable. */ +async function readStoredSubject(encrypted: string): Promise { + try { + const { decrypted } = await decryptSecret(encrypted) + const parsed = storedSubjectSchema.safeParse(JSON.parse(decrypted)) + return parsed.success ? parsed.data.config.subject : null + } catch { + return null + } +} + +function lockSubject(tx: DbTransaction, subject: string): Promise { + return acquireAdvisoryXactLock( + tx, + 'workload_identity_subject', + `workload-identity-subject:${subject}` + ) +} + +/** Throws unless no live keyless credential of the owner other than `credentialId` holds `subject`. */ +async function assertNoOtherCredentialHolds( + tx: DbTransaction, + input: { subject: string; scope: ResourceScope; credentialId?: string } +): Promise { + const rows = await tx + .select({ + id: credential.id, + displayName: credential.displayName, + encryptedServiceAccountKey: credential.encryptedServiceAccountKey, + }) + .from(credential) + .where( + and( + resourceScopeCondition(credential, input.scope), + inArray(credential.providerId, KEYLESS_PROVIDER_IDS), + isNull(credential.revokedAt), + input.credentialId ? ne(credential.id, input.credentialId) : undefined + ) + ) + for (const row of rows) { + if (!row.encryptedServiceAccountKey) continue + const storedSubject = await readStoredSubject(row.encryptedServiceAccountKey) + if (storedSubject === null) { + logger.warn('Unreadable keyless credential blocks a subject uniqueness check', { + credentialId: row.id, + }) + throw new WorkloadIdentitySubjectError( + `Sim could not read the saved credential "${row.displayName}", so it cannot confirm this trust setup is unused. Reconnect or delete that credential, then try again.` + ) + } + if (storedSubject === input.subject) { + throw new WorkloadIdentitySubjectError( + 'Another credential already uses this trust setup. Start the keyless connection again.' + ) + } + } +} + +/** + * Refuses a subject that another live credential of the same owner already + * holds. A proof stays valid for a day and is not tied to one credential, so + * without this a member removed from a keyless credential could save a second + * credential with the same subject and keep assuming the customer's role. + * + * The subject lives only inside each credential's encrypted secret, so this + * decrypts the owner's keyless credentials (one owner holds few) rather than + * relying on a unique index. It runs inside the transaction that writes the + * credential: the advisory lock holds concurrent saves of one subject until + * commit, so the later save sees the earlier row. A sibling whose secret cannot + * be read might hold the subject, so it refuses the save rather than skip it. + */ +export async function assertWorkloadIdentitySubjectUnclaimed( + tx: DbTransaction, + input: { subject: string; scope: ResourceScope } +): Promise { + await lockSubject(tx, input.subject) + await assertNoOtherCredentialHolds(tx, input) +} + +/** + * The reconnect counterpart of {@link assertWorkloadIdentitySubjectUnclaimed}, + * run in the transaction that writes `rotatedSecret` onto `credentialId`. + * + * Whether the reconnect keeps the credential's subject is decided here, from + * the row as it stands under the lock, not from the secret read before + * provider verification: while that verification ran, another reconnect could + * have moved this credential to a different subject and a second credential + * could have claimed the old one, so writing it back unchecked would leave two + * credentials holding it. Locks the subject first, then the credential row; no + * path takes a subject lock while holding a credential row, so the order cannot + * deadlock. + */ +export async function assertReconnectedSubjectUnclaimed( + tx: DbTransaction, + input: { rotatedSecret: string; scope: ResourceScope; credentialId: string } +): Promise { + const subject = await readStoredSubject(input.rotatedSecret) + if (!subject) return + await lockSubject(tx, subject) + const [current] = await tx + .select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey }) + .from(credential) + .where(eq(credential.id, input.credentialId)) + .for('update') + const currentSubject = current?.encryptedServiceAccountKey + ? await readStoredSubject(current.encryptedServiceAccountKey) + : undefined + if (currentSubject === subject) return + await assertNoOtherCredentialHolds(tx, { + subject, + scope: input.scope, + credentialId: input.credentialId, + }) +} diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index 286e91edb6f..a413db2d390 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -3624,12 +3624,13 @@ export class PauseResumeManager { entry.contextId === contextId && (entry.status === 'claimed' || entry.status === 'pending') ) - // The selected pause point's full `response.data` and `display` are already - // returned via `pausePoint` below; strip both from `execution.pausePoints` so a - // large HITL display payload isn't duplicated in full within the same response. + // The selected pause point's full `response.data`, `display`, and `inputFields` + // are already returned via `pausePoint` below; strip them from + // `execution.pausePoints` so a large HITL payload isn't duplicated in full + // within the same response. const execution: PausedExecutionDetail = { ...detail, - pausePoints: detail.pausePoints.map(({ display, ...point }) => + pausePoints: detail.pausePoints.map(({ display, inputFields, ...point }) => point.response ? { ...point, response: { ...point.response, data: undefined } } : point ), } diff --git a/apps/sim/lib/workflows/executor/pause-persistence.integration.ts b/apps/sim/lib/workflows/executor/pause-persistence.integration.ts index df03f717ae8..352589ee73b 100644 --- a/apps/sim/lib/workflows/executor/pause-persistence.integration.ts +++ b/apps/sim/lib/workflows/executor/pause-persistence.integration.ts @@ -445,7 +445,7 @@ describe('readPausedRunPoints', () => { } }) - it('returns the selected point display once, not again in every listed point', async () => { + it('returns the selected point display and input fields once, not again in every listed point', async () => { const registry = registryWithResolvedSecret() const executionId = await publishPausedRun([ humanBranchPoint(0, registry), @@ -461,9 +461,11 @@ describe('readPausedRunPoints', () => { expect(detail?.pausePoint.display).toEqual({ responseStructure: [{ name: 'summary', type: 'string', value: 'Token is {{API_KEY}}' }], }) + expect(detail?.pausePoint.inputFields).toHaveLength(1) expect(detail?.execution.pausePoints).toHaveLength(2) for (const point of detail?.execution.pausePoints ?? []) { expect(point).not.toHaveProperty('display') + expect(point).not.toHaveProperty('inputFields') } }) diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 92494ad0d48..0b62ae529b1 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -3971,7 +3971,7 @@ export const PROVIDER_DEFINITIONS: Record = { id: 'nvidia', name: 'NVIDIA NIM', description: "NVIDIA's Nemotron models via NIM's OpenAI-compatible API", - defaultModel: 'nvidia/nemotron-3-super-120b-a12b', + defaultModel: 'nvidia/nemotron-3-ultra-550b-a55b', modelPatterns: [/^nvidia\//], icon: NvidiaIcon, color: '#77B900', @@ -4069,7 +4069,7 @@ export const PROVIDER_DEFINITIONS: Record = { }, contextWindow: 1048576, releaseDate: '2026-03-11', - recommended: true, + sunset: { status: 'deprecated' }, }, { id: 'nvidia/nemotron-3-ultra-550b-a55b', diff --git a/apps/sim/providers/pi-providers.test.ts b/apps/sim/providers/pi-providers.test.ts index 0d54440f763..70dbeebcb1c 100644 --- a/apps/sim/providers/pi-providers.test.ts +++ b/apps/sim/providers/pi-providers.test.ts @@ -32,11 +32,15 @@ describe('Pi provider catalog', () => { ) }) - it('rejects saved model IDs removed by Pi 1.0 without substituting another model', () => { + it('rejects saved model IDs removed by Pi without substituting another model', () => { expect(resolvePiModelId('anthropic', 'claude-opus-4-1')).toBeUndefined() expect(resolvePiModelId('cerebras', 'cerebras/zai-glm-4.7')).toBeUndefined() expect(resolvePiModelId('zai', 'glm-5.1')).toBeUndefined() expect(resolvePiModelId('zai', 'glm-4.5-air')).toBeUndefined() + expect(resolvePiModelId('mistral', 'mistral-large-2411')).toBeUndefined() + expect(resolvePiModelId('mistral', 'devstral-small-2507')).toBeUndefined() + expect(resolvePiModelId('mistral', 'devstral-medium-2507')).toBeUndefined() + expect(resolvePiModelId('nvidia', 'nvidia/nemotron-3-super-120b-a12b')).toBeUndefined() }) it('rejects provider/model pairs absent from the installed Pi catalog', () => { diff --git a/apps/sim/tools/shared/saved-credentials.ts b/apps/sim/tools/shared/saved-credentials.ts index a100c0a4cf3..e09521e5b5c 100644 --- a/apps/sim/tools/shared/saved-credentials.ts +++ b/apps/sim/tools/shared/saved-credentials.ts @@ -78,12 +78,9 @@ export function isSavedCredentialPicker( */ export function credentialSuppliedParamsFor( oauth: Pick | undefined, - subBlocks: readonly Pick[] + subBlocks: readonly Pick[] ): ReadonlySet { - const hasPicker = subBlocks.some( - (subBlock) => subBlock.type === 'oauth-input' && subBlock.canonicalParamId === 'oauthCredential' - ) - return new Set(hasPicker ? oauth?.credentialSuppliedParams : []) + return new Set(subBlocks.some(isSavedCredentialPicker) ? oauth?.credentialSuppliedParams : []) } /** diff --git a/bun.lock b/bun.lock index fc707d68286..ec60d0537a9 100644 --- a/bun.lock +++ b/bun.lock @@ -723,7 +723,10 @@ "dependencies": { "@aws-sdk/client-secrets-manager": "3.1150.0", "@sim/logger": "workspace:*", + "@sim/security": "workspace:*", "@sim/utils": "workspace:*", + "@smithy/fetch-http-handler": "5.8.0", + "@smithy/node-http-handler": "4.12.1", }, "devDependencies": { "@sim/tsconfig": "workspace:*", diff --git a/packages/runtime-secrets/package.json b/packages/runtime-secrets/package.json index eceaa6335e8..8be0b047139 100644 --- a/packages/runtime-secrets/package.json +++ b/packages/runtime-secrets/package.json @@ -31,7 +31,10 @@ "dependencies": { "@aws-sdk/client-secrets-manager": "3.1150.0", "@sim/logger": "workspace:*", - "@sim/utils": "workspace:*" + "@sim/security": "workspace:*", + "@sim/utils": "workspace:*", + "@smithy/fetch-http-handler": "5.8.0", + "@smithy/node-http-handler": "4.12.1" }, "devDependencies": { "@sim/tsconfig": "workspace:*", diff --git a/packages/runtime-secrets/src/index.ts b/packages/runtime-secrets/src/index.ts index c1119e69fe0..bd89bf7995f 100644 --- a/packages/runtime-secrets/src/index.ts +++ b/packages/runtime-secrets/src/index.ts @@ -1,9 +1,15 @@ import type { GetSecretValueCommandOutput } from '@aws-sdk/client-secrets-manager' import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager' import { createLogger } from '@sim/logger' +import { + isEnvironmentProxyConfigured, + resolveEnvironmentProxy, +} from '@sim/security/environment-proxy' import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { backoffWithJitter } from '@sim/utils/retry' +import { FetchHttpHandler } from '@smithy/fetch-http-handler' +import { NodeHttpHandler } from '@smithy/node-http-handler' const logger = createLogger('RuntimeSecrets') @@ -56,9 +62,11 @@ export async function loadRuntimeSecrets(): Promise { * times, then throws, so a caller never proceeds on a partial or missing secret. */ export async function fetchRuntimeSecrets(secretId: string): Promise> { - const client = new SecretsManagerClient( - process.env.AWS_REGION ? { region: process.env.AWS_REGION } : {} - ) + const requestHandler = proxyAwareRequestHandler() + const client = new SecretsManagerClient({ + ...(process.env.AWS_REGION ? { region: process.env.AWS_REGION } : {}), + ...(requestHandler ? { requestHandler } : {}), + }) const entries = parseSecretJson(await fetchSecretString(client, secretId)) return Object.fromEntries( Object.entries(entries).map(([key, value]) => [ @@ -68,6 +76,55 @@ export async function fetchRuntimeSecrets(secretId: string): Promise { + const url = input instanceof Request ? input.url : String(input) + const proxied: RequestInit & { proxy?: string } = { + ...init, + proxy: resolveEnvironmentProxy(new URL(url)) ?? undefined, + } + return fetch(input, proxied) + }, + }) + } + + override handle(...args: Parameters) { + const [request, options] = args + const port = request.port ? `:${request.port}` : '' + const target = new URL(`${request.protocol}//${request.hostname}${port}`) + if (resolveEnvironmentProxy(target)) return super.handle(request, options) + return this.direct.handle(request, options) + } + + override destroy() { + this.direct.destroy() + super.destroy() + } +} + +/** + * The SDK's default Node handler ignores `HTTPS_PROXY`, so a container whose only egress is a + * proxy could not fetch its config and would never boot. Both container bootstraps run on Bun, + * so while a proxy is set this returns an {@link EnvironmentProxyHandler}. The app's + * `awsRequestHandler` lives in `apps/sim`, which packages cannot import. Undefined otherwise, + * leaving the SDK default in place. + */ +function proxyAwareRequestHandler(): EnvironmentProxyHandler | undefined { + if (!process.versions.bun || !isEnvironmentProxyConfigured()) return undefined + return new EnvironmentProxyHandler() +} + async function fetchSecretString(client: SecretsManagerClient, secretId: string): Promise { const response = await sendWithRetry(client, secretId) if (!response.SecretString) { diff --git a/packages/runtime-secrets/src/proxy-egress.test.ts b/packages/runtime-secrets/src/proxy-egress.test.ts new file mode 100644 index 00000000000..8215d0d5c08 --- /dev/null +++ b/packages/runtime-secrets/src/proxy-egress.test.ts @@ -0,0 +1,74 @@ +import { execFile } from 'node:child_process' +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' + +const tunnels: string[] = [] +let proxy: Server +let proxyUrl = '' + +beforeAll(async () => { + proxy = createServer((_req, res) => res.end()) + proxy.on('connect', (req, socket) => { + socket.on('error', () => {}) + tunnels.push(String(req.url)) + socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n') + }) + await new Promise((resolve) => proxy.listen(0, '127.0.0.1', resolve)) + proxyUrl = `http://127.0.0.1:${(proxy.address() as AddressInfo).port}` +}) + +beforeEach(() => { + tunnels.length = 0 +}) + +afterAll(async () => { + proxy.closeAllConnections() + await new Promise((resolve) => proxy.close(() => resolve())) +}) + +/** Runs `loadRuntimeSecrets` in a real Bun process against a Secrets Manager endpoint only the proxy could reach. */ +function loadUnderBun(proxyEnv: Record) { + const entry = fileURLToPath(new URL('./index.ts', import.meta.url)) + return promisify(execFile)( + 'bun', + ['--no-env-file', '-e', `await (await import(${JSON.stringify(entry)})).loadRuntimeSecrets()`], + { + timeout: 20_000, + env: { + PATH: process.env.PATH, + HOME: process.env.HOME, + SIM_ENV_SECRET_ID: '/test/sim/env-vars', + AWS_REGION: 'us-east-1', + AWS_ACCESS_KEY_ID: 'AKIDEXAMPLE', + AWS_SECRET_ACCESS_KEY: 'secret', + AWS_CONFIG_FILE: '/dev/null', + AWS_SHARED_CREDENTIALS_FILE: '/dev/null', + AWS_ENDPOINT_URL_SECRETS_MANAGER: 'https://secrets.invalid', + ...proxyEnv, + }, + } + ) +} + +describe('loadRuntimeSecrets under a proxy-only egress', () => { + it.each(['HTTPS_PROXY', 'HTTP_PROXY'])( + 'reaches Secrets Manager through %s under the Bun runtime', + async (name) => { + await expect(loadUnderBun({ [name]: proxyUrl })).rejects.toThrow( + /Failed to fetch runtime secrets/ + ) + expect(tunnels).toContain('secrets.invalid:443') + }, + 30_000 + ) + + it('connects directly when NO_PROXY names the Secrets Manager host', async () => { + await expect(loadUnderBun({ HTTP_PROXY: proxyUrl, NO_PROXY: '.invalid' })).rejects.toThrow( + /Failed to fetch runtime secrets/ + ) + expect(tunnels).toEqual([]) + }, 30_000) +}) diff --git a/packages/security/package.json b/packages/security/package.json index 5d20bd94efd..b6931864745 100644 --- a/packages/security/package.json +++ b/packages/security/package.json @@ -26,6 +26,10 @@ "types": "./src/encryption.ts", "default": "./src/encryption.ts" }, + "./environment-proxy": { + "types": "./src/environment-proxy.ts", + "default": "./src/environment-proxy.ts" + }, "./hash": { "types": "./src/hash.ts", "default": "./src/hash.ts" diff --git a/packages/security/src/environment-proxy.ts b/packages/security/src/environment-proxy.ts new file mode 100644 index 00000000000..bfd9c6bbfa9 --- /dev/null +++ b/packages/security/src/environment-proxy.ts @@ -0,0 +1,124 @@ +import { BlockList, isIP } from 'node:net' +import { unwrapIpv6Brackets } from './hostnames' + +const DEFAULT_PORTS: Record = { 'http:': 80, 'https:': 443 } + +/** + * Lowercase wins when set, but an empty lowercase variable counts as unset, so it cannot hide + * the uppercase one (undici's `EnvHttpProxyAgent` lets an empty `no_proxy` hide `NO_PROXY`). + */ +function readEnv(lower: string, upper: string): string | undefined { + return process.env[lower]?.trim() || process.env[upper]?.trim() || undefined +} + +/** Whether any `HTTPS_PROXY` / `HTTP_PROXY` is set, before any destination is considered. */ +export function isEnvironmentProxyConfigured(): boolean { + return Boolean(readEnv('https_proxy', 'HTTPS_PROXY') ?? readEnv('http_proxy', 'HTTP_PROXY')) +} + +type NoProxyRule = + | { kind: 'domain'; domain: string; port: number } + | { kind: 'address'; family: 'ipv4' | 'ipv6'; addresses: BlockList; port: number } + +function ipFamily(address: string): 'ipv4' | 'ipv6' | null { + const version = isIP(address) + return version === 4 ? 'ipv4' : version === 6 ? 'ipv6' : null +} + +/** + * Parses one `NO_PROXY` entry, or null when it can never match. A bracketed IPv6 address may + * carry a port (`[::1]:8080`); a bare one (`::1`) never does, since its last group reads as one. + */ +function parseNoProxyEntry(entry: string): NoProxyRule | null { + let host = entry + let port = 0 + const bracketed = entry.match(/^\[([^\]]+)\](?::(\d+))?$/) + const withPort = entry.match(/^(.+):(\d+)$/) + if (bracketed) { + host = bracketed[1] + port = bracketed[2] ? Number(bracketed[2]) : 0 + } else if (withPort && isIP(entry.split('/')[0]) !== 6) { + host = withPort[1] + port = Number(withPort[2]) + } + const [address, prefix] = host.split('/') + const family = ipFamily(address) + if (!family) return { kind: 'domain', domain: host.replace(/^\*?\./, '').toLowerCase(), port } + const addresses = new BlockList() + try { + if (prefix === undefined) addresses.addAddress(address, family) + else if (/^\d+$/.test(prefix)) addresses.addSubnet(address, Number(prefix), family) + else return null + } catch { + return null + } + return { kind: 'address', family, addresses, port } +} + +/** The last parsed `NO_PROXY`, so each request reuses its rules until the value changes. */ +let parsedNoProxy: { raw: string; rules: NoProxyRule[] } | null = null + +function noProxyRules(raw: string): NoProxyRule[] { + if (parsedNoProxy?.raw !== raw) { + const rules = raw + .split(/[,\s]/) + .filter(Boolean) + .map(parseNoProxyEntry) + .filter((rule): rule is NoProxyRule => rule !== null) + parsedNoProxy = { raw, rules } + } + return parsedNoProxy.rules +} + +/** + * Whether `NO_PROXY` sends this destination direct, with curl's matching: `*` matches + * everything; `example.com`, `.example.com` and `*.example.com` all match the domain and + * every subdomain; `host:port` and `[v6]:port` match only that port; IP entries match any + * spelling of the address; and `10.0.0.0/8` or `fd00::/8` match IP-literal destinations in + * the range (never a hostname, which is not resolved here). + */ +function isExcludedByNoProxy(hostname: string, port: number): boolean { + const noProxy = readEnv('no_proxy', 'NO_PROXY') + if (!noProxy) return false + if (noProxy === '*') return true + const host = unwrapIpv6Brackets(hostname.toLowerCase()) + const hostFamily = ipFamily(host) + return noProxyRules(noProxy).some((rule) => { + if (rule.port && rule.port !== port) return false + if (rule.kind === 'address') { + return hostFamily === rule.family && rule.addresses.check(host, hostFamily) + } + return host === rule.domain || host.endsWith(`.${rule.domain}`) + }) +} + +/** + * The operator's `HTTPS_PROXY` / `HTTP_PROXY` for a destination, or null when none applies. + * Lowercase names win, an https destination falls back to `HTTP_PROXY`, and `NO_PROXY` + * excludes hosts as {@link isExcludedByNoProxy} describes. Read per call so a value set + * after startup is honored. + */ +export function resolveEnvironmentProxy(target: URL): string | null { + const defaultPort = DEFAULT_PORTS[target.protocol] + if (defaultPort === undefined) return null + const httpProxy = readEnv('http_proxy', 'HTTP_PROXY') + const proxy = + target.protocol === 'https:' ? (readEnv('https_proxy', 'HTTPS_PROXY') ?? httpProxy) : httpProxy + if (!proxy) return null + if (isExcludedByNoProxy(target.hostname, Number(target.port) || defaultPort)) return null + return normalizeProxyUrl(proxy) +} + +/** Accepts a bare `host:port`, as curl does; anything unparseable fails rather than going direct. */ +function normalizeProxyUrl(value: string): string { + let parsed: URL + try { + parsed = new URL(value.includes('://') ? value : `http://${value}`) + } catch { + throw new Error('The configured HTTP(S)_PROXY is not a valid URL') + } + if (DEFAULT_PORTS[parsed.protocol] === undefined) { + throw new Error('The configured HTTP(S)_PROXY must be an http:// or https:// URL') + } + return parsed.href +}