Skip to content

Commit d095f67

Browse files
committed
Merge remote-tracking branch 'origin/staging' into wip/platform-org-wiring
2 parents a1de8e2 + 9b10389 commit d095f67

65 files changed

Lines changed: 1418 additions & 182 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/docs/MACHINE_ACCESS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ Continue until `nextCursor` is `null`. Unknown cursors and invalid parameters re
3333

3434
Configure a remote Streamable HTTP server at `https://docs.sim.ai/api/mcp`. This endpoint is separate from Sim's workspace MCP server and `/mcp/...` documentation pages. It requires no API key.
3535

36-
The official stable MCP TypeScript SDK 2.3.1 serves the 2026-07-28 protocol and stateless compatibility for 2025-era clients. Tools:
36+
The official stable MCP TypeScript SDK 2.4.0 serves the 2026-07-28 protocol and stateless compatibility for 2025-era clients. Tools:
3737

3838
| Tool | Input | Result |
3939
| --- | --- | --- |

‎apps/docs/content/docs/platform/self-hosting/networking.mdx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ This depends on the runtime. The published images run Bun. If you build the stan
278278
| Everything through the SSRF guard — SSO OIDC discovery, the HTTP block, tools, connectors, outbound webhooks, content fetches, MCP servers, and model endpoints typed into a block | Yes — see [Proxying guarded requests](#proxying-guarded-requests) |
279279
| SSO sign-in — the token, JWKS, and userinfo calls made while a user signs in | Yes — they use the default `fetch` |
280280
| SSO domain verification (TXT lookup) | No — DNS, sent to `1.1.1.1` and `8.8.8.8` unless `DOMAIN_VERIFICATION_NAMESERVERS` names your resolvers |
281-
| Amazon Bedrock, object storage on S3, and every AWS integration (DynamoDB, SQS, Lambda, Textract, and the rest) | Yes — each AWS client gets a proxy-aware agent while a proxy is set. Bedrock then uses HTTP/1.1 instead of HTTP/2 |
281+
| Amazon Bedrock, object storage on S3, and every AWS integration (DynamoDB, SQS, Lambda, Textract, and the rest) | Yes — each AWS service client gets a proxy-aware agent while a proxy is set, and STS calls made for its credentials inherit it. Bedrock then uses HTTP/1.1 instead of HTTP/2. IAM Identity Center (SSO) credential lookups do not use the proxy |
282282
| Email via SMTP, and the SMTP block | No — SMTP is not HTTP. Use a mail relay the app can reach directly, or Resend or SES |
283283
| Email via Amazon SES | Yes — the AWS SDK client above |
284284
| OTLP export from the server SDK | Yes — the exporters get the same proxy-aware agent |
@@ -289,7 +289,7 @@ The practical consequence: a mandatory-egress-proxy environment can route LLM tr
289289
290290
### Proxying guarded requests
291291
292-
A request through the SSRF guard keeps its checks when it is proxied. Before each request, and again for each redirect hop, the app resolves the destination's hostname with its own DNS and refuses it if any policy in [Security](/platform/self-hosting/security#the-ssrf-boundary) blocks the address. It then hands the hostname, not the address, to the proxy, so a proxy that allowlists by domain sees the name it expects.
292+
A request through the SSRF guard keeps its checks when it is proxied. Before each request, and again for each redirect hop, the app resolves the destination's hostname with its own DNS and refuses it if any policy in [Security](/platform/self-hosting/security#the-ssrf-boundary) blocks any of the addresses it resolves to, since the proxy may connect to any of them. It then hands the hostname, not the address, to the proxy, so a proxy that allowlists by domain sees the name it expects.
293293
294294
- **DNS must still resolve inside the container.** A destination that the app cannot resolve is refused before the proxy is asked.
295295
- **The proxy resolves the name again.** The app cannot pin the address across a proxy, so the proxy's own egress policy is the last check.
@@ -301,6 +301,8 @@ A request through the SSRF guard keeps its checks when it is proxied. Before eac
301301
302302
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.
303303
304+
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.
305+
304306
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:
305307
306308
```yaml

‎apps/docs/openapi-v2-resources.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18651,7 +18651,7 @@
1865118651
"type": "string",
1865218652
"enum": ["api:read", "api:write", "search:read"]
1865318653
},
18654-
"description": "API scopes the calling credential holds. A read-only API key holds only api:read: it is refused every operation that changes state and every operation that declares no API scope, so api:read alone does not authorize those. A full-access key holds api:read and api:write; an OAuth access token holds what it was granted."
18654+
"description": "API scopes the calling credential can use. api:write implies api:read, and either implies search:read. A read-only API key holds api:read and search:read: it is refused every operation that changes state and every operation that declares no API scope, so api:read alone does not authorize those. A full-access key holds all three; an OAuth access token holds what it was granted and what that implies."
1865518655
}
1865618656
},
1865718657
"required": ["v2Enabled", "keyType", "expiresAt", "scopes"],
@@ -23929,7 +23929,7 @@
2392923929
"type": "string",
2393023930
"minLength": 1,
2393123931
"maxLength": 4194304,
23932-
"description": "UTF-8 CSV contents, up to 4 MiB."
23932+
"description": "UTF-8 CSV contents, up to 4 MiB. Both files travel in one request body limited to 10 MiB after JSON encoding."
2393323933
}
2393423934
},
2393523935
"required": ["filename", "content"],
@@ -23949,7 +23949,7 @@
2394923949
"type": "string",
2395023950
"minLength": 1,
2395123951
"maxLength": 4194304,
23952-
"description": "UTF-8 CSV contents, up to 4 MiB."
23952+
"description": "UTF-8 CSV contents, up to 4 MiB. Both files travel in one request body limited to 10 MiB after JSON encoding."
2395323953
}
2395423954
},
2395523955
"required": ["filename", "content"],

‎apps/docs/openapi-v2-workflows.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3958,7 +3958,7 @@
39583958
"post": {
39593959
"operationId": "executeWorkflowV2",
39603960
"summary": "Execute workflow",
3961-
"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`.",
3961+
"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`.",
39623962
"x-sim-operation": "workflows.execute",
39633963
"x-oauth-scope": "api:write",
39643964
"tags": ["Workflows"],

‎apps/sim/app/api/auth/oauth/token/route.test.ts‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
createMockRequest,
88
hybridAuthMockFns,
99
} from '@sim/testing'
10+
import { createRouteContext } from '@sim/testing/helpers/http'
1011
import { NextRequest } from 'next/server'
1112
import { beforeEach, describe, expect, it, vi } from 'vitest'
1213

@@ -36,11 +37,13 @@ vi.mock('@/lib/credentials/application/resolve-managed-oauth-token', () => ({
3637
resolveManagedOAuthCredentialToken: { execute: mockResolveManagedOAuthCredentialToken },
3738
}))
3839

40+
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
3941
import { createQuickBooksAccountId } from '@/lib/oauth/quickbooks'
4042
import { GET, POST } from '@/app/api/auth/oauth/token/route'
4143
import { getToolMetadata } from '@/tools/metadata'
4244

4345
const mockGetToolMetadata = vi.mocked(getToolMetadata)
46+
const mockCheckSessionOrInternalAuth = vi.mocked(checkSessionOrInternalAuth)
4447

4548
describe('OAuth Token API Routes', () => {
4649
beforeEach(() => {
@@ -290,6 +293,116 @@ describe('OAuth Token API Routes', () => {
290293
})
291294
})
292295

296+
describe('saved service-account credentials', () => {
297+
const authorized = {
298+
ok: true,
299+
requesterUserId: 'test-user-id',
300+
credentialOwnerUserId: 'test-user-id',
301+
workspaceId: 'workspace-id',
302+
}
303+
304+
beforeEach(() => {
305+
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce({
306+
accountId: '',
307+
credentialId: 'saved-credential-id',
308+
credentialType: 'service_account',
309+
providerId: 'vanta-service-account',
310+
workspaceId: 'workspace-id',
311+
usedCredentialTable: true,
312+
})
313+
authOAuthUtilsMockFns.mockResolveServiceAccountToken.mockResolvedValue({
314+
accessToken: 'saved-secret',
315+
})
316+
mockGetToolMetadata.mockReturnValue({
317+
id: 'vanta_list_tests',
318+
params: {},
319+
oauth: { required: true, provider: 'vanta', credentialKind: 'service-account' },
320+
})
321+
})
322+
323+
it('refuses to release the secret to a session caller', async () => {
324+
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
325+
success: true,
326+
authType: AuthType.SESSION,
327+
userId: 'test-user-id',
328+
})
329+
mockAuthorizeCredentialUse.mockResolvedValueOnce(authorized)
330+
331+
const response = await POST(
332+
createMockRequest('POST', {
333+
credentialId: 'saved-credential-id',
334+
toolId: 'vanta_list_tests',
335+
}),
336+
createRouteContext({})
337+
)
338+
339+
expect(response.status).toBe(403)
340+
const body = await response.json()
341+
expect(body).toMatchObject({ code: 'SERVICE_ACCOUNT_SESSION_FORBIDDEN' })
342+
expect(JSON.stringify(body)).not.toContain('saved-secret')
343+
expect(authOAuthUtilsMockFns.mockResolveServiceAccountToken).not.toHaveBeenCalled()
344+
})
345+
346+
it('resolves the secret for an internal-JWT execution caller', async () => {
347+
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
348+
success: true,
349+
authType: AuthType.INTERNAL_JWT,
350+
userId: 'test-user-id',
351+
})
352+
mockAuthorizeCredentialUse.mockResolvedValueOnce(authorized)
353+
354+
const response = await POST(
355+
createMockRequest('POST', {
356+
credentialId: 'saved-credential-id',
357+
toolId: 'vanta_list_tests',
358+
workflowId: 'workflow-id',
359+
}),
360+
createRouteContext({})
361+
)
362+
363+
expect(response.status).toBe(200)
364+
await expect(response.json()).resolves.toMatchObject({
365+
accessToken: 'saved-secret',
366+
credentialType: 'service_account',
367+
})
368+
})
369+
})
370+
371+
it('still returns an OAuth token to a session caller', async () => {
372+
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
373+
success: true,
374+
authType: AuthType.SESSION,
375+
userId: 'test-user-id',
376+
})
377+
mockAuthorizeCredentialUse.mockResolvedValueOnce({
378+
ok: true,
379+
requesterUserId: 'test-user-id',
380+
credentialOwnerUserId: 'test-user-id',
381+
})
382+
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
383+
id: 'credential-id',
384+
accessToken: 'test-token',
385+
refreshToken: 'refresh-token',
386+
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
387+
providerId: 'google',
388+
})
389+
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValueOnce({
390+
accessToken: 'session-oauth-token',
391+
refreshed: false,
392+
})
393+
394+
const response = await POST(
395+
createMockRequest('POST', { credentialId: 'credential-id' }),
396+
createRouteContext({})
397+
)
398+
399+
expect(response.status).toBe(200)
400+
await expect(response.json()).resolves.toMatchObject({
401+
accessToken: 'session-oauth-token',
402+
credentialType: 'oauth',
403+
})
404+
})
405+
293406
describe('credentialAccountUserId + providerId path', () => {
294407
it('should reject internal JWT authentication', async () => {
295408
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({

‎apps/sim/lib/api-key/__integration__/read-only-access.integration.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ describe('read-only and expiring API keys in PostgreSQL', () => {
275275
for (const key of [readPersonalKey, readWorkspaceKey]) {
276276
const meta = await call(v2Meta, '/api/v2/meta', { key })
277277
expect(meta.status).toBe(200)
278-
expect(meta.body).toMatchObject({ data: { scopes: ['api:read'] } })
278+
expect(meta.body).toMatchObject({ data: { scopes: ['api:read', 'search:read'] } })
279279
}
280280
const personalMeta = await call(v2Meta, '/api/v2/meta', { key: readPersonalKey })
281281
expect(personalMeta.body).toMatchObject({ data: { expiresAt } })
@@ -417,7 +417,7 @@ describe('read-only and expiring API keys in PostgreSQL', () => {
417417
it('treats a key created before access levels as full access', async () => {
418418
const meta = await call(v2Meta, '/api/v2/meta', { key: legacyKey })
419419
expect(meta.body).toMatchObject({
420-
data: { scopes: ['api:read', 'api:write'], expiresAt: null },
420+
data: { scopes: ['api:read', 'api:write', 'search:read'], expiresAt: null },
421421
})
422422

423423
const tablesBefore = await tableCount()

‎apps/sim/lib/api/contracts/knowledge/gitlab-permissions.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ const gitLabCsvUploadSchema = z
1111
.string()
1212
.min(1)
1313
.max(GITLAB_CSV_MAX_BYTES)
14-
.describe('UTF-8 CSV contents, up to 4 MiB.'),
14+
.describe(
15+
'UTF-8 CSV contents, up to 4 MiB. Both files travel in one request body limited to 10 MiB after JSON encoding.'
16+
),
1517
})
1618
.strict()
1719
export const gitLabPermissionInputSchema = z

‎apps/sim/lib/api/contracts/v2/meta.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const v2MetaSchema = z
2525
scopes: z
2626
.array(z.enum(['api:read', 'api:write', 'search:read']))
2727
.describe(
28-
'API scopes the calling credential holds. A read-only API key holds only api:read: it is refused every operation that changes state and every operation that declares no API scope, so api:read alone does not authorize those. A full-access key holds api:read and api:write; an OAuth access token holds what it was granted.'
28+
'API scopes the calling credential can use. api:write implies api:read, and either implies search:read. A read-only API key holds api:read and search:read: it is refused every operation that changes state and every operation that declares no API scope, so api:read alone does not authorize those. A full-access key holds all three; an OAuth access token holds what it was granted and what that implies.'
2929
),
3030
})
3131
.meta({

0 commit comments

Comments
 (0)