Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion apps/docs/content/docs/api-reference/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Workspace Admins can turn off **Allow personal API keys**. The workspace policy
## Create and store a key [#generating-api-keys]

1. Open **Account settings → Sim API keys** to create a personal key, or **Workspace settings → Sim API keys** to create a workspace key.
2. Select **Create API key**, enter a name, and create it. Workspace-key creation requires workspace **Admin** access and the applicable key-management permission.
2. Select **Create API key** and enter a name. Choose its **Access** and when it **Expires**, then create it. Workspace-key creation requires workspace **Admin** access and the applicable key-management permission.
3. Copy the key and store it in a server-side environment variable such as `SIM_API_KEY`.

<Callout type="warn">
Expand All @@ -33,6 +33,23 @@ Sim shows the complete key once, immediately after creation. Copy it before clos

Delete a key from the same settings page to revoke it. Update applications that used it with a replacement key.

### Access [#key-access]

| Access | Allows |
| --- | --- |
| **Full access** | Everything the key's identity can do: reads, changes, deploys, and workflow runs |
| **Read only** | The operations the [`api:read` scope](#oauth-access-tokens) allows, including searches sent as POST requests and search chat |

A read-only key cannot change workspace resources or run, resume, or cancel workflows. Reads can still cost credits: searches use embedding credits, and search chat uses model credits and keeps a private conversation history. Selector lookups call third-party services with your connected accounts.

A refused request fails with `403` and the message `This API key is read-only and cannot perform this operation`. Where the surface carries a code, it is `READ_ONLY_API_KEY`: in `error.details.code` on the v2 API and the Sim MCP server, in `details.code` on the v1 API and workflow execution, and in `error.data.code` on a workflow MCP server.

Access narrows a key; it never grants more than the key's identity already has. Access is fixed at creation, so create a new key to change it. Keys created before access levels existed have full access. Keys created from the deploy modal or for a workflow MCP server always have full access, because they exist to run workflows. If **Access** isn't shown when you create a key, read-only keys aren't available on your deployment yet.

### Expiration [#key-expiration]

A key expires 30 days, 90 days, or 1 year after creation, or never. The settings page defaults to never. After it expires, every request with the key fails with `401`. The v1 and v2 APIs, both MCP servers, and workflow execution answer with the message `API key has expired`. Create a replacement before then. The key list shows each key's expiration date. `GET /api/v2/meta` reports the calling key's `expiresAt` and `scopes`.

## Send an API key [#using-api-keys]

Send the key in `X-API-Key`. The v2 API accepts `Authorization: Bearer` only for [OAuth access tokens](#oauth-access-tokens), not API keys. For example, with `SIM_API_KEY` and `WORKFLOW_ID` set:
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/cli/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export SIM_WORKSPACE="2f6d0b1c-8a34-4d92-b7e5-31c8a0f45d67"
sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --input '{"source":"nightly"}' --output json
```

The environment key overrides stored authentication. Use a workspace key only for supported workspace-scoped operations; organization audit logs and fork administration require a personal credential. Never commit credentials. `SIM_CONFIG_DIR` relocates local state when a runner has no writable home directory.
The environment key overrides stored authentication. Use a workspace key only for supported workspace-scoped operations; organization audit logs and fork administration require a personal credential. A job that runs workflows needs a **Full access** key; a **Read only** key suits jobs that only read, such as exporting logs. Choose an expiration your secret rotation covers: an expired key fails with `401` and `API key has expired`. See [key access and expiration](/api-reference/authentication#key-access). Never commit credentials. `SIM_CONFIG_DIR` relocates local state when a runner has no writable home directory.

### GitHub Actions

Expand Down
4 changes: 3 additions & 1 deletion apps/docs/content/docs/mcp/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,10 @@ A personal key uses your current access in the workspaces you can reach. A works

The Cursor process must receive `SIM_API_KEY` for its environment interpolation to work. For a self-hosted deployment, replace the Cloud URL in both examples with your server URL.

For an agent that only reads, create a **Read only** key: it reaches the operations `api:read` allows, and a tool call that changes something or runs a workflow fails with `403` and the code `READ_ONLY_API_KEY`. Search and search chat still use credits. See [key access](/api-reference/authentication#key-access).

<Callout type="warn">
Keys created in Sim settings have no default expiration. Revoke a key when it is no longer needed. Prefer OAuth for an app that
Keys created in Sim settings never expire unless you choose an expiration. Revoke a key when it is no longer needed. Prefer OAuth for an app that
supports browser sign-in, and store keys in your app's secret or environment
settings rather than in a shared config file.
</Callout>
Expand Down
1 change: 1 addition & 0 deletions apps/docs/openapi-v2-billing.json
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,7 @@
"PERMISSION_GROUP_CAPABILITY_BLOCKED",
"INTEGRATION_NOT_ALLOWED",
"INSUFFICIENT_SCOPE",
"READ_ONLY_API_KEY",
"SCIM_MANAGED_MEMBERSHIP"
],
"title": "Forbidden detail code",
Expand Down
1 change: 1 addition & 0 deletions apps/docs/openapi-v2-files-audit.json
Original file line number Diff line number Diff line change
Expand Up @@ -4713,6 +4713,7 @@
"PERMISSION_GROUP_CAPABILITY_BLOCKED",
"INTEGRATION_NOT_ALLOWED",
"INSUFFICIENT_SCOPE",
"READ_ONLY_API_KEY",
"SCIM_MANAGED_MEMBERSHIP"
],
"title": "Forbidden detail code",
Expand Down
1 change: 1 addition & 0 deletions apps/docs/openapi-v2-knowledge.json
Original file line number Diff line number Diff line change
Expand Up @@ -6018,6 +6018,7 @@
"PERMISSION_GROUP_CAPABILITY_BLOCKED",
"INTEGRATION_NOT_ALLOWED",
"INSUFFICIENT_SCOPE",
"READ_ONLY_API_KEY",
"SCIM_MANAGED_MEMBERSHIP"
],
"title": "Forbidden detail code",
Expand Down
1 change: 1 addition & 0 deletions apps/docs/openapi-v2-logs.json
Original file line number Diff line number Diff line change
Expand Up @@ -1065,6 +1065,7 @@
"PERMISSION_GROUP_CAPABILITY_BLOCKED",
"INTEGRATION_NOT_ALLOWED",
"INSUFFICIENT_SCOPE",
"READ_ONLY_API_KEY",
"SCIM_MANAGED_MEMBERSHIP"
],
"title": "Forbidden detail code",
Expand Down
21 changes: 16 additions & 5 deletions apps/docs/openapi-v2-resources.json
Original file line number Diff line number Diff line change
Expand Up @@ -4947,7 +4947,7 @@
"get": {
"operationId": "getApiMeta",
"summary": "Get API status",
"description": "Get the calling credential’s type and expiry. Requires a valid API key or OAuth access token. `v2Enabled` is true whenever this endpoint is served; the response does not enumerate resource permissions.\n\nOAuth scope: `api:read`.",
"description": "Get the calling credential’s type, expiry, and API scopes. Requires a valid API key or OAuth access token. `v2Enabled` is true whenever this endpoint is served; the response does not enumerate resource permissions.\n\nOAuth scope: `api:read`.",
"x-sim-operation": "meta.capabilities.read",
"x-oauth-scope": "api:read",
"tags": ["Meta"],
Expand Down Expand Up @@ -4985,7 +4985,8 @@
"data": {
"v2Enabled": true,
"keyType": "personal",
"expiresAt": null
"expiresAt": null,
"scopes": ["api:read", "api:write"]
}
}
}
Expand Down Expand Up @@ -14807,6 +14808,7 @@
"PERMISSION_GROUP_CAPABILITY_BLOCKED",
"INTEGRATION_NOT_ALLOWED",
"INSUFFICIENT_SCOPE",
"READ_ONLY_API_KEY",
"SCIM_MANAGED_MEMBERSHIP"
],
"title": "Forbidden detail code",
Expand Down Expand Up @@ -18642,9 +18644,17 @@
}
],
"description": "ISO 8601 timestamp when the calling credential expires, or null when it does not."
},
"scopes": {
"type": "array",
"items": {
"type": "string",
"enum": ["api:read", "api:write", "search:read"]
},
"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."
}
},
"required": ["v2Enabled", "keyType", "expiresAt"],
"required": ["v2Enabled", "keyType", "expiresAt", "scopes"],
"additionalProperties": false,
"title": "API status",
"description": "API availability and lifecycle facts about the calling credential."
Expand All @@ -18660,13 +18670,14 @@
"required": ["data"],
"additionalProperties": false,
"title": "API status response",
"description": "API availability, credential type, and expiry for the caller.",
"description": "API availability, credential type, expiry, and scopes for the caller.",
"examples": [
{
"data": {
"v2Enabled": true,
"keyType": "personal",
"expiresAt": null
"expiresAt": null,
"scopes": ["api:read", "api:write"]
}
}
]
Expand Down
1 change: 1 addition & 0 deletions apps/docs/openapi-v2-tables.json
Original file line number Diff line number Diff line change
Expand Up @@ -5977,6 +5977,7 @@
"PERMISSION_GROUP_CAPABILITY_BLOCKED",
"INTEGRATION_NOT_ALLOWED",
"INSUFFICIENT_SCOPE",
"READ_ONLY_API_KEY",
"SCIM_MANAGED_MEMBERSHIP"
],
"title": "Forbidden detail code",
Expand Down
1 change: 1 addition & 0 deletions apps/docs/openapi-v2-workflows.json
Original file line number Diff line number Diff line change
Expand Up @@ -7784,6 +7784,7 @@
"PERMISSION_GROUP_CAPABILITY_BLOCKED",
"INTEGRATION_NOT_ALLOWED",
"INSUFFICIENT_SCOPE",
"READ_ONLY_API_KEY",
"SCIM_MANAGED_MEMBERSHIP"
],
"title": "Forbidden detail code",
Expand Down
47 changes: 47 additions & 0 deletions apps/sim/app/account/settings/layout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { ReactNode } from 'react'
import { authMockFns } from '@sim/testing'
import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock'
import { nextNavigationMock } from '@sim/testing/mocks/next-navigation.mock'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('next/navigation', () => nextNavigationMock)
vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock)
vi.mock('@/lib/permissions/super-user', () => ({ isPlatformAdmin: vi.fn(async () => false) }))
vi.mock('@/components/settings/standalone-settings-shell', () => ({
StandaloneSettingsShell: ({ children }: { children: ReactNode }) => children,
}))

import AccountSettingsLayout from '@/app/account/settings/layout'
import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'

/**
* Renders the account layout around a section that reads the read-only key flag the way the
* personal API keys page's create modal does on every render, and returns what it read.
*/
async function readFlagInAccountSettings(): Promise<boolean> {
let observed: boolean | undefined
function ReadOnlyKeysSection() {
observed = useFeatureFlag('api-key-read-only')
return null
}
renderToStaticMarkup(await AccountSettingsLayout({ children: <ReadOnlyKeysSection /> }))
if (observed === undefined) throw new Error('The account section never rendered')
return observed
}

describe('AccountSettingsLayout', () => {
beforeEach(() => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'viewer-1' },
session: { id: 'session-1' },
})
})

it.each([true, false])('gives account sections the read-only key flag (%s)', async (enabled) => {
featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(
async (flag) => flag === 'api-key-read-only' && enabled
)
expect(await readFlagInAccountSettings()).toBe(enabled)
})
})
31 changes: 24 additions & 7 deletions apps/sim/app/account/settings/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,38 @@
import { redirect } from 'next/navigation'
import { StandaloneSettingsShell } from '@/components/settings/standalone-settings-shell'
import { isReadOnlyApiKeyCreationEnabled } from '@/lib/api-key/feature-flag'
import { getSession } from '@/lib/auth'
import { resolveDeploymentShape } from '@/lib/core/config/deployment-shape'
import { isPlatformAdmin } from '@/lib/permissions/super-user'
import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'

export default async function AccountSettingsLayout({ children }: { children: React.ReactNode }) {
const session = await getSession()
if (!session?.user) redirect('/login')
const isSuperUser = await isPlatformAdmin(session.user.id)
const [isSuperUser, readOnlyApiKeysEnabled] = await Promise.all([
isPlatformAdmin(session.user.id),
isReadOnlyApiKeyCreationEnabled(),
])

/** Account settings host no workspace surface, so only the flags its sections read are on. */
return (
<StandaloneSettingsShell
plane='account'
isSuperUser={isSuperUser}
deployment={resolveDeploymentShape()}
<FeatureFlagsProvider
flags={{
dashboards: false,
'workflow-tests': false,
changelog: false,
'mothership-model-selector': false,
'mothership-plan-mode': false,
'api-key-read-only': readOnlyApiKeysEnabled,
}}
>
{children}
</StandaloneSettingsShell>
<StandaloneSettingsShell
plane='account'
isSuperUser={isSuperUser}
deployment={resolveDeploymentShape()}
>
{children}
</StandaloneSettingsShell>
</FeatureFlagsProvider>
)
}
9 changes: 8 additions & 1 deletion apps/sim/app/api/mcp/discover/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { workflowMcpServer, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { API_KEY_EXPIRED } from '@/lib/api-key/policy-messages'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
Expand All @@ -21,7 +22,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => {

if (!auth.success || !auth.userId) {
return NextResponse.json(
{ success: false, error: 'Authentication required. Provide X-API-Key header.' },
{
success: false,
error:
auth.error === API_KEY_EXPIRED
? API_KEY_EXPIRED
: 'Authentication required. Provide X-API-Key header.',
},
{ status: 401 }
)
}
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/mcp/serve/[serverId]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ const PERSONAL_API_KEY_PRINCIPAL = {
kind: 'personal_api_key',
userId: 'user-1',
keyId: 'personal-key-1',
access: 'write',
} as const

const OAUTH_WRITE_PRINCIPAL = {
Expand Down
38 changes: 32 additions & 6 deletions apps/sim/app/api/mcp/serve/[serverId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import {
SUPPORTED_PROTOCOL_VERSIONS,
type Tool,
} from '@modelcontextprotocol/sdk/types.js'
import { isUserCredentialPrincipal, type WorkflowExecutionPrincipal } from '@sim/auth/principal'
import {
isReadOnlyApiKeyPrincipal,
isUserCredentialPrincipal,
type WorkflowExecutionPrincipal,
} from '@sim/auth/principal'
import { db } from '@sim/db'
import {
workflow,
Expand All @@ -37,7 +41,11 @@ import {
mcpServeRouteParamsSchema,
mcpToolCallParamsSchema,
} from '@/lib/api/contracts/mcp'
import { PERSONAL_KEY_DENIED } from '@/lib/api-key/policy-messages'
import {
API_KEY_EXPIRED,
PERSONAL_KEY_DENIED,
READ_ONLY_API_KEY_DENIED,
} from '@/lib/api-key/policy-messages'
import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid'
import {
InvalidOAuthAccessTokenError,
Expand Down Expand Up @@ -483,10 +491,14 @@ async function resolveWorkflowMcpBillingAttribution(
* A 401 that points OAuth clients at this server's protected-resource metadata,
* so they can discover Sim's authorization server and start the flow.
*/
function unauthorizedResponse(serverId: string, invalidToken = false): NextResponse {
function unauthorizedResponse(
serverId: string,
invalidToken = false,
error?: string
): NextResponse {
return withWorkflowMcpAuthChallenge(
NextResponse.json(
{ error: invalidToken ? 'Invalid access token' : 'Unauthorized' },
{ error: invalidToken ? 'Invalid access token' : (error ?? 'Unauthorized') },
{
status: 401,
...(invalidToken && { headers: { 'WWW-Authenticate': 'Bearer error="invalid_token"' } }),
Expand Down Expand Up @@ -561,7 +573,10 @@ async function authorizeMcpServeRequest(
}
}
if (!auth.success || !auth.userId) {
return { response: unauthorizedResponse(server.id) }
const expired = auth.error === API_KEY_EXPIRED
return {
response: unauthorizedResponse(server.id, false, expired ? API_KEY_EXPIRED : undefined),
}
}
if (!auth.principal) {
throw new Error('Authenticated MCP request is missing its principal')
Expand Down Expand Up @@ -604,13 +619,24 @@ async function authorizeMcpServeRequest(
}
}

/** Calling a tool runs a workflow, so an OAuth token needs `api:write`. */
/**
* Calling a tool runs a workflow, so an OAuth token needs `api:write` and a
* read-only API key is refused outright: it has no scope to step up to.
*/
function insufficientToolCallScopeResponse(
id: RequestId,
serverId: string,
executeAuthContext: ExecuteAuthContext | null
): NextResponse | null {
const principal = executeAuthContext?.principal
if (principal && isReadOnlyApiKeyPrincipal(principal)) {
return NextResponse.json(
createError(id, ErrorCode.InvalidRequest, READ_ONLY_API_KEY_DENIED, {
code: 'READ_ONLY_API_KEY',
}),
{ status: 403 }
)
}
if (principal?.kind !== 'oauth_access_token') return null
if (oauthScopeSatisfies(principal.scopes, OAUTH_API_WRITE_SCOPE)) return null
return insufficientScopeResponse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ describe('POST /api/resume/[workflowId]/[executionId]/[contextId]', () => {
success: true,
keyId: WORKSPACE_KEY_PRINCIPAL.keyId,
keyType: 'workspace',
access: 'write',
workspaceId: WORKSPACE_ID,
})
mockResolvePermission.mockResolvedValue('write')
Expand Down Expand Up @@ -250,6 +251,7 @@ describe('POST /api/resume/[workflowId]/[executionId]/[contextId]', () => {
success: true,
keyId: 'personal-key',
keyType: 'personal',
access: 'write',
userId: 'read-only-member',
}),
},
Expand Down
Loading
Loading